From db1e4349e971358d44c936649aacf3e8dc982700 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Wed, 9 Sep 2026 15:26:27 +0000
Subject: [PATCH 1/2] feat: introduce optional integration discovery during
setup
---
apps/docs/self-hosting.mdx | 12 +
.../(authenticated)/home/OnboardingCard.tsx | 35 +-
.../FastSessionTranscript.client.test.tsx | 125 +++--
.../[sessionId]/FastSessionTranscript.tsx | 7 +
.../SessionUserInputCard.client.test.tsx | 27 +
.../[sessionId]/SessionUserInputCard.tsx | 11 +-
.../SetupIntegrationsCard.client.test.tsx | 260 +++++++++
.../setup/SetupIntegrationsCard.tsx | 349 ++++++++++++
.../components/settings/Integrations.test.tsx | 58 +-
.../src/components/settings/Integrations.tsx | 77 ++-
.../trpc/commands/fast-sessions/index.test.ts | 286 ++++++++++
.../src/trpc/commands/fast-sessions/index.ts | 43 +-
.../trpc/commands/setup/setup-session.test.ts | 498 ++++++++++++++++++
.../src/trpc/commands/setup/setup-session.ts | 266 ++++++++--
.../fast-agent-native-tool-schemas.test.ts | 21 +
.../__tests__/fast-agent-service.test.ts | 103 ++++
.../fast-agent/fast-agent-conversation.ts | 5 +-
.../fast-agent-native-tool-bridge.ts | 5 +-
.../server/fast-agent/fast-agent-prompt.ts | 14 +-
.../server/fast-agent/fast-agent-service.ts | 39 +-
.../fast-agent/fast-agent-setup-tools.test.ts | 30 ++
.../types/src/acp-request-user-input.test.ts | 17 +
packages/types/src/acp.ts | 12 +-
packages/types/src/index.ts | 1 +
.../types/src/onboarding-integrations.test.ts | 186 +++++++
packages/types/src/onboarding-integrations.ts | 203 +++++++
packages/types/src/setup-new.test.ts | 17 +
packages/types/src/setup-new.ts | 12 +
28 files changed, 2551 insertions(+), 168 deletions(-)
create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx
create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx
create mode 100644 apps/web/src/trpc/commands/setup/setup-session.test.ts
create mode 100644 packages/types/src/onboarding-integrations.test.ts
create mode 100644 packages/types/src/onboarding-integrations.ts
diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx
index 8809652286..71c27e9cdd 100644
--- a/apps/docs/self-hosting.mdx
+++ b/apps/docs/self-hosting.mdx
@@ -202,6 +202,18 @@ recommendations. Detailed provider instructions and credential entry open in a
dialog from the source-control card; Roomote never asks for credentials in
chat.
+The conversation also asks briefly about the tools your team uses for
+documents, monitoring, and project tracking, one topic at a time.
+You can skip these questions. The optional integrations card highlights matching
+available connectors and opens their secure configuration without leaving setup.
+Tools without a built-in connector are not presented as supported. Use
+**Continue without connections** to move on; you can connect tools later in
+Settings. Integration choices do not change the starter tasks offered.
+Services that are also source-control, communications, inference, or sandbox
+providers are excluded from this optional step; their separate setup is unchanged.
+The Vercel deployments integration remains available separately from Vercel AI
+Gateway inference.
+
Setup completes once inference and a sandbox provider are ready, source control
is successfully configured, and at least one repository has synchronized.
Roomote then offers preselected starter tasks in one structured multi-select
diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx
index 1a45f0b95e..fd2bb4b1f0 100644
--- a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx
+++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx
@@ -5,7 +5,12 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'motion/react';
import { toast } from 'sonner';
-import { MCP_INTEGRATIONS } from '@roomote/types';
+import {
+ MCP_INTEGRATIONS,
+ ADMIN_INTEGRATION_ORDER,
+ COMMUNICATION_PROVIDER_ORDER,
+ SOURCE_CONTROL_PROVIDER_ORDER,
+} from '@roomote/types';
import { useAuthorizedUser } from '@/hooks/useUser';
import {
@@ -50,19 +55,6 @@ import { TelegramLinkAccountStep } from '@/components/settings/TelegramLinkAccou
const DISMISSED_KEY = 'OnboardingCardsDismissedByOrg';
const DISMISSED_DEPLOYMENT_KEY = 'deployment';
-const ADMIN_INTEGRATION_ORDER = [
- 'notion',
- 'sentry',
- 'linear',
- 'jira',
- 'monday',
- 'vercel',
- 'supabase',
- 'posthog',
- 'grafana',
- 'asana',
-] as const;
-
const PERSONAL_MCP_INTEGRATION_ORDER = ['monday', 'supabase'] as const;
const CARD_EXIT_TRANSITION = {
@@ -82,21 +74,6 @@ const CARD_ANIMATION = {
exit: { opacity: 0, y: -20, transition: CARD_EXIT_TRANSITION },
} as const;
-const COMMUNICATION_PROVIDER_ORDER = [
- 'slack',
- 'microsoft',
- 'telegram',
- 'discord',
-] as const;
-
-const SOURCE_CONTROL_PROVIDER_ORDER = [
- 'github',
- 'gitlab',
- 'gitea',
- 'bitbucket',
- 'ado',
-] as const;
-
type CardConfig = {
id: string;
icon: ReactNode;
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
index e16ac00cd8..7a6e7fb80a 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
@@ -169,6 +169,9 @@ vi.mock('./SessionUserInputCard', async (importOriginal) => ({
vi.mock('./setup/SetupStarterTasksCard', () => ({
SetupStarterTasksCard: () => Setup starter tasks
,
}));
+vi.mock('./setup/SetupIntegrationsCard', () => ({
+ SetupIntegrationsCard: () => Optional integration setup
,
+}));
class FakeEventSource {
static instances: FakeEventSource[] = [];
@@ -436,60 +439,76 @@ describe('FastSessionTranscript', () => {
});
});
- it('removes a structured-input card when its response control event arrives', () => {
- const requestId = 'rui:setup-starters';
- const request = {
- ...textMessage({
- id: 'starter-request',
- role: 'assistant',
- text: 'Choose starter tasks',
- ts: 1,
- }),
- eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
- payload: {
- requestId,
- status: 'pending',
- sessionId: 'session-1',
- turnId: 'turn-1',
- callId: 'call-1',
- preset: 'setup_starter_tasks',
- questions: [
- {
- id: 'starters',
- question: 'What should I work on first?',
- multiple: true,
- isOther: false,
- isSecret: false,
- options: [{ label: 'Speed up CI', description: 'Improve CI.' }],
- },
- ],
- },
- };
- const response = {
- ...textMessage({
- id: 'starter-response',
- role: 'user',
- text: 'Structured response',
- ts: 2,
- }),
- eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
- payload: {
- requestId,
- answers: { starters: { answers: ['Speed up CI'] } },
- resolution: 'submitted',
- },
- };
-
- render(
- ,
- );
+ it.each(['setup_starter_tasks', 'setup_integrations'])(
+ 'renders and removes the %s card when its response control event arrives',
+ (preset) => {
+ const requestId = 'rui:setup-starters';
+ const request = {
+ ...textMessage({
+ id: 'starter-request',
+ role: 'assistant',
+ text: 'Choose starter tasks',
+ ts: 1,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId,
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ preset,
+ questions: [
+ {
+ id: 'starters',
+ question: 'What should I work on first?',
+ multiple: true,
+ isOther: false,
+ isSecret: false,
+ options: [{ label: 'Speed up CI', description: 'Improve CI.' }],
+ },
+ ],
+ },
+ };
+ const response = {
+ ...textMessage({
+ id: 'starter-response',
+ role: 'user',
+ text: 'Structured response',
+ ts: 2,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
+ payload: {
+ requestId,
+ answers: { starters: { answers: ['Speed up CI'] } },
+ resolution: 'submitted',
+ },
+ };
+
+ const { unmount } = render(
+ ,
+ );
+ const cardLabel =
+ preset === 'setup_integrations'
+ ? 'Optional integration setup'
+ : 'Setup starter tasks';
+ expect(screen.getByText(cardLabel)).toBeInTheDocument();
+ unmount();
+ render(
+ ,
+ );
- expect(screen.queryByText('Structured input request')).toBeNull();
- expect(screen.queryByText('Structured response')).toBeNull();
- });
+ expect(screen.queryByText('Structured input request')).toBeNull();
+ expect(screen.queryByText('Structured response')).toBeNull();
+ expect(screen.queryByText(cardLabel)).toBeNull();
+ },
+ );
it.each([
[1, '1 task running'],
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index 701a77111d..5c9482c0db 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -59,6 +59,7 @@ import {
SessionUserInputCard,
} from './SessionUserInputCard';
import { SetupStarterTasksCard } from './setup/SetupStarterTasksCard';
+import { SetupIntegrationsCard } from './setup/SetupIntegrationsCard';
import { SESSION_HEADER_CONTENT_CLASS_NAME } from './session-header-layout';
import {
@@ -811,6 +812,12 @@ export function FastSessionTranscript({
sessionId={sessionId}
request={pendingInputRequest}
/>
+ ) : pendingInputRequest.preset === 'setup_integrations' ? (
+
) : (
{
mockMutate.mockClear();
});
+ it('allows skipping tool discovery before entering an answer', () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Skip tool setup' }));
+ expect(mockMutate).toHaveBeenCalledWith({
+ sessionId: 's',
+ requestId: 'tools',
+ answers: {},
+ resolution: 'cancelled',
+ });
+ });
+
it('requires the minimum number of selections before submitting', () => {
render( );
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx
index 3af15b1246..c1d7479e21 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx
@@ -3,6 +3,8 @@
import { useMemo, useState } from 'react';
import {
parseAcpRequestUserInputPayload,
+ SETUP_INTEGRATION_CATEGORIES,
+ getSetupIntegrationQuestionId,
type AcpRequestUserInputPayload,
} from '@roomote/types';
@@ -356,7 +358,14 @@ export function SessionUserInputCard({
})
}
>
- Cancel
+ {request.questions.some((question) =>
+ SETUP_INTEGRATION_CATEGORIES.some(
+ (category) =>
+ getSetupIntegrationQuestionId(category.id) === question.id,
+ ),
+ )
+ ? 'Skip tool setup'
+ : 'Cancel'}
) : null}
({
+ capture: vi.fn(),
+ mutate: vi.fn(),
+ refetch: vi.fn(),
+ isAdmin: true,
+ error: false,
+ pending: false,
+ submitError: false,
+ submitPending: false,
+ enabled: true,
+ connections: [] as { mcpId: string; authStatus: string }[],
+ enablements: [] as { mcpId: string; enabled: boolean }[],
+ searchParams: new URLSearchParams(),
+}));
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/sessions/setup-session',
+ useSearchParams: () => mocks.searchParams,
+}));
+vi.mock('@/hooks/useUser', () => ({
+ useAuthorizedUser: () => ({ isAdmin: mocks.isAdmin }),
+}));
+vi.mock('@/hooks/useTelemetry', () => ({
+ useTelemetry: () => ({ enabled: true, capture: mocks.capture }),
+}));
+vi.mock('@/trpc/client', () => ({
+ useTRPC: () => ({
+ onboarding: { status: { queryOptions: () => ({}) } },
+ setup: { submitSessionUserInput: { mutationOptions: () => ({}) } },
+ }),
+}));
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: () => ({
+ data: { linkableProviders: [], orgHasLinear: false },
+ refetch: mocks.refetch,
+ isPending: mocks.pending,
+ isError: mocks.error,
+ }),
+ useMutation: () => ({
+ mutate: mocks.mutate,
+ isPending: mocks.submitPending,
+ isError: mocks.submitError,
+ }),
+}));
+vi.mock('@/hooks/mcp-connections', () => ({
+ useConnectMcp: () => ({ mutate: mocks.mutate, isPending: false }),
+ useUserMcpConnections: () => ({
+ data: mocks.connections,
+ refetch: mocks.refetch,
+ }),
+ useDeploymentMcpEnablements: () => ({
+ data: mocks.enablements,
+ refetch: mocks.refetch,
+ }),
+ useCuratedIntegrationsAvailability: () => ({
+ data: { enabled: mocks.enabled },
+ refetch: mocks.refetch,
+ }),
+}));
+vi.mock('@/components/settings/Integrations', () => ({
+ Integrations: ({ integrationIds }: { integrationIds: string[] }) => (
+ Secure configuration: {integrationIds.join(',')}
+ ),
+}));
+
+import { SetupIntegrationsCard } from './SetupIntegrationsCard';
+
+const request: Pick<
+ AcpRequestUserInputPayload,
+ 'requestId' | 'preset' | 'questions'
+> = {
+ requestId: 'integration-request',
+ preset: 'setup_integrations',
+ questions: [
+ {
+ id: 'setup-integrations',
+ header: 'Your tools',
+ question: 'Connect tools or continue',
+ isOther: false,
+ isSecret: false,
+ options: [
+ { id: 'notion', label: 'Notion', description: 'Documents' },
+ { id: 'jira', label: 'Jira', description: 'Issues' },
+ {
+ id: 'google-docs',
+ label: 'Google Docs',
+ description: 'Not a trusted connector',
+ },
+ SETUP_INTEGRATIONS_CONTINUE_OPTION,
+ ],
+ },
+ ],
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ Object.assign(mocks, {
+ isAdmin: true,
+ error: false,
+ pending: false,
+ submitError: false,
+ submitPending: false,
+ enabled: true,
+ connections: [],
+ enablements: [],
+ searchParams: new URLSearchParams(),
+ });
+});
+
+it('highlights catalog matches without claiming support for unknown options, in homepage order', () => {
+ render( );
+ const rows = within(
+ screen.getByRole('list', { name: 'Available integrations' }),
+ ).getAllByRole('listitem');
+ expect(
+ rows.map((row) =>
+ within(row).getByRole('button').getAttribute('aria-label'),
+ ),
+ ).toEqual([
+ 'Configure Notion',
+ 'Configure Sentry',
+ 'Configure Linear',
+ 'Configure Jira',
+ ]);
+ expect(screen.getAllByText('Your tools')).toHaveLength(2);
+ expect(screen.queryByText('Google Docs')).not.toBeInTheDocument();
+ expect(mocks.capture).toHaveBeenCalledWith('setup_integrations_shown', {
+ matchedCount: 2,
+ });
+});
+
+it('continues with zero connections using the durable setup input contract', () => {
+ render( );
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Continue without connections' }),
+ );
+ expect(mocks.mutate).toHaveBeenCalledWith({
+ sessionId: 's',
+ requestId: 'integration-request',
+ answers: { 'setup-integrations': { answers: ['Continue'] } },
+ });
+});
+
+it('shows live connected, disabled and attention states instead of inferring a connection from enablement', () => {
+ mocks.connections = [{ mcpId: 'notion', authStatus: 'authenticated' }];
+ mocks.enablements = [
+ { mcpId: 'notion', enabled: true },
+ { mcpId: 'sentry', enabled: true },
+ ];
+ render( );
+ expect(screen.getByText('Connected')).toBeInTheDocument();
+ expect(screen.getByText('Needs connection')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Continue setup' })).toBeEnabled();
+});
+
+it('keeps continuation available during loading, status failure and operator disablement', () => {
+ mocks.pending = true;
+ mocks.error = true;
+ mocks.enabled = false;
+ render( );
+ expect(
+ screen.getByRole('button', { name: 'Continue without connections' }),
+ ).toBeEnabled();
+ expect(
+ screen.getByRole('button', { name: 'Configure Notion' }),
+ ).toBeDisabled();
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh status' }));
+ expect(mocks.refetch).toHaveBeenCalledTimes(4);
+});
+
+it('opens existing secure configuration inline and refreshes after cancellation', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' }));
+ await waitFor(() =>
+ expect(
+ screen.getByText('Secure configuration: notion'),
+ ).toBeInTheDocument(),
+ );
+ expect(mocks.capture).toHaveBeenCalledWith(
+ 'setup_integration_configuration_opened',
+ { integration_id: 'notion' },
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Back to setup' }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(mocks.refetch).toHaveBeenCalledTimes(4);
+});
+
+it('never reintroduces provider overlaps from an old pending request', () => {
+ const oldRequest = {
+ ...request,
+ questions: request.questions.map((question) => ({
+ ...question,
+ options: [
+ ...(question.options ?? []),
+ { id: 'slack', label: 'Slack', description: '' },
+ { id: 'vercel', label: 'Vercel', description: '' },
+ ],
+ })),
+ };
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /See all/ }));
+ expect(
+ screen.queryByRole('button', { name: 'Configure Slack' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Configure Vercel' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Configure Supabase' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Continue without connections' }),
+ ).toBeEnabled();
+});
+
+it('requires an administrator for configuration, not for displaying the optional continue action', () => {
+ mocks.isAdmin = false;
+ render( );
+ expect(
+ screen.getByRole('button', { name: 'Configure Notion' }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: 'Continue without connections' }),
+ ).toBeEnabled();
+});
+
+it('allows retry after a failed continue and does not disclose callback reason values', () => {
+ mocks.submitError = true;
+ mocks.searchParams = new URLSearchParams('mcp=error&reason=private-value');
+ render( );
+ expect(screen.getByText(/Authorization didn't finish/)).toBeInTheDocument();
+ expect(screen.queryByText(/private-value/)).not.toBeInTheDocument();
+ expect(screen.getByText(/Couldn't continue setup/)).toBeInTheDocument();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Continue without connections' }),
+ );
+ expect(mocks.mutate).toHaveBeenCalledTimes(1);
+});
+
+it('makes the rest of the supported catalog available on demand', () => {
+ render( );
+ expect(
+ screen.queryByRole('button', { name: 'Configure Granola' }),
+ ).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /See all/ }));
+ expect(
+ screen.getByRole('button', { name: 'Configure Granola' }),
+ ).toBeInTheDocument();
+});
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx
new file mode 100644
index 0000000000..b9f01d492b
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx
@@ -0,0 +1,349 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import dynamic from 'next/dynamic';
+import { usePathname, useSearchParams } from 'next/navigation';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import {
+ MCP_INTEGRATIONS,
+ SETUP_INTEGRATIONS,
+ SETUP_INTEGRATION_CATEGORIES,
+ SETUP_INTEGRATIONS_CONTINUE_OPTION,
+ SETUP_INTEGRATIONS_QUESTION_ID,
+ isDeploymentScopedMcpIntegration,
+ type AcpRequestUserInputPayload,
+} from '@roomote/types';
+
+import {
+ Badge,
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Plug,
+ RefreshCw,
+ Skeleton,
+} from '@/components/system';
+import { McpIcon } from '@/components/settings/McpIcon';
+import {
+ useConnectMcp,
+ useCuratedIntegrationsAvailability,
+ useDeploymentMcpEnablements,
+ useUserMcpConnections,
+} from '@/hooks/mcp-connections';
+import { useAuthorizedUser } from '@/hooks/useUser';
+import { useTelemetry } from '@/hooks/useTelemetry';
+import { useTRPC } from '@/trpc/client';
+import { SetupSessionActionCard } from './SetupSessionActionCard';
+
+const Integrations = dynamic(() =>
+ import('@/components/settings/Integrations').then(
+ (module) => module.Integrations,
+ ),
+);
+
+export function SetupIntegrationsCard({
+ sessionId,
+ request,
+}: {
+ sessionId: string;
+ request: Pick<
+ AcpRequestUserInputPayload,
+ 'requestId' | 'questions' | 'preset'
+ >;
+}) {
+ const trpc = useTRPC();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const { isAdmin } = useAuthorizedUser();
+ const { enabled, capture } = useTelemetry();
+ const onboarding = useQuery(trpc.onboarding.status.queryOptions());
+ const enablements = useDeploymentMcpEnablements();
+ const connections = useUserMcpConnections();
+ const availability = useCuratedIntegrationsAvailability();
+ const connectMcp = useConnectMcp();
+ const [showAll, setShowAll] = useState(false);
+ const [activeId, setActiveId] = useState(null);
+ const [continued, setContinued] = useState(false);
+ const shownRequest = useRef(null);
+
+ const matchedIds = new Set(
+ request.questions
+ .find((question) => question.id === SETUP_INTEGRATIONS_QUESTION_ID)
+ ?.options?.map((option) => option.id) ?? [],
+ );
+ const matchedCount = SETUP_INTEGRATIONS.filter((integration) =>
+ matchedIds.has(integration.id),
+ ).length;
+
+ useEffect(() => {
+ if (!enabled || shownRequest.current === request.requestId) return;
+ shownRequest.current = request.requestId;
+ capture('setup_integrations_shown', { matchedCount });
+ }, [capture, enabled, matchedCount, request.requestId]);
+
+ const submit = useMutation(
+ trpc.setup.submitSessionUserInput.mutationOptions({
+ onSuccess: () => {
+ capture('setup_integrations_continued');
+ setContinued(true);
+ },
+ onError: (error) => toast.error(error.message),
+ }),
+ );
+ const refresh = () => {
+ void onboarding.refetch();
+ void enablements.refetch();
+ void connections.refetch();
+ void availability.refetch();
+ };
+ const statusPending =
+ onboarding.isPending || enablements.isPending || connections.isPending;
+ const statusError =
+ onboarding.isError ||
+ enablements.isError ||
+ connections.isError ||
+ availability.isError;
+ const active = SETUP_INTEGRATIONS.find(
+ (integration) => integration.id === activeId,
+ );
+ const activeDefinition = MCP_INTEGRATIONS.find(
+ (integration) => integration.id === activeId,
+ );
+ const authenticatedIds = new Set(
+ (connections.data ?? [])
+ .filter((connection) => connection.authStatus === 'authenticated')
+ .map((connection) => connection.mcpId),
+ );
+ const enabledIds = new Set(
+ (enablements.data ?? [])
+ .filter((entry) => entry.enabled)
+ .map((entry) => entry.mcpId),
+ );
+ const getStatus = (integration: (typeof SETUP_INTEGRATIONS)[number]) => {
+ if (statusPending || statusError) return null;
+ if (integration.id === 'linear')
+ return onboarding.data?.orgHasLinear ? 'Connected' : 'Available';
+ if (authenticatedIds.has(integration.id))
+ return enabledIds.has(integration.id) ? 'Connected' : 'Not enabled';
+ return enabledIds.has(integration.id) ? 'Needs connection' : 'Available';
+ };
+ const hasConnections = SETUP_INTEGRATIONS.some(
+ (integration) => getStatus(integration) === 'Connected',
+ );
+ const previewIds = new Set(
+ SETUP_INTEGRATION_CATEGORIES.map((category) => category.integrationIds[0]),
+ );
+ const visibleIntegrations = SETUP_INTEGRATIONS.filter(
+ (integration) =>
+ showAll ||
+ matchedIds.has(integration.id) ||
+ previewIds.has(integration.id),
+ );
+ const authFailed =
+ searchParams.get('mcp') === 'error' || searchParams.get('error') !== null;
+
+ if (continued)
+ return (
+
+ You can connect more tools any time in Settings.
+
+ );
+
+ return (
+ }
+ intro="Connect the tools you use so I can work with your team's context, not just your code. This is optional."
+ >
+ {matchedCount > 0 ? (
+
+ I've highlighted the available connectors that match your
+ answers.
+
+ ) : null}
+ {authFailed ? (
+
+ Authorization didn't finish. You can try connecting again or
+ continue without it.
+
+ ) : null}
+ {statusError ? (
+
+ I couldn't refresh connection status. Try Refresh status, or
+ continue setup.
+
+ ) : null}
+ {availability.data?.enabled === false ? (
+
+ Tool integrations are disabled by the deployment operator. You can
+ still continue setup.
+
+ ) : null}
+ {!isAdmin ? (
+
+ An administrator can configure these connections.
+
+ ) : null}
+
+ {visibleIntegrations.map((integration) => {
+ const definition = MCP_INTEGRATIONS.find(
+ (entry) => entry.id === integration.id,
+ );
+ const status = getStatus(integration);
+ const unavailable = availability.data?.enabled === false;
+ return (
+
+ {definition ? (
+
+ ) : null}
+
+
{integration.name}
+ {statusPending ? (
+
+ ) : (
+
+ {unavailable
+ ? 'Unavailable on this instance'
+ : (status ?? 'Status unavailable')}
+
+ )}
+
+ {matchedIds.has(integration.id) ? (
+ Your tools
+ ) : null}
+ {
+ capture('setup_integration_configuration_opened', {
+ integration_id: integration.id,
+ });
+ setActiveId(integration.id);
+ }}
+ >
+ {status === 'Connected' ? 'Manage' : 'Set up'}
+
+
+ );
+ })}
+
+
+ setShowAll(!showAll)}
+ >
+ {showAll
+ ? 'Show fewer tools'
+ : `See all ${SETUP_INTEGRATIONS.length} integrations`}
+
+
+
+ Refresh status
+
+
+
+ Don't see your tool? There may not be a built-in connector for it
+ yet. No credentials belong in this conversation.
+
+
+ submit.mutate({
+ sessionId,
+ requestId: request.requestId,
+ answers: {
+ [SETUP_INTEGRATIONS_QUESTION_ID]: {
+ answers: [SETUP_INTEGRATIONS_CONTINUE_OPTION.label],
+ },
+ },
+ })
+ }
+ >
+ {submit.isPending
+ ? 'Continuing...'
+ : hasConnections
+ ? 'Continue setup'
+ : 'Continue without connections'}
+
+ {submit.isError ? (
+
+ Couldn't continue setup. Please try again.
+
+ ) : null}
+ {
+ if (!open) {
+ setActiveId(null);
+ refresh();
+ }
+ }}
+ >
+
+
+
+ {active ? `Connect ${active.name}` : 'Connect a tool'}
+
+
+ Use the secure configuration below. You can cancel and continue
+ setup without connecting.
+
+
+ {active ? : null}
+ {active &&
+ activeDefinition &&
+ active.id !== 'linear' &&
+ !isDeploymentScopedMcpIntegration(activeDefinition) &&
+ enabledIds.has(active.id) &&
+ !authenticatedIds.has(active.id) ? (
+
+ connectMcp.mutate(
+ { mcpId: active.id, redirectTo: pathname },
+ {
+ onSuccess: (url) => {
+ window.location.href = url;
+ },
+ onError: (error) => toast.error(error.message),
+ },
+ )
+ }
+ >
+ Connect my {active.name} account
+
+ ) : null}
+
+ {
+ setActiveId(null);
+ refresh();
+ }}
+ >
+ Back to setup
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx
index 4ad0f023ba..766d0eec08 100644
--- a/apps/web/src/components/settings/Integrations.test.tsx
+++ b/apps/web/src/components/settings/Integrations.test.tsx
@@ -94,6 +94,7 @@ const state = vi.hoisted(() => ({
},
},
linearRedirectPath: '',
+ pathname: '/settings/integrations',
searchParams: '',
}));
@@ -150,7 +151,7 @@ function cloneMcpToolsData() {
}
vi.mock('next/navigation', () => ({
- usePathname: () => '/settings/integrations',
+ usePathname: () => state.pathname,
useSearchParams: () => new URLSearchParams(state.searchParams),
}));
@@ -515,6 +516,7 @@ describe('Integrations settings', () => {
linearOrganizationName: 'Roomote',
};
state.linearRedirectPath = '';
+ state.pathname = '/settings/integrations';
state.asanaConnection = null;
state.notionConnection = null;
state.ripplingConnection = null;
@@ -552,6 +554,60 @@ describe('Integrations settings', () => {
);
});
+ it('renders only requested integrations in passed order without custom servers or groups', () => {
+ render( );
+ expect(
+ screen.getAllByRole('heading').map((heading) => heading.textContent),
+ ).toEqual(['Integrations', 'Notion', 'Sentry', 'Linear']);
+ expect(screen.queryByText('Add custom server')).not.toBeInTheDocument();
+ });
+
+ it('does not leak custom servers when filtered integrations are disabled', () => {
+ state.integrationsEnabled = false;
+ render( );
+ expect(
+ screen.getByText('Integrations disabled by deployment operator'),
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Add custom server')).not.toBeInTheDocument();
+ });
+
+ it('preserves the embedded pathname for Linear and MCP OAuth', () => {
+ state.pathname = '/sessions/setup-session';
+ state.linearInstallation = null;
+ render( );
+ expect(state.linearRedirectPath).toBe('/sessions/setup-session');
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Connect and enable Pylon' }),
+ );
+ expect(mutations.connectMcp).toHaveBeenCalledWith(
+ { mcpId: 'pylon', redirectTo: '/sessions/setup-session' },
+ expect.any(Object),
+ );
+ });
+
+ it('keeps filtered deployment configuration read-only for non-admins', () => {
+ state.isAdmin = false;
+ render( );
+ expect(screen.getByRole('heading', { name: 'Notion' })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Configure Notion' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('clears an embedded secret on cancellation without saving', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' }));
+ fireEvent.change(screen.getByLabelText('Internal integration secret'), {
+ target: { value: 'test-secret' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(mutations.saveNotionConnection).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' }));
+ expect(screen.getByLabelText('Internal integration secret')).toHaveValue(
+ '',
+ );
+ });
+
it('uses the settings action for missing Linear OAuth setup', () => {
state.linearInstallation = null;
state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }];
diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx
index 21332eae1b..11a488d624 100644
--- a/apps/web/src/components/settings/Integrations.tsx
+++ b/apps/web/src/components/settings/Integrations.tsx
@@ -1379,7 +1379,11 @@ function VercelConnectionFields({
);
}
-export function Integrations() {
+export function Integrations({
+ integrationIds,
+}: {
+ integrationIds?: readonly string[];
+} = {}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const { isAdmin } = useAuthorizedUser();
@@ -1479,7 +1483,9 @@ export function Integrations() {
const [isLinearOauthSetupOpen, setIsLinearOauthSetupOpen] = useState(false);
const linearInstallation = useLinearInstallation();
- const connectLinear = useConnectLinear(`${pathname}?service=linear`);
+ const connectLinear = useConnectLinear(
+ integrationIds === undefined ? `${pathname}?service=linear` : pathname,
+ );
const disconnectLinear = useDisconnectLinear();
const deploymentEnablements = useDeploymentMcpEnablements();
@@ -2228,7 +2234,13 @@ export function Integrations() {
}),
];
- return sortIntegrationItems(baseItems, highlightedIntegrationId);
+ return integrationIds === undefined
+ ? sortIntegrationItems(baseItems, highlightedIntegrationId)
+ : [...new Set(integrationIds)].flatMap((id) =>
+ baseItems.filter(
+ (item) => item.id === (id === 'sentry' ? 'sentry-mcp' : id),
+ ),
+ );
}, [
connectLinear,
connectMcp,
@@ -2257,6 +2269,7 @@ export function Integrations() {
saveVercelConnection.isPending,
deploymentEnablements.data,
pathname,
+ integrationIds,
setDeploymentEnabled,
saveSnowflakeConnection.isPending,
asanaConnection.isPending,
@@ -2906,7 +2919,7 @@ export function Integrations() {
instance.
- {customMcpEnabled ? (
+ {integrationIds === undefined && customMcpEnabled ? (
<>
{customMcpDialogs}
@@ -3170,32 +3183,42 @@ export function Integrations() {
deepLinkDialogItem.onAction?.();
}}
/>
- {customMcpDialogs}
- {customMcpEnabled ? (
-
- ) : null}
-
- You haven't connected any integrations yet.
-
- }
- />
- {configured.length > 0 && (
+ {integrationIds !== undefined ? (
+ ) : (
+ <>
+ {customMcpDialogs}
+ {customMcpEnabled ? (
+
+ ) : null}
+
+ You haven't connected any integrations yet.
+
+ }
+ />
+ {configured.length > 0 && (
+
+ )}
+
+ >
)}
-
);
}
diff --git a/apps/web/src/trpc/commands/fast-sessions/index.test.ts b/apps/web/src/trpc/commands/fast-sessions/index.test.ts
index ab959e372b..f8520e565f 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.test.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.test.ts
@@ -23,6 +23,9 @@ const mocks = vi.hoisted(() => ({
dbSelect: vi.fn(),
dbInnerJoin: vi.fn(),
dbSelectLimit: vi.fn(),
+ resolveSetupContext: vi.fn().mockResolvedValue(null),
+ submitSetupInput: vi.fn(),
+ upsertMessage: vi.fn(),
sql: vi.fn(),
}));
@@ -35,6 +38,7 @@ vi.mock('@roomote/cloud-agents/server', () => ({
FastAgentDurableRetryScheduledError: class FastAgentDurableRetryScheduledError extends Error {},
getOrCreateFastAgentSession: mocks.getOrCreateSession,
resolveApiBaseUrl: vi.fn(),
+ upsertFastAgentMessage: mocks.upsertMessage,
}));
vi.mock('@roomote/sdk/server', () => ({
@@ -82,6 +86,11 @@ vi.mock('./pinned-launch', () => ({
startPinnedFastSessionLaunch: mocks.startPinnedLaunch,
}));
+vi.mock('../setup/setup-session', () => ({
+ resolveSetupSessionTurnContext: mocks.resolveSetupContext,
+ submitSetupSessionUserInputCommand: mocks.submitSetupInput,
+}));
+
import {
getFastSessionTasksCommand,
handleFastSessionPrReviewActionCommand,
@@ -90,6 +99,7 @@ import {
startFastSessionCommand,
startSetupFastSessionCommand,
updateFastSessionModelSelectionCommand,
+ submitFastSessionUserInputCommand,
} from './index';
describe('getFastSessionTasksCommand', () => {
@@ -140,6 +150,282 @@ describe('getFastSessionTasksCommand', () => {
});
});
+describe('setup context on ordinary Fast session input', () => {
+ afterEach(() => {
+ mocks.resolveSetupContext.mockReset().mockResolvedValue(null);
+ });
+ const resolvePreset = vi.fn();
+ const initialSnapshot = JSON.stringify({
+ integrationDiscovery: { completed: false, answeredCategoryIds: [] },
+ });
+ const freshSnapshot = JSON.stringify({
+ integrationDiscovery: {
+ completed: false,
+ answeredCategoryIds: ['documents'],
+ matchedIntegrationIds: ['granola'],
+ },
+ });
+ const setupContext = {
+ setupSession: true,
+ adapterExtensions: { resolveUserInputPreset: resolvePreset },
+ setupSnapshot: initialSnapshot,
+ };
+ const question = {
+ id: 'setup-tools-documents',
+ header: 'Documents',
+ question: 'Where do you keep documents?',
+ isOther: true,
+ isSecret: false,
+ };
+ const request = {
+ eventId: 'request-event',
+ turnId: 'request-turn',
+ payload: {
+ requestId: 'request-1',
+ sessionId: 'session-1',
+ turnId: 'request-turn',
+ callId: 'request-call',
+ status: 'pending',
+ questions: [question],
+ },
+ };
+ const input = {
+ sessionId: 'session-1',
+ requestId: 'request-1',
+ answers: { 'setup-tools-documents': { answers: ['Granola'] } },
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.resolveSetupContext.mockReset().mockResolvedValue(null);
+ mocks.upsertMessage.mockReset().mockResolvedValue(undefined);
+ mocks.findAccessibleSession.mockResolvedValue(session);
+ mocks.acquireTurnLock.mockResolvedValue(
+ Object.assign(vi.fn().mockResolvedValue(undefined), {
+ signal: new AbortController().signal,
+ }),
+ );
+ mocks.answerQuestion.mockResolvedValue('Ready');
+ mocks.buildReplyDelivery.mockResolvedValue({
+ conversation: {
+ surface: 'web',
+ workspaceId: 'user-1',
+ conversationId: 'session-1',
+ },
+ adapter: { launchTask: mocks.launchTask, postReply: vi.fn() },
+ });
+ mocks.retireReviewActions.mockResolvedValue([]);
+ mocks.updateOfferStatus.mockResolvedValue(undefined);
+ mocks.dbSelect.mockReturnValue({
+ from: () => ({
+ where: () => ({
+ limit: mocks.dbSelectLimit,
+ orderBy: () => ({ limit: mocks.dbSelectLimit }),
+ }),
+ }),
+ });
+ mocks.dbSelectLimit.mockReset().mockResolvedValue([]);
+ mocks.submitSetupInput.mockResolvedValue({ success: true });
+ });
+
+ async function runScheduled() {
+ expect(mocks.after).toHaveBeenCalledOnce();
+ await mocks.after.mock.calls[0]![0]();
+ return mocks.answerQuestion.mock.calls[0]![0];
+ }
+
+ it('attaches setup adapters and snapshot to ordinary prose replies', async () => {
+ mocks.resolveSetupContext.mockResolvedValue(setupContext);
+ await replyToFastSessionCommand(auth, {
+ sessionId: session.id,
+ text: 'We use Granola. Skip the other questions.',
+ });
+ const turn = await runScheduled();
+ expect(turn).toMatchObject({
+ setupSession: true,
+ setupSnapshot: initialSnapshot,
+ adapter: { resolveUserInputPreset: resolvePreset },
+ });
+ expect(mocks.resolveSetupContext).toHaveBeenCalledWith(auth, session.id);
+ });
+
+ it('leaves ordinary non-setup replies unchanged', async () => {
+ await replyToFastSessionCommand(auth, {
+ sessionId: session.id,
+ text: 'Review this change.',
+ });
+ const turn = await runScheduled();
+ expect(turn.setupSession).toBeUndefined();
+ expect(turn.setupSnapshot).toBeUndefined();
+ expect(turn.adapter.resolveUserInputPreset).toBeUndefined();
+ });
+
+ it('refreshes setup snapshots after category response persistence, overriding stale caller context', async () => {
+ mocks.dbSelectLimit
+ .mockResolvedValueOnce([request])
+ .mockResolvedValueOnce([]);
+ mocks.resolveSetupContext
+ .mockResolvedValueOnce(setupContext)
+ .mockImplementation(async () => {
+ expect(mocks.upsertMessage).toHaveBeenCalledOnce();
+ return { ...setupContext, setupSnapshot: freshSnapshot };
+ });
+ await submitFastSessionUserInputCommand(auth, input, {
+ setupSession: true,
+ setupSnapshot: initialSnapshot,
+ });
+ const turn = await runScheduled();
+ expect(turn).toMatchObject({
+ setupSession: true,
+ setupSnapshot: freshSnapshot,
+ adapter: { resolveUserInputPreset: resolvePreset },
+ });
+ expect(mocks.upsertMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.objectContaining({
+ payload: expect.objectContaining({
+ answers: input.answers,
+ resolution: 'submitted',
+ }),
+ }),
+ }),
+ );
+ });
+
+ it.each(['documents', 'communication'])(
+ 'resumes cancelled %s discovery questions as an early skip without marking discovery complete',
+ async (category) => {
+ mocks.dbSelectLimit
+ .mockResolvedValueOnce([
+ {
+ ...request,
+ payload: {
+ ...request.payload,
+ questions: [{ ...question, id: `setup-tools-${category}` }],
+ },
+ },
+ ])
+ .mockResolvedValueOnce([]);
+ const skippedSnapshot = JSON.stringify({
+ integrationDiscovery: { completed: false, skipped: true },
+ });
+ mocks.resolveSetupContext
+ .mockResolvedValueOnce(setupContext)
+ .mockImplementation(async () => {
+ expect(mocks.upsertMessage).toHaveBeenCalledOnce();
+ return { ...setupContext, setupSnapshot: skippedSnapshot };
+ });
+ await submitFastSessionUserInputCommand(auth, {
+ ...input,
+ answers: {},
+ resolution: 'cancelled',
+ });
+ const turn = await runScheduled();
+ expect(turn).toMatchObject({
+ setupSession: true,
+ setupSnapshot: skippedSnapshot,
+ });
+ expect(turn.question).toContain('"resolution":"cancelled"');
+ },
+ );
+
+ it('keeps generic non-setup submissions and cancellation behavior unchanged', async () => {
+ mocks.dbSelectLimit
+ .mockResolvedValueOnce([request])
+ .mockResolvedValueOnce([]);
+ await submitFastSessionUserInputCommand(auth, input);
+ const turn = await runScheduled();
+ expect(turn.setupSession).toBe(false);
+ expect(turn.setupSnapshot).toBeUndefined();
+ expect(turn.adapter.resolveUserInputPreset).toBeUndefined();
+ expect(turn.question).toBe(
+ `${JSON.stringify({ requestId: input.requestId, answers: input.answers })} `,
+ );
+ mocks.after.mockClear();
+ mocks.dbSelectLimit
+ .mockResolvedValueOnce([request])
+ .mockResolvedValueOnce([]);
+ await submitFastSessionUserInputCommand(auth, {
+ ...input,
+ answers: {},
+ resolution: 'cancelled',
+ });
+ expect(mocks.after).not.toHaveBeenCalled();
+ });
+
+ it('replays a saved setup category response with a fresh snapshot without persisting twice', async () => {
+ const saved = {
+ eventId: 'response-event',
+ payload: {
+ requestId: 'request-1',
+ sessionId: 'session-1',
+ turnId: 'request-turn',
+ callId: 'request-call',
+ answers: input.answers,
+ resolution: 'submitted',
+ },
+ };
+ mocks.dbSelectLimit
+ .mockResolvedValueOnce([request])
+ .mockResolvedValueOnce([saved]);
+ mocks.resolveSetupContext.mockResolvedValue({
+ ...setupContext,
+ setupSnapshot: freshSnapshot,
+ });
+ await submitFastSessionUserInputCommand(auth, input);
+ expect(mocks.upsertMessage).not.toHaveBeenCalled();
+ expect(await runScheduled()).toMatchObject({
+ setupSession: true,
+ setupSnapshot: freshSnapshot,
+ });
+ });
+
+ it('routes final presets through setup-specific persistence, not ordinary response writes', async () => {
+ mocks.resolveSetupContext.mockResolvedValue(setupContext);
+ const final = {
+ ...request,
+ payload: {
+ ...request.payload,
+ preset: 'setup_integrations',
+ questions: [
+ {
+ ...question,
+ id: 'setup-integrations',
+ isOther: false,
+ options: [
+ {
+ id: 'continue',
+ label: 'Continue',
+ description: 'Continue without connections',
+ },
+ ],
+ },
+ ],
+ },
+ };
+ mocks.dbSelectLimit
+ .mockResolvedValueOnce([final])
+ .mockResolvedValueOnce([]);
+ const finalInput = {
+ ...input,
+ answers: { 'setup-integrations': { answers: ['Continue'] } },
+ };
+ await submitFastSessionUserInputCommand(auth, finalInput);
+ expect(mocks.submitSetupInput).toHaveBeenCalledWith(auth, finalInput);
+ expect(mocks.upsertMessage).not.toHaveBeenCalled();
+ expect(mocks.after).not.toHaveBeenCalled();
+ });
+
+ it('checks setup admin ownership before an ordinary response is persisted', async () => {
+ mocks.resolveSetupContext.mockRejectedValue(new Error('Unauthorized'));
+ await expect(
+ submitFastSessionUserInputCommand(auth, input),
+ ).rejects.toThrow('Unauthorized');
+ expect(mocks.upsertMessage).not.toHaveBeenCalled();
+ expect(mocks.after).not.toHaveBeenCalled();
+ });
+});
+
const auth = {
userId: 'user-1',
isAdmin: false,
diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts
index 0db5c6f25d..f4f83740b2 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.ts
@@ -43,6 +43,7 @@ import {
formatErrorForLog,
getAcpRequestUserInputValidationError,
getUserDisplayName,
+ isSetupIntegrationDiscoveryQuestionId,
parseAcpRequestUserInputAnswers,
parseAcpRequestUserInputPayload,
parseAcpRequestUserInputResponsePayload,
@@ -666,6 +667,9 @@ export async function replyToFastSessionCommand(
if (!session) {
throw new Error('Fast session not found');
}
+ const { resolveSetupSessionTurnContext } =
+ await import('../setup/setup-session');
+ const setupContext = await resolveSetupSessionTurnContext(auth, session.id);
const senderDisplayName =
getUserDisplayName({ name: auth.name, email: auth.primaryEmail }) ?? null;
@@ -708,6 +712,7 @@ export async function replyToFastSessionCommand(
reasoningEffort: settings.reasoningEffort,
...(senderDisplayName ? { senderDisplayName } : {}),
durableSessionId: session.id,
+ ...setupContext,
});
return { success: true };
@@ -778,6 +783,10 @@ export async function submitFastSessionUserInputCommand(
if (!session) {
throw new Error('Fast session not found');
}
+ const { resolveSetupSessionTurnContext, submitSetupSessionUserInputCommand } =
+ await import('../setup/setup-session');
+ // Check setup ownership before persisting input; rebuild its snapshot after the write.
+ const setupContext = await resolveSetupSessionTurnContext(auth, session.id);
const [request] = await db
.select({
@@ -832,7 +841,19 @@ export async function submitFastSessionUserInputCommand(
throw new Error(validationError);
}
- const scheduleResponseTurn = (answers: AcpRequestUserInputAnswers) => {
+ const scheduleResponseTurn = async (
+ answers: AcpRequestUserInputAnswers,
+ responseResolution: 'submitted' | 'cancelled',
+ ) => {
+ const freshSetupContext = setupContext
+ ? await resolveSetupSessionTurnContext(auth, session.id)
+ : null;
+ const skippedDiscovery =
+ freshSetupContext &&
+ requestPayload.questions.some((question) =>
+ isSetupIntegrationDiscoveryQuestionId(question.id),
+ );
+ if (responseResolution === 'cancelled' && !skippedDiscovery) return;
const responseTurnId = `input-response:${input.requestId}`;
const conversation =
session.surface === 'automation'
@@ -861,6 +882,9 @@ export async function submitFastSessionUserInputCommand(
question: `${JSON.stringify({
requestId: input.requestId,
answers,
+ ...(responseResolution === 'cancelled'
+ ? { resolution: responseResolution }
+ : {}),
})} `,
turnSource: 'platform_event',
platformEventKind: 'input_response',
@@ -878,6 +902,7 @@ export async function submitFastSessionUserInputCommand(
? { setupSnapshot: options.setupSnapshot }
: {}),
setupSession: options.setupSession ?? false,
+ ...freshSetupContext,
});
};
@@ -885,17 +910,20 @@ export async function submitFastSessionUserInputCommand(
const persistedResponse = parseAcpRequestUserInputResponsePayload(
existingResponse.payload,
);
- if (
- !requestPayload.preset &&
- persistedResponse?.resolution === 'submitted'
- ) {
- scheduleResponseTurn(persistedResponse.answers);
+ if (!requestPayload.preset && persistedResponse) {
+ await scheduleResponseTurn(
+ persistedResponse.answers,
+ persistedResponse.resolution,
+ );
}
return { success: true };
}
const responseEventId = `${request.eventId}:response`;
if (requestPayload.preset) {
+ if (setupContext && !options.persistSetupPresetResponse) {
+ return submitSetupSessionUserInputCommand(auth, input);
+ }
if (!options.persistSetupPresetResponse || resolution !== 'submitted') {
throw new Error('This trusted setup response cannot be handled here.');
}
@@ -941,8 +969,7 @@ export async function submitFastSessionUserInputCommand(
},
});
- if (resolution === 'cancelled') return { success: true };
- scheduleResponseTurn(submitted);
+ await scheduleResponseTurn(submitted, resolution);
return { success: true };
}
diff --git a/apps/web/src/trpc/commands/setup/setup-session.test.ts b/apps/web/src/trpc/commands/setup/setup-session.test.ts
new file mode 100644
index 0000000000..37fb0c9c62
--- /dev/null
+++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts
@@ -0,0 +1,498 @@
+const mocks = vi.hoisted(() => ({
+ getStatus: vi.fn(),
+ schedule: vi.fn(),
+ submit: vi.fn(),
+ complete: vi.fn(),
+}));
+vi.mock('../setup-new', () => ({ getSetupNewStatusCommand: mocks.getStatus }));
+vi.mock('../fast-sessions', () => ({
+ scheduleWebFastAgentTurn: mocks.schedule,
+ submitFastSessionUserInputCommand: mocks.submit,
+}));
+vi.mock('./setup-session-completion', () => ({
+ completeConversationalSetupIfReady: mocks.complete,
+}));
+vi.mock('@/lib/server/setup-funnel-telemetry', () => ({
+ recordSetupFunnelMilestones: vi.fn(),
+}));
+vi.mock('@roomote/sdk/server', () => ({
+ buildFastAgentArtifactCreator: vi.fn(),
+ LINEAR_ORG_CONNECTION_ROLE: 'organization',
+}));
+vi.mock('@roomote/cloud-agents/server', () => ({
+ createFastAgentWebTaskLauncher: vi.fn(),
+}));
+vi.mock('@roomote/telemetry/server', () => ({ captureEvent: vi.fn() }));
+
+import {
+ db,
+ deploymentSettings,
+ ensureSessionForFastConversation,
+ eq,
+ fastAgentConversations,
+ fastAgentMessages,
+ sessions,
+ userFactory,
+ users,
+} from '@roomote/db/server';
+import {
+ ACP_ENVELOPE_EVENT_TYPES,
+ createSetupNewSetupSession,
+ normalizeSetupNewState,
+ type AcpRequestUserInputPayload,
+} from '@roomote/types';
+import type { UserAuthSuccess } from '@/types';
+import { SETUP_STARTER_TASKS } from '@/lib/setup-starter-tasks';
+import {
+ getOrCreateSetupSessionCommand,
+ reconcileSetupPlatformEvents,
+ resolveSetupSessionTurnContext,
+ scheduleSetupPlatformEvent,
+ submitSetupSessionUserInputCommand,
+} from './setup-session';
+
+describe('optional setup integration discovery', () => {
+ let auth: UserAuthSuccess;
+ let sessionId: string;
+ let conversationId: string;
+ let ts: number;
+
+ async function readState() {
+ const [row] = await db
+ .select()
+ .from(deploymentSettings)
+ .where(eq(deploymentSettings.id, 'default'));
+ return normalizeSetupNewState(row?.setupNewState);
+ }
+ async function context() {
+ return (await resolveSetupSessionTurnContext(auth, sessionId))!;
+ }
+ async function request(payload: AcpRequestUserInputPayload) {
+ const row = {
+ eventId: `event:${payload.requestId}`,
+ turnId: payload.turnId,
+ payload,
+ };
+ await db.insert(fastAgentMessages).values({
+ ...row,
+ payload: { ...payload },
+ conversationId,
+ turnSeq: 0,
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ role: 'assistant',
+ ts: ts++,
+ source: 'web',
+ });
+ return row;
+ }
+ async function answeredCategory(
+ category: string,
+ values: string[],
+ resolution: 'submitted' | 'cancelled' = 'submitted',
+ ) {
+ const payload: AcpRequestUserInputPayload = {
+ requestId: `category:${category}`,
+ sessionId: conversationId,
+ turnId: `turn:${category}`,
+ callId: category,
+ status: 'pending',
+ questions: [
+ {
+ id: `setup-tools-${category}`,
+ header: category,
+ question: 'Your tools?',
+ isOther: true,
+ isSecret: false,
+ },
+ ],
+ };
+ await request(payload);
+ await db.insert(fastAgentMessages).values({
+ conversationId,
+ eventId: `response:${category}`,
+ turnId: payload.turnId,
+ turnSeq: 1,
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
+ role: 'user',
+ ts: ts++,
+ source: 'web',
+ payload: {
+ requestId: payload.requestId,
+ sessionId: conversationId,
+ turnId: payload.turnId,
+ callId: category,
+ answers: { [`setup-tools-${category}`]: { answers: values } },
+ resolution,
+ },
+ });
+ }
+ async function continueDiscovery() {
+ const questions = await (
+ await context()
+ ).adapterExtensions.resolveUserInputPreset!('setup_integrations');
+ const row = await request({
+ requestId: 'integrations',
+ sessionId: conversationId,
+ turnId: 'integrations',
+ callId: 'integrations',
+ status: 'pending',
+ preset: 'setup_integrations',
+ questions,
+ });
+ mocks.submit.mockImplementation(async (_auth, input, options) => {
+ await options.persistSetupPresetResponse({
+ fastConversationId: conversationId,
+ request: row,
+ answers: input.answers,
+ });
+ return { success: true };
+ });
+ return submitSetupSessionUserInputCommand(auth, {
+ sessionId,
+ requestId: 'integrations',
+ answers: { 'setup-integrations': { answers: ['Continue'] } },
+ });
+ }
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ ts = Date.now();
+ const user = await userFactory.create({ role: 'admin' });
+ auth = { userId: user.id, isAdmin: true } as UserAuthSuccess;
+ const [conversation] = await db
+ .insert(fastAgentConversations)
+ .values({
+ surface: 'web',
+ userId: user.id,
+ workspaceId: user.id,
+ conversationId: `setup-test:${user.id}`,
+ })
+ .returning();
+ conversationId = conversation!.id;
+ const session = await ensureSessionForFastConversation(db, conversationId);
+ sessionId = session.id;
+ const state = normalizeSetupNewState({
+ setupSession: createSetupNewSetupSession({ sessionId }),
+ });
+ await db
+ .insert(deploymentSettings)
+ .values({ id: 'default', setupNewState: state })
+ .onConflictDoUpdate({
+ target: deploymentSettings.id,
+ set: { setupNewState: state },
+ });
+ mocks.getStatus.mockImplementation(async () => ({
+ setupNewState: await readState(),
+ setupCompletedAt: null,
+ modelSetup: { setupSatisfied: true },
+ computeSetup: { setupSatisfied: true, providers: [] },
+ sourceControlSetup: {
+ setupSatisfied: true,
+ providers: [
+ {
+ provider: 'github',
+ label: 'GitHub',
+ connected: true,
+ repositoryCount: 1,
+ },
+ ],
+ },
+ }));
+ mocks.complete.mockResolvedValue(true);
+ });
+ afterEach(async () => {
+ await db
+ .update(deploymentSettings)
+ .set({ setupNewState: normalizeSetupNewState({}) })
+ .where(eq(deploymentSettings.id, 'default'));
+ await db.delete(sessions).where(eq(sessions.id, sessionId));
+ await db
+ .delete(fastAgentConversations)
+ .where(eq(fastAgentConversations.id, conversationId));
+ await db.delete(users).where(eq(users.id, auth.userId));
+ });
+
+ it('continues without any connector or source connection and persists completion', async () => {
+ mocks.getStatus.mockImplementation(async () => ({
+ setupNewState: await readState(),
+ setupCompletedAt: null,
+ modelSetup: { setupSatisfied: true },
+ computeSetup: { setupSatisfied: false, providers: [] },
+ sourceControlSetup: { setupSatisfied: false, providers: [] },
+ }));
+ const questions = await (
+ await context()
+ ).adapterExtensions.resolveUserInputPreset!('setup_integrations');
+ expect(questions[0]?.options?.map((option) => option.id)).toEqual([
+ 'continue',
+ ]);
+ await expect(continueDiscovery()).resolves.toEqual({ success: true });
+ expect(
+ (await readState()).setupSession?.integrationDiscoveryCompletedAt,
+ ).toEqual(expect.any(String));
+ expect((await readState()).setupSession?.starterTaskSelection).toBeNull();
+ expect(
+ JSON.parse((await context()).setupSnapshot).integrationDiscovery
+ .completed,
+ ).toBe(true);
+ const responses = await db
+ .select()
+ .from(fastAgentMessages)
+ .where(eq(fastAgentMessages.eventId, 'event:integrations:response'));
+ expect(responses).toHaveLength(1);
+ expect(responses[0]?.payload).toMatchObject({
+ resolution: 'submitted',
+ answers: { 'setup-integrations': { answers: ['Continue'] } },
+ });
+ await expect(
+ (await context()).adapterExtensions.resolveUserInputPreset!(
+ 'setup_integrations',
+ ),
+ ).rejects.toThrow('already complete');
+ });
+
+ it('persists cancellation as an early skip while leaving final continuation optional', async () => {
+ await answeredCategory('communication', [], 'cancelled');
+ const snapshot = JSON.parse(
+ (await context()).setupSnapshot,
+ ).integrationDiscovery;
+ expect(snapshot).toMatchObject({
+ skipped: true,
+ completed: false,
+ matchedIntegrationIds: [],
+ });
+ await reconcileSetupPlatformEvents(auth);
+ expect(mocks.schedule).not.toHaveBeenCalled();
+ const questions = await (
+ await context()
+ ).adapterExtensions.resolveUserInputPreset!('setup_integrations');
+ expect(questions[0]?.options?.map((option) => option.id)).toEqual([
+ 'continue',
+ ]);
+ await continueDiscovery();
+ expect(
+ JSON.parse((await context()).setupSnapshot).integrationDiscovery
+ .completed,
+ ).toBe(true);
+ });
+
+ it('keeps canonical prose-derived connector matches on the persisted final request across reloads', async () => {
+ const questions = await (
+ await context()
+ ).adapterExtensions.resolveUserInputPreset!('setup_integrations', {
+ documents: { answers: ['Granola', 'Google Docs'] },
+ 'project-tracking': { answers: ['Vercel'] },
+ });
+ expect(questions[0]?.options?.map((option) => option.id)).toEqual([
+ 'vercel',
+ 'granola',
+ 'continue',
+ ]);
+ await request({
+ requestId: 'prose-tools',
+ sessionId: conversationId,
+ turnId: 'prose-tools',
+ callId: 'prose-tools',
+ status: 'pending',
+ preset: 'setup_integrations',
+ questions,
+ });
+ const [saved] = await db
+ .select()
+ .from(fastAgentMessages)
+ .where(eq(fastAgentMessages.eventId, 'event:prose-tools'));
+ expect(saved?.payload).toMatchObject({
+ preset: 'setup_integrations',
+ questions: [
+ {
+ options: [
+ { id: 'vercel', label: 'Vercel' },
+ { id: 'granola', label: 'Granola' },
+ { id: 'continue', label: 'Continue' },
+ ],
+ },
+ ],
+ });
+ expect(
+ JSON.parse((await context()).setupSnapshot).integrationDiscovery,
+ ).toMatchObject({
+ completed: false,
+ matchedIntegrationIds: ['vercel', 'granola'],
+ });
+ });
+
+ it('resumes persisted category answers and exactly matches catalog options in homepage order', async () => {
+ await answeredCategory('communication', ['Discord', 'slack']);
+ await answeredCategory('monitoring', ['Grafana', 'Sentry', 'Datadog']);
+ const turn = await context();
+ const snapshot = JSON.parse(turn.setupSnapshot).integrationDiscovery;
+ expect(snapshot.answeredCategoryIds).toEqual(['monitoring']);
+ expect(snapshot.unsupportedTools).toEqual(['Datadog']);
+ expect(
+ snapshot.categories.map((category: { id: string }) => category.id),
+ ).toEqual(['documents', 'monitoring', 'project-tracking']);
+ const questions = await turn.adapterExtensions.resolveUserInputPreset!(
+ 'setup_integrations',
+ {
+ communication: { answers: ['Teams'] },
+ documents: { answers: ['notion'] },
+ 'project-tracking': { answers: ['Jira-like'] },
+ },
+ );
+ expect(questions[0]?.options?.map((option) => option.id)).toEqual([
+ 'notion',
+ 'sentry',
+ 'grafana',
+ 'continue',
+ ]);
+ await continueDiscovery();
+ expect(
+ JSON.parse((await context()).setupSnapshot).integrationDiscovery
+ .matchedIntegrationIds,
+ ).toEqual(['sentry', 'grafana']);
+ });
+
+ it('filters provider IDs out of old persisted preset options and new hints', async () => {
+ await request({
+ requestId: 'legacy-integrations',
+ sessionId: conversationId,
+ turnId: 'legacy',
+ callId: 'legacy',
+ status: 'pending',
+ preset: 'setup_integrations',
+ questions: [
+ {
+ id: 'setup-integrations',
+ header: 'Tools',
+ question: 'Your tools?',
+ isOther: false,
+ isSecret: false,
+ options: [
+ { id: 'slack', label: 'Slack', description: 'Old provider option' },
+ {
+ id: 'vercel',
+ label: 'Vercel',
+ description: 'Old provider option',
+ },
+ {
+ id: 'supabase',
+ label: 'Supabase',
+ description: 'Eligible connector',
+ },
+ ],
+ },
+ ],
+ });
+ const turn = await context();
+ expect(
+ JSON.parse(turn.setupSnapshot).integrationDiscovery.matchedIntegrationIds,
+ ).toEqual(['vercel', 'supabase']);
+ const questions = await turn.adapterExtensions.resolveUserInputPreset!(
+ 'setup_integrations',
+ {
+ documents: { answers: ['Slack', 'Vercel', 'Railway'] },
+ communication: { answers: ['discord'] },
+ },
+ );
+ expect(questions[0]?.options?.map(({ id }) => id)).toEqual([
+ 'vercel',
+ 'supabase',
+ 'railway',
+ 'continue',
+ ]);
+ });
+
+ it('suppresses async setup events and starter choices during discovery without gating setup completion', async () => {
+ expect(await reconcileSetupPlatformEvents(auth)).toBe(true);
+ expect(mocks.complete).toHaveBeenCalled();
+ expect(
+ mocks.schedule.mock.calls.map(
+ ([turn]) =>
+ JSON.parse(turn.question.replace(/<\/?platform_event>/g, '')).type,
+ ),
+ ).toEqual(['session_creation']);
+ await answeredCategory('documents', ['Notion']);
+ mocks.schedule.mockClear();
+ await reconcileSetupPlatformEvents(auth);
+ for (const kind of [
+ 'provider_selection',
+ 'source_connection',
+ 'compute_readiness',
+ 'starter_selection',
+ 'recommendation_readiness',
+ ] as const) {
+ expect(
+ await scheduleSetupPlatformEvent(auth, {
+ kind,
+ fingerprint: 'test',
+ payload: {},
+ }),
+ ).toEqual({ scheduled: false });
+ }
+ expect(mocks.schedule).not.toHaveBeenCalled();
+ await expect(
+ (await context()).adapterExtensions.resolveUserInputPreset!(
+ 'setup_starter_tasks',
+ ),
+ ).rejects.toThrow('optional tool discovery');
+ await continueDiscovery();
+ expect(
+ mocks.schedule.mock.calls.some(([turn]) =>
+ turn.question.includes('starter_request'),
+ ),
+ ).toBe(true);
+ const questions = await (
+ await context()
+ ).adapterExtensions.resolveUserInputPreset!('setup_starter_tasks');
+ expect(questions[0]?.options).toEqual(
+ SETUP_STARTER_TASKS.map((task) => ({
+ label: task.title,
+ description: task.description,
+ })),
+ );
+ });
+
+ it('preserves old sessions without retroactively starting optional discovery', async () => {
+ const state = await readState();
+ delete state.setupSession!.integrationDiscoveryCompletedAt;
+ await db
+ .update(deploymentSettings)
+ .set({ setupNewState: state })
+ .where(eq(deploymentSettings.id, 'default'));
+ expect(
+ JSON.parse((await context()).setupSnapshot).integrationDiscovery
+ .completed,
+ ).toBe(true);
+ await expect(getOrCreateSetupSessionCommand(auth)).resolves.toEqual({
+ sessionId,
+ created: false,
+ });
+ expect(
+ mocks.schedule.mock.calls.some(([turn]) =>
+ turn.question.includes('starter_request'),
+ ),
+ ).toBe(true);
+ });
+
+ it('restricts setup continuation and context to its admin owner', async () => {
+ await expect(
+ submitSetupSessionUserInputCommand(
+ { ...auth, isAdmin: false },
+ { sessionId, requestId: 'integrations', answers: {} },
+ ),
+ ).rejects.toThrow('Unauthorized');
+ await expect(
+ submitSetupSessionUserInputCommand(
+ { ...auth, userId: 'other-admin' },
+ { sessionId, requestId: 'integrations', answers: {} },
+ ),
+ ).rejects.toThrow('does not belong');
+ await expect(
+ resolveSetupSessionTurnContext(
+ { ...auth, userId: 'other-admin' },
+ sessionId,
+ ),
+ ).resolves.toBeNull();
+ expect(mocks.submit).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts
index a25ab4064e..a93156a476 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -22,6 +22,15 @@ import {
normalizeSetupNewState,
normalizeSetupNewSetupSession,
RunStatus,
+ SETUP_INTEGRATION_CATEGORIES,
+ SETUP_INTEGRATIONS,
+ SETUP_INTEGRATIONS_QUESTION_ID,
+ SETUP_INTEGRATIONS_CONTINUE_OPTION,
+ getSetupIntegrationQuestionId,
+ isSetupIntegrationDiscoveryQuestionId,
+ matchSetupIntegrationAnswers,
+ parseAcpRequestUserInputPayload,
+ parseAcpRequestUserInputResponsePayload,
type AcpRequestUserInputAnswers,
type AcpRequestUserInputPayload,
type AutomationRecommendationBatch,
@@ -89,6 +98,14 @@ async function assertSetupStarterWorkReady(
const setupSession = normalizeSetupNewSetupSession(
status.setupNewState.setupSession,
);
+ if (
+ setupSession?.integrationDiscoveryCompletedAt === null &&
+ !setupSession.starterTaskSelection
+ ) {
+ throw new Error(
+ 'Finish or skip the optional tool discovery before choosing first work. No connections are required.',
+ );
+ }
if (options.requireStarterSelection && !setupSession?.starterTaskSelection) {
throw new Error('Choose your first work before starting a task.');
}
@@ -191,6 +208,9 @@ function buildSetupEventTurnId(input: {
function buildSetupSnapshot(input: {
status: Awaited>;
hasSuccessfulStarterLaunch: boolean;
+ integrationDiscovery: Awaited<
+ ReturnType
+ >;
}): string {
const state = normalizeSetupNewState(input.status.setupNewState);
const setupSession = normalizeSetupNewSetupSession(state.setupSession);
@@ -200,6 +220,7 @@ function buildSetupSnapshot(input: {
);
return JSON.stringify({
+ integrationDiscovery: input.integrationDiscovery,
rail: deriveSetupRailMilestones(input.status),
sourceControl: {
selectedProvider: state.sourceControlProvider,
@@ -232,6 +253,7 @@ async function resolveSetupSnapshot(auth: UserAuthSuccess): Promise {
);
return buildSetupSnapshot({
status,
+ integrationDiscovery: await readSetupIntegrationDiscovery(auth),
hasSuccessfulStarterLaunch: setupSession?.starterTaskSelection
? await hasSuccessfulSetupSessionTaskLaunch(
auth,
@@ -241,6 +263,109 @@ async function resolveSetupSnapshot(auth: UserAuthSuccess): Promise {
});
}
+async function readSetupIntegrationDiscovery(
+ auth: UserAuthSuccess,
+ suppliedAnswers: AcpRequestUserInputAnswers = {},
+) {
+ const state = await readSetupNewState();
+ const setupSession = normalizeSetupNewSetupSession(state.setupSession);
+ const conversation = await findSetupSessionConversation(auth);
+ const messages = conversation
+ ? await db
+ .select({
+ eventType: fastAgentMessages.eventType,
+ payload: fastAgentMessages.payload,
+ })
+ .from(fastAgentMessages)
+ .where(
+ and(
+ eq(
+ fastAgentMessages.conversationId,
+ conversation.fastConversationId,
+ ),
+ sql`${fastAgentMessages.eventType} IN (${ACP_ENVELOPE_EVENT_TYPES.RequestUserInput}, ${ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse})`,
+ ),
+ )
+ .orderBy(fastAgentMessages.ts, fastAgentMessages.id)
+ : [];
+ const requests = new Map();
+ const answers: AcpRequestUserInputAnswers = { ...suppliedAnswers };
+ let finalMatches: string[] = [];
+ let skipped = false;
+ for (const message of messages) {
+ if (message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) {
+ const request = parseAcpRequestUserInputPayload(message.payload);
+ if (request) {
+ requests.set(request.requestId, request);
+ if (request.preset === 'setup_integrations') {
+ finalMatches = request.questions.flatMap(
+ (question) =>
+ question.options?.flatMap((option) =>
+ option.id ? [option.id] : [],
+ ) ?? [],
+ );
+ }
+ }
+ }
+ }
+ // Resolve by request ID rather than assuming distinct or monotonic timestamps.
+ for (const message of messages) {
+ if (
+ message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse
+ ) {
+ const response = parseAcpRequestUserInputResponsePayload(message.payload);
+ const request = response ? requests.get(response.requestId) : undefined;
+ if (!response || !request || request.preset) continue;
+ if (response.resolution === 'cancelled') {
+ if (
+ request.questions.some((question) =>
+ isSetupIntegrationDiscoveryQuestionId(question.id),
+ )
+ )
+ skipped = true;
+ continue;
+ }
+ for (const category of SETUP_INTEGRATION_CATEGORIES) {
+ const questionId = getSetupIntegrationQuestionId(category.id);
+ if (request.questions.some((question) => question.id === questionId)) {
+ const answer = response.answers[questionId];
+ if (!answer) continue;
+ if (
+ answer.answers.some((value) =>
+ ['skip', 'skip for now'].includes(value.trim().toLowerCase()),
+ )
+ )
+ skipped = true;
+ // Persisted user answers take precedence over model-extracted prose preferences.
+ answers[questionId] = answer;
+ }
+ }
+ }
+ }
+ const matches = matchSetupIntegrationAnswers(answers);
+ const completed =
+ setupSession?.integrationDiscoveryCompletedAt !== null ||
+ Boolean(setupSession?.starterTaskSelection);
+ return {
+ completed,
+ skipped,
+ ...matches,
+ matchedIntegrationIds: SETUP_INTEGRATIONS.filter(
+ (integration) =>
+ finalMatches.includes(integration.id) ||
+ matches.matchedIntegrationIds.includes(integration.id),
+ ).map((integration) => integration.id),
+ hasInputRequest: requests.size > 0,
+ categories: SETUP_INTEGRATION_CATEGORIES.map((category) => ({
+ ...category,
+ questionId: getSetupIntegrationQuestionId(category.id),
+ integrations: SETUP_INTEGRATIONS.filter((integration) =>
+ (category.integrationIds as readonly string[]).includes(integration.id),
+ ),
+ })),
+ };
+}
+
async function hasSuccessfulSetupSessionTaskLaunch(
auth: UserAuthSuccess,
selectedAt: string,
@@ -303,7 +428,38 @@ async function buildSetupSessionAdapterExtensions(
auth: UserAuthSuccess,
): Promise> {
return {
- resolveUserInputPreset: async (preset) => {
+ resolveUserInputPreset: async (preset, setupIntegrationAnswers) => {
+ assertAdmin(auth);
+ if (!(await findSetupSessionConversation(auth)))
+ throw new Error('This request does not belong to the setup Session.');
+ if (preset === 'setup_integrations') {
+ const discovery = await readSetupIntegrationDiscovery(
+ auth,
+ setupIntegrationAnswers,
+ );
+ if (discovery.completed)
+ throw new Error('Optional tool discovery is already complete.');
+ return [
+ {
+ id: SETUP_INTEGRATIONS_QUESTION_ID,
+ header: 'Your tools',
+ question:
+ 'Connect any useful tools, or continue without connections.',
+ isOther: false,
+ isSecret: false,
+ options: [
+ ...SETUP_INTEGRATIONS.filter((integration) =>
+ discovery.matchedIntegrationIds.includes(integration.id),
+ ).map((integration) => ({
+ id: integration.id,
+ label: integration.name,
+ description: `Connect ${integration.name} in Settings.`,
+ })),
+ SETUP_INTEGRATIONS_CONTINUE_OPTION,
+ ],
+ },
+ ];
+ }
if (preset !== 'setup_starter_tasks') {
throw new Error('Unsupported setup input preset.');
}
@@ -355,6 +511,9 @@ async function buildSetupPlatformEventTurn(
prepared?: {
conversation: SetupSessionConversation;
setupSnapshot: string;
+ integrationDiscovery: Awaited<
+ ReturnType
+ >;
},
): Promise[0] | null> {
assertAdmin(auth);
@@ -362,6 +521,15 @@ async function buildSetupPlatformEventTurn(
prepared?.conversation ?? (await findSetupSessionConversation(auth));
if (!conversation) return null;
+ const integrationDiscovery =
+ prepared?.integrationDiscovery ??
+ (await readSetupIntegrationDiscovery(auth));
+ if (
+ !integrationDiscovery.completed &&
+ (input.kind !== 'session_creation' || integrationDiscovery.hasInputRequest)
+ )
+ return null;
+
const currentMessageId = buildSetupEventTurnId({
sessionId: conversation.sessionId,
workflowVersion: conversation.workflowVersion,
@@ -430,9 +598,11 @@ export async function reconcileSetupPlatformEvents(
setupSession.starterTaskSelection.selectedAt,
)
: false;
+ const integrationDiscovery = await readSetupIntegrationDiscovery(auth);
const setupSnapshot = buildSetupSnapshot({
status,
hasSuccessfulStarterLaunch,
+ integrationDiscovery,
});
const connected = status.sourceControlSetup.providers.filter(
@@ -620,6 +790,7 @@ export async function reconcileSetupPlatformEvents(
const turn = await buildSetupPlatformEventTurn(auth, event, {
conversation,
setupSnapshot,
+ integrationDiscovery,
});
if (turn) scheduleWebFastAgentTurn(turn);
}
@@ -802,10 +973,18 @@ async function persistSetupPresetResponse(input: {
}): Promise {
assertAdmin(input.auth);
const preset = input.request.payload.preset;
- if (preset !== 'setup_starter_tasks') {
+ if (preset !== 'setup_starter_tasks' && preset !== 'setup_integrations') {
throw new Error('The setup starter-task preset is missing.');
}
- await assertSetupStarterWorkReady(input.auth);
+ if (preset === 'setup_starter_tasks')
+ await assertSetupStarterWorkReady(input.auth);
+ else if (
+ input.answers[SETUP_INTEGRATIONS_QUESTION_ID]?.answers.length !== 1 ||
+ input.answers[SETUP_INTEGRATIONS_QUESTION_ID]?.answers[0] !==
+ SETUP_INTEGRATIONS_CONTINUE_OPTION.label
+ ) {
+ throw new Error('Continue with or without connecting tools.');
+ }
await db.transaction(async (tx) => {
await tx.execute(
@@ -860,7 +1039,7 @@ async function persistSetupPresetResponse(input: {
}) ?? [],
),
];
- if (taskIds.length === 0) {
+ if (preset === 'setup_starter_tasks' && taskIds.length === 0) {
throw new Error('Select at least one starter task.');
}
const selectedAt = new Date();
@@ -868,11 +1047,15 @@ async function persistSetupPresetResponse(input: {
...state,
setupSession: {
...setupSession,
- starterTaskSelection: {
- requestId: input.request.payload.requestId,
- taskIds,
- selectedAt: selectedAt.toISOString(),
- },
+ ...(preset === 'setup_integrations'
+ ? { integrationDiscoveryCompletedAt: selectedAt.toISOString() }
+ : {
+ starterTaskSelection: {
+ requestId: input.request.payload.requestId,
+ taskIds,
+ selectedAt: selectedAt.toISOString(),
+ },
+ }),
},
};
const now = new Date();
@@ -914,29 +1097,30 @@ async function persistSetupPresetResponse(input: {
},
source: 'web',
});
- await tx
- .insert(fastAgentMessages)
- .values({
- conversationId: input.fastConversationId,
- ...buildSetupReceiptMessage({
- sessionId: setupSession.sessionId,
- workflowVersion: setupSession.workflowVersion,
- userId: input.auth.userId,
- kind: 'starter_selection',
- fingerprint: input.request.payload.requestId,
- text: formatStarterSelectionReceipt(
- taskIds.map(
- (taskId) =>
- SETUP_STARTER_TASKS.find((task) => task.id === taskId)!.title,
+ if (preset === 'setup_starter_tasks')
+ await tx
+ .insert(fastAgentMessages)
+ .values({
+ conversationId: input.fastConversationId,
+ ...buildSetupReceiptMessage({
+ sessionId: setupSession.sessionId,
+ workflowVersion: setupSession.workflowVersion,
+ userId: input.auth.userId,
+ kind: 'starter_selection',
+ fingerprint: input.request.payload.requestId,
+ text: formatStarterSelectionReceipt(
+ taskIds.map(
+ (taskId) =>
+ SETUP_STARTER_TASKS.find((task) => task.id === taskId)!.title,
+ ),
),
- ),
- payload: { taskIds },
- ts: now.getTime(),
- }),
- })
- .onConflictDoNothing({
- target: [fastAgentMessages.conversationId, fastAgentMessages.eventId],
- });
+ payload: { taskIds },
+ ts: now.getTime(),
+ }),
+ })
+ .onConflictDoNothing({
+ target: [fastAgentMessages.conversationId, fastAgentMessages.eventId],
+ });
});
}
@@ -968,3 +1152,23 @@ export async function submitSetupSessionUserInputCommand(
},
});
}
+
+/** Attach setup capabilities to ordinary replies as well as structured input turns. */
+export async function resolveSetupSessionTurnContext(
+ auth: UserAuthSuccess,
+ sessionId: string,
+) {
+ const conversation = await findSetupSessionConversation(auth);
+ if (
+ !conversation ||
+ (conversation.sessionId !== sessionId &&
+ conversation.fastConversationId !== sessionId)
+ )
+ return null;
+ assertAdmin(auth);
+ return {
+ adapterExtensions: await buildSetupSessionAdapterExtensions(auth),
+ setupSnapshot: await resolveSetupSnapshot(auth),
+ setupSession: true as const,
+ };
+}
diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts
index 6963a81798..564f624ca0 100644
--- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts
@@ -429,6 +429,27 @@ describe('Fast native tool schemas as OpenAI receives them', () => {
).toEqual(request);
});
+ it('preserves discovery prose preferences through the native bridge', async () => {
+ const inputTool = tools.find(
+ (tool) => tool.name === FAST_AGENT_NATIVE_TOOL_NAMES.requestUserInput,
+ )!;
+ const request = {
+ preset: 'setup_integrations',
+ setupIntegrationAnswers: { communication: { answers: ['Slack'] } },
+ };
+ const parsed = zod.z
+ .object(inputTool.args as Record)
+ .parse(request);
+ const execute = inputTool.execute as (
+ args: unknown,
+ context: unknown,
+ ) => Promise<{ name: string; args: unknown }>;
+ expect(await execute(parsed, {})).toEqual({
+ name: 'request_user_input',
+ args: request,
+ });
+ });
+
it('rejects a bare union or object as args, the shape that broke OpenAI models', () => {
const { z } = zod;
const question = z.object({ id: z.string() });
diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts
index 8abdd049fc..aa46be5a0a 100644
--- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts
@@ -1016,6 +1016,109 @@ describe('answerFastAgentQuestion native OpenCode tools', () => {
});
});
+ it.each([undefined, { documents: { answers: ['Notion'] } }])(
+ 'resolves integration discovery with optional prose preferences: %j',
+ async (setupIntegrationAnswers) => {
+ const questions = [
+ {
+ id: 'setup-integrations',
+ header: 'Connections',
+ question: 'Which tools would you like to connect?',
+ isOther: false,
+ isSecret: false,
+ options: [
+ { id: 'notion', label: 'Notion', description: 'Documents' },
+ ],
+ },
+ ];
+ const requestUserInput = vi.fn();
+ const resolveUserInputPreset = vi.fn(async () => questions);
+ mocks.generateText.mockImplementation(
+ async (_params, _session, options) => {
+ await options.onSessionReady('opencode-session-1');
+ await invokeTool(nativeToolNames.requestUserInput, {
+ preset: 'setup_integrations',
+ ...(setupIntegrationAnswers !== undefined
+ ? { setupIntegrationAnswers }
+ : {}),
+ questions: [
+ { id: 'ignored', header: 'Ignored', question: 'Ignored' },
+ ],
+ });
+ return '';
+ },
+ );
+ await answerFastAgentQuestion({
+ ...baseParams,
+ conversation: {
+ surface: 'web',
+ workspaceId: 'deployment-1',
+ conversationId: 'setup-session-1',
+ },
+ turnSource: 'platform_event',
+ platformEventKind: 'setup',
+ platformEventVisibility: 'required',
+ setupSession: true,
+ adapter: callbacks({ requestUserInput, resolveUserInputPreset }),
+ });
+ expect(resolveUserInputPreset.mock.calls).toEqual([
+ setupIntegrationAnswers === undefined
+ ? ['setup_integrations']
+ : ['setup_integrations', setupIntegrationAnswers],
+ ]);
+ expect(requestUserInput).toHaveBeenCalledWith({
+ requestId: expect.any(String),
+ preset: 'setup_integrations',
+ questions,
+ });
+ expect(mocks.upsertMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.objectContaining({
+ payload: expect.objectContaining({ questions }),
+ }),
+ }),
+ );
+ },
+ );
+
+ it.each(['setup_starter_tasks', undefined])(
+ 'rejects integration preferences outside their preset: %s',
+ async (preset) => {
+ const resolveUserInputPreset = vi.fn();
+ const requestUserInput = vi.fn();
+ let toolResult: unknown;
+ mocks.generateText.mockImplementation(
+ async (_params, _session, options) => {
+ await options.onSessionReady('opencode-session-1');
+ toolResult = await invokeTool(nativeToolNames.requestUserInput, {
+ ...(preset
+ ? { preset }
+ : {
+ questions: [
+ { id: 'q', header: 'Tools', question: 'Which tools?' },
+ ],
+ }),
+ setupIntegrationAnswers: { documents: { answers: ['Notion'] } },
+ });
+ return 'Please choose your tools.';
+ },
+ );
+ await answerFastAgentQuestion({
+ ...baseParams,
+ conversation: {
+ surface: 'web',
+ workspaceId: 'deployment-1',
+ conversationId: 'setup-session-1',
+ },
+ setupSession: true,
+ adapter: callbacks({ requestUserInput, resolveUserInputPreset }),
+ });
+ expect(toolResult).toEqual(expect.objectContaining({ success: false }));
+ expect(resolveUserInputPreset).not.toHaveBeenCalled();
+ expect(requestUserInput).not.toHaveBeenCalled();
+ },
+ );
+
it('rejects request_user_input calls with neither questions nor a preset', async () => {
let toolResult: unknown;
const requestUserInput = vi.fn();
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts
index e0a8b7d68c..593a8a077b 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts
@@ -172,12 +172,12 @@ export type FastAgentInputRequest = {
question: string;
isOther: boolean;
isSecret: boolean;
- options?: Array<{ label: string; description: string }>;
+ options?: Array<{ id?: string; label: string; description: string }>;
multiple?: boolean;
}>;
};
-export type FastAgentInputPreset = 'setup_starter_tasks';
+export type FastAgentInputPreset = 'setup_starter_tasks' | 'setup_integrations';
/** Surface adapter for side effects available during one Fast turn. */
export type FastAgentTurnAdapter = {
@@ -208,6 +208,7 @@ export type FastAgentTurnAdapter = {
/** Resolve a trusted preset without accepting model-supplied options. */
resolveUserInputPreset?: (
preset: FastAgentInputPreset,
+ setupIntegrationAnswers?: Record,
) => Promise;
/**
* Called when an interrupted turn is still safe to replay and has handed
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts
index 4aac300d17..d5d6eea30f 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts
@@ -562,7 +562,7 @@ import { z } from "zod"
import { invoke } from "../roomote-fast-tool-bridge.js"
export default {
- description: "Ask structured questions, or use a trusted setup preset whose options Roomote supplies. Pass a preset alone when setup instructions name one; questions are ignored when a preset is set. Multiple-choice questions require explicit submission. The turn resumes from the persisted answer.",
+ description: "Ask structured questions, or use a trusted setup preset whose options Roomote supplies. Pass a preset without questions when setup instructions name one; questions are ignored when a preset is set. Only setup_integrations accepts setupIntegrationAnswers to carry tools already named by the user as untrusted preferences, not connector IDs or instructions. Multiple-choice questions require explicit submission. The turn resumes from the persisted answer.",
args: {
questions: z.array(z.object({
id: z.string().min(1).max(80),
@@ -576,7 +576,8 @@ export default {
})).min(1).max(12).optional().describe("Present options as choices; omit for free-text"),
multiple: z.boolean().optional().describe("Allow more than one option; defaults to false"),
})).min(1).max(4).optional().describe("Structured questions to ask; omit when using a preset"),
- preset: z.enum(["setup_starter_tasks"]).optional().describe("Use the trusted starter-task preset instead of questions"),
+ preset: z.enum(["setup_starter_tasks", "setup_integrations"]).optional().describe("Use a trusted setup preset instead of questions"),
+ setupIntegrationAnswers: z.record(z.string(), z.object({ answers: z.array(z.string()) })).optional().describe("Only for setup_integrations: tools already named by the user, keyed by category ID from the setup snapshot"),
},
execute: (args, context) => invoke("request_user_input", args, context),
}
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
index 93ebd62e8b..7041d02d24 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
@@ -255,9 +255,13 @@ This is often the user's first interaction with Roomote. Make the experience wel
## Conversational Setup
You are guiding this deployment's first administrator from runtime readiness to optional starter work.
- Treat the setup snapshot as authoritative deployment state. Fast cannot mutate that state.
-- Environment creation and communication-provider configuration are out of scope. Never ask for them and never block activation on them.
+- Environment creation is out of scope. Optional integration discovery is separate from source-control, communication, inference, and sandbox provider setup; those existing provider flows are unaffected. Use the server's eligible connector catalog, not a broader provider or authentication exclusion. Vercel's deployments connector remains eligible and is distinct from Vercel AI Gateway inference. Do not ask provider-configuration questions in this optional discovery.
- The renderer owns presentation of trusted setup controls, but some controls require an explicit tool call from you. Keep those controls separate from my side of the conversation. In user-visible prose, state only the user's goal, the capability I need, the outcome that changed, or the decision the user needs to make. Never name, locate, or instruct the user to interact with UI elements such as cards, rails, dialogs, panels, buttons, presets, or setup steps. Do not describe what the interface displays or will display. Never ask for credentials in chat; detailed source-control instructions and credential entry remain in the trusted interface.
-- Source control must be connected and repositories synchronized before setup completes or starter tasks are offered. Inference and sandbox readiness remain prerequisites for completion. When source control is not connected, explain that I need access to the user's source code, then stop after the user-visible response; source-control controls are state-driven. When all completion requirements are ready and the setup snapshot has no starter selection, the server emits a starter-request setup event. Starter work is optional and never gates setup completion. On that event, call \`request_user_input\` with exactly \`{ preset: "setup_starter_tasks" }\`. Do not send a closeout first: that tool call creates the user-visible first-work control and is the terminal response for the turn. Do not replace the tool call with prose asking the user to choose. The server supplies the choices; never invent or repeat their catalog in prose. Never ask where I should run the work before collecting the first-work selection.
+- Source control must be connected and repositories synchronized before setup completes or starter tasks are offered. Inference and sandbox readiness remain prerequisites for completion, but none of these prerequisites delay optional integration discovery. After discovery is completed, when source control is not connected, explain that I need access to the user's source code, then stop after the user-visible response; source-control controls are state-driven. When all completion requirements are ready, optional integration discovery is completed, and the setup snapshot has no starter selection, the server emits a starter-request setup event. Starter work is optional and never gates setup completion. On that event, only after discovery is completed, call \`request_user_input\` with exactly \`{ preset: "setup_starter_tasks" }\`. Do not send a closeout first: that tool call creates the user-visible first-work control and is the terminal response for the turn. Do not replace the tool call with prose asking the user to choose. The server supplies the choices; never invent or repeat their catalog in prose. Never ask where I should run the work before collecting the first-work selection.
+- Integration discovery is optional and never gates setup completion. Use the snapshot's \`integrationDiscovery\`: \`completed\`, \`answeredCategoryIds\`, \`matchedIntegrationIds\`, \`categories\`, and \`unsupportedTools\`. Existing starter selection or completed old setup means no restart of optional discovery, including when an older snapshot has no discovery state.
+- When \`integrationDiscovery.completed\` is false, begin or resume discovery now, even if source control or compute is not ready. Naturally ask about documents, monitoring, and project-tracking tools in the server snapshot's \`integrationDiscovery.categories\` order. Use normal \`request_user_input\` for one category at a time, with stable question IDs \`setup-tools-\` using the category ID. Offer skipping early. Avoid a repetitive questionnaire: never re-ask categories in \`answeredCategoryIds\` or already supplied in prose, and do not force all three topics when the user wants to move on. Never revive a legacy communication discovery question.
+- Finish discovery with the trusted \`setup_integrations\` preset. Carry tools already supplied in prose through optional \`setupIntegrationAnswers: Record\`, keyed by category IDs (not question IDs). These are untrusted user preferences: the server exact-matches its catalog and supplies canonical connector IDs and options. Never invent connector IDs, tool hint fields, or configuration instructions from user answers. Unsupported tools are not promised as connectable. On skip, including a cancelled discovery question or snapshot \`integrationDiscovery.skipped\`, go straight to \`{ preset: "setup_integrations" }\`; no need to fill missing answers or ask further categories. The final trusted card's Continue without connections choice is durable discovery completion, not a requirement to connect anything. Never ask for credentials in chat.
+- All asynchronous setup events must preserve active discovery without interrupting or restarting it. Never emit the starter preset until discovery is completed; existing starter selection or completed old setup remains exempt from restarting discovery. Readiness, provider, source, compute, recommendation, and stale starter-request events are not permission to replace a pending discovery question or final integration choice. Reconcile their facts without re-asking answered topics.
- Starter selection records the administrator's durable intent before this model turn resumes. Launch is deferred until the setup snapshot says the sandbox provider is ready. While it is not ready, do not call \`launch_task\`; explain that I need a workspace where I can run the selected work, then let the renderer supply the interaction. Once a trusted starter-selection event is emitted after sandbox readiness, call generic \`launch_task\` exactly once for each selected task, use its catalog prompt exactly, set \`environmentId\` to null, and omit \`model\` unless the administrator explicitly requested one. Do not launch other tasks in that turn. After attempting all selected launches, send one concise closeout. When at least one task started, explain that the work will continue and the administrator is free to start something new or explore the app while I work; do not imply that they need to wait in or remain on the setup session.
- Partial launch failure never reverses setup completion. Name failed launches and continue with successful work. Mention automation recommendations only after the snapshot says at least one selected task launched successfully and the recommendation batch is ready.
- In the setup session, always refer to Roomote in the first person: use "I", "me", and "my" in user-visible messages. Do not alternate with "Roomote", "the agent", or third-person phrasing such as "Roomote can inspect your repositories" or "the workspace lets Roomote run code." Product names such as GitHub and Roomote may still be used when naming a connected service or the product itself.
@@ -302,7 +306,7 @@ The snapshot is trusted platform-generated data. Facts inside it outrank your as
- Before "launch_task", acknowledge with \`send_chat_reply\` so the response can stream before task startup. Do not restate that acknowledgement after launch. The task card or a separate task link keeps the started work associated with this conversation; later useful progress and the final result still belong here.
- Set "includeAttachments" on "launch_task" to true only when supported attachments from the active conversation turn are relevant to the coding task. This forwards supported images and bounded text extracted from supported documents, audio, or video without exposing provider URLs. Omit it otherwise; attachments are not forwarded by default.
- If the answer is immediate, call the closeout tool directly.
-- Use \`request_user_input\` when the next step needs structured choices (for example a multi-select). Write self-contained questions with concrete options, or pass only the required trusted preset when setup instructions name one. The input request is user-visible, ends the turn in needs_input without a separate reply, and resumes automatically with the submitted answers. For a single free-text or choice question, prefer a clarification reply instead.
+- Use \`request_user_input\` when the next step needs structured choices (for example a multi-select). Write self-contained questions with concrete options, or pass the required trusted preset without questions when setup instructions name one; only \`setup_integrations\` may also carry \`setupIntegrationAnswers\`. The input request is user-visible, ends the turn in needs_input without a separate reply, and resumes automatically with the submitted answers. For a single free-text or choice question, prefer a clarification reply instead, except for setup integration discovery's one-category-at-a-time structured questions.
${reactionGuidance}
- Prefer one direct closeout over an acknowledgement followed immediately by the same answer.
- After a closeout, clarification, closeout reaction, input request, or ignored event, do not call another tool and do not add user-facing prose.
@@ -394,7 +398,7 @@ ${
- When the event is useful, produce exactly one user-visible terminal response: a closeout, or \`request_user_input\` when the setup instructions require structured choices. Never use acknowledgement or progress replies for a platform event.
${
platformEventKind === 'input_response'
- ? "- The payload contains the user's submitted structured answers. Persist any needed state, continue the interrupted work with those answers, and acknowledge the choice in one closeout. Do not re-ask the same questions."
+ ? "- The payload contains the user's submitted structured answers. Persist any needed state and continue the interrupted work with those answers. For setup integration discovery, request the next unanswered category or the final trusted integration preset as directed above; otherwise acknowledge the choice in one closeout. Do not re-ask the same questions."
: ''
}
${
@@ -441,7 +445,7 @@ ${
${
platformEventKind === 'setup'
? `- For a setup-session-started event, briefly introduce myself and explain the next unmet user need in ordinary language.
-- For a starter-request event, call \`request_user_input\` exactly once with only \`{ preset: "setup_starter_tasks" }\`, then stop. Do not replace the tool call with prose asking the user to choose.
+- For a starter-request event, call \`request_user_input\` exactly once with only \`{ preset: "setup_starter_tasks" }\` only after integration discovery is completed (or existing starter selection/completed old setup exempts discovery), then stop. Otherwise preserve discovery without interrupting or restarting it. Do not replace the tool call with prose asking the user to choose.
- For a starter-tasks-selected event, launch each canonical task definition exactly once with "launch_task": use its prompt verbatim, null for environmentId, and no model unless explicitly requested. The event is emitted only after the sandbox readiness fact is true; if the trusted snapshot disagrees, do not launch and report the configuration blocker. After all launch attempts, post one concise closeout. If any selected task started, say that the started work will continue while the user starts something new or explores the app. The persisted selection is authoritative and setup is already complete; launch failures do not reverse it.
- For provider, source, compute, or recommendation events, use the supplied trusted facts and snapshot without claiming that I made configuration changes myself.
`
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts
index b47b4a7506..6f509d951b 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts
@@ -541,7 +541,14 @@ const requestUserInputQuestionSchema = z.object({
.optional(),
multiple: z.boolean().optional(),
});
-const fastAgentInputPresetSchema = z.enum(['setup_starter_tasks']);
+const fastAgentInputPresetSchema = z.enum([
+ 'setup_starter_tasks',
+ 'setup_integrations',
+]);
+const setupIntegrationAnswersSchema = z.record(
+ z.string(),
+ z.object({ answers: z.array(z.string()) }),
+);
// Some models fill every optional tool parameter, so a trusted preset may
// arrive alongside placeholder questions. The preset wins: its questions are
// server-supplied and model-provided ones are discarded rather than rejected.
@@ -549,16 +556,33 @@ const requestUserInputArgsSchema = z
.object({
questions: z.array(requestUserInputQuestionSchema).min(1).max(4).optional(),
preset: fastAgentInputPresetSchema.optional(),
+ setupIntegrationAnswers: setupIntegrationAnswersSchema.optional(),
})
+ .refine(
+ (args) =>
+ args.setupIntegrationAnswers === undefined ||
+ args.preset === 'setup_integrations',
+ 'setupIntegrationAnswers is only available with setup_integrations.',
+ )
.transform(
(
args,
):
- | { preset: FastAgentInputPreset }
+ | {
+ preset: FastAgentInputPreset;
+ setupIntegrationAnswers?: z.output<
+ typeof setupIntegrationAnswersSchema
+ >;
+ }
| { questions: z.output[] }
| null =>
args.preset
- ? { preset: args.preset }
+ ? {
+ preset: args.preset,
+ ...(args.setupIntegrationAnswers !== undefined
+ ? { setupIntegrationAnswers: args.setupIntegrationAnswers }
+ : {}),
+ }
: args.questions
? { questions: args.questions }
: null,
@@ -4336,9 +4360,12 @@ export async function answerFastAgentQuestion({
const questions =
'questions' in args
? args.questions
- : await adapter.resolveUserInputPreset!(
- args.preset as FastAgentInputPreset,
- );
+ : args.setupIntegrationAnswers !== undefined
+ ? await adapter.resolveUserInputPreset!(
+ args.preset,
+ args.setupIntegrationAnswers,
+ )
+ : await adapter.resolveUserInputPreset!(args.preset);
for (const question of questions) {
if (question.options && question.isSecret) {
return {
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts
index e0dccc8b8e..34cd293baf 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts
@@ -117,6 +117,36 @@ describe('setup prompt guidance and snapshot injection', () => {
expect(prompt).not.toContain('update_plan');
});
+ it('keeps discovery optional, ordered, resumable, and server-resolved', () => {
+ const prompt = buildFastAgentSystemPrompt({
+ ...baseInput,
+ setupSession: true,
+ });
+ for (const rule of [
+ 'Integration discovery is optional and never gates setup completion',
+ 'documents, monitoring, and project-tracking',
+ 'those existing provider flows are unaffected',
+ 'Do not ask provider-configuration questions in this optional discovery',
+ 'integrationDiscovery.categories',
+ 'setup-tools-',
+ 'Offer skipping early',
+ 'already supplied in prose',
+ 'setupIntegrationAnswers',
+ 'keyed by category IDs (not question IDs)',
+ 'server exact-matches its catalog',
+ 'Continue without connections',
+ 'no need to fill missing answers',
+ 'All asynchronous setup events must preserve active discovery',
+ 'Never emit the starter preset until discovery is completed',
+ 'Existing starter selection or completed old setup means no restart',
+ 'answeredCategoryIds',
+ 'matchedIntegrationIds',
+ 'unsupportedTools',
+ ])
+ expect(prompt).toContain(rule);
+ expect(prompt).not.toContain('Naturally ask about communication');
+ });
+
it('omits setup sections for ordinary sessions', () => {
const prompt = buildFastAgentSystemPrompt(baseInput);
diff --git a/packages/types/src/acp-request-user-input.test.ts b/packages/types/src/acp-request-user-input.test.ts
index 397fd8e3e1..d4d3efb40a 100644
--- a/packages/types/src/acp-request-user-input.test.ts
+++ b/packages/types/src/acp-request-user-input.test.ts
@@ -102,6 +102,23 @@ describe('request_user_input multi-select payloads', () => {
preset: 'setup_starter_tasks',
})?.preset,
).toBe('setup_starter_tasks');
+ expect(
+ parseAcpRequestUserInputPayload({
+ ...payload,
+ preset: 'setup_integrations',
+ questions: [
+ {
+ ...singleQuestion,
+ options: [
+ { id: 'slack', label: 'Slack', description: 'Connect Slack' },
+ ],
+ },
+ ],
+ }),
+ ).toMatchObject({
+ preset: 'setup_integrations',
+ questions: [{ options: [{ id: 'slack', label: 'Slack' }] }],
+ });
expect(
parseAcpRequestUserInputPayload({ ...payload, preset: 'untrusted' })
?.preset,
diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts
index e8b801e414..f6965ba98e 100644
--- a/packages/types/src/acp.ts
+++ b/packages/types/src/acp.ts
@@ -164,6 +164,8 @@ export const ACP_REQUEST_USER_INPUT_METHOD =
export const ACP_REQUEST_USER_INPUT_REQUEST_ID_PREFIX = 'rui' as const;
export interface AcpRequestUserInputQuestionOption {
+ /** Canonical option identity supplied by trusted server presets. */
+ id?: string;
label: string;
description: string;
}
@@ -233,7 +235,7 @@ export interface AcpRequestUserInputRequestParams {
export interface AcpRequestUserInputPayload extends AcpRequestUserInputRequestParams {
requestId: string;
status: 'pending';
- preset?: 'setup_starter_tasks';
+ preset?: 'setup_starter_tasks' | 'setup_integrations';
}
export interface AcpRequestUserInputResponsePayload {
@@ -307,7 +309,8 @@ function parseAcpRequestUserInputQuestionOption(
return null;
}
- return { label, description };
+ const id = asStringOrNull(record?.id);
+ return { label, description, ...(id ? { id } : {}) };
}
export function parseAcpRequestUserInputQuestion(
@@ -405,7 +408,10 @@ export function parseAcpRequestUserInputPayload(
const requestId = asStringOrNull(payload?.requestId);
const request = parseAcpRequestUserInputRequestParams(payload);
const preset =
- payload?.preset === 'setup_starter_tasks' ? payload.preset : undefined;
+ payload?.preset === 'setup_starter_tasks' ||
+ payload?.preset === 'setup_integrations'
+ ? payload.preset
+ : undefined;
if (!requestId || !request) {
return null;
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 53212d172b..dc84e853a7 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -78,6 +78,7 @@ export * from './control-plane-env-vars';
export * from './setup-auth-config';
export * from './setup-compute-config';
export * from './setup-new';
+export * from './onboarding-integrations';
export * from './setup-source-control-config';
export * from './source-control';
export * from './slack';
diff --git a/packages/types/src/onboarding-integrations.test.ts b/packages/types/src/onboarding-integrations.test.ts
new file mode 100644
index 0000000000..9d53eb79e0
--- /dev/null
+++ b/packages/types/src/onboarding-integrations.test.ts
@@ -0,0 +1,186 @@
+import { MCP_INTEGRATIONS } from './mcp-oauth';
+import { communicationProviders } from './communication';
+import { sourceControlProviders } from './source-control';
+import { computeProviders } from './compute-providers/compute-provider';
+import { SETUP_MODEL_PROVIDER_IDS } from './model-provider-config';
+import {
+ ADMIN_INTEGRATION_ORDER,
+ COMMUNICATION_PROVIDER_ORDER,
+ SOURCE_CONTROL_PROVIDER_ORDER,
+ SETUP_INTEGRATION_CATEGORIES,
+ SETUP_INTEGRATIONS,
+ SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS,
+ getSetupIntegrationCategories,
+ isSetupIntegrationDiscoveryQuestionId,
+ matchSetupIntegrationAnswers,
+} from './onboarding-integrations';
+
+describe('setup integration discovery catalog', () => {
+ it('excludes the four provider categories, retaining the distinct Vercel connector and homepage priority', () => {
+ const excluded = new Set([
+ ...sourceControlProviders,
+ ...communicationProviders,
+ ...computeProviders,
+ ...SETUP_MODEL_PROVIDER_IDS.filter((id) => id !== 'vercel'),
+ ]);
+ expect(SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS).toEqual(excluded);
+ const ids = SETUP_INTEGRATIONS.map(({ id }) => id);
+ expect(ids).toEqual(
+ [
+ ...new Set([
+ ...ADMIN_INTEGRATION_ORDER,
+ ...MCP_INTEGRATIONS.map(({ id }) => id),
+ ]),
+ ].filter(
+ (id) =>
+ !excluded.has(id) &&
+ MCP_INTEGRATIONS.some((integration) => integration.id === id),
+ ),
+ );
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const id of excluded) expect(ids).not.toContain(id);
+ expect(ids).toContain('vercel');
+ expect(SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS.has('microsoft')).toBe(
+ false,
+ );
+ expect(
+ MCP_INTEGRATIONS.filter(({ id }) =>
+ (SETUP_MODEL_PROVIDER_IDS as readonly string[]).includes(id),
+ ).map(({ id }) => id),
+ ).toEqual(['vercel']);
+ expect(ids).toEqual(
+ expect.arrayContaining(['railway', 'supabase', 'granola']),
+ );
+ for (const integration of SETUP_INTEGRATIONS) {
+ expect(integration.kind).toBe('mcp');
+ expect(integration.name).toBe(
+ MCP_INTEGRATIONS.find(({ id }) => id === integration.id)?.name,
+ );
+ }
+ expect(SETUP_INTEGRATION_CATEGORIES.map(({ id }) => id)).toEqual([
+ 'documents',
+ 'monitoring',
+ 'project-tracking',
+ ]);
+ for (const category of SETUP_INTEGRATION_CATEGORIES) {
+ expect(category.integrationIds.length).toBeGreaterThan(0);
+ for (const id of category.integrationIds) expect(ids).toContain(id);
+ }
+ });
+
+ it('preserves the separate homepage provider controls and ordering', () => {
+ expect(COMMUNICATION_PROVIDER_ORDER).toEqual([
+ 'slack',
+ 'microsoft',
+ 'telegram',
+ 'discord',
+ ]);
+ expect(SOURCE_CONTROL_PROVIDER_ORDER).toEqual([
+ 'github',
+ 'gitlab',
+ 'gitea',
+ 'bitbucket',
+ 'ado',
+ ]);
+ expect(ADMIN_INTEGRATION_ORDER).toContain('vercel');
+ });
+
+ it('derives category order from eligible first appearances, not provider entries', () => {
+ const categories = getSetupIntegrationCategories([
+ 'slack',
+ 'vercel',
+ 'asana',
+ 'grafana',
+ 'linear',
+ 'microsoft',
+ 'notion',
+ ...ADMIN_INTEGRATION_ORDER,
+ ]);
+ expect(categories.map(({ id }) => id)).toEqual([
+ 'project-tracking',
+ 'monitoring',
+ 'documents',
+ ]);
+ expect(
+ categories.find(({ id }) => id === 'project-tracking')?.integrationIds,
+ ).toEqual(['asana', 'linear', 'jira', 'monday']);
+ expect(
+ categories.flatMap(({ integrationIds }) => integrationIds),
+ ).not.toContain('vercel');
+ });
+
+ it('matches all eligible catalog names and IDs globally', () => {
+ for (const category of SETUP_INTEGRATION_CATEGORIES) {
+ expect(
+ matchSetupIntegrationAnswers({
+ [category.id]: {
+ answers: SETUP_INTEGRATIONS.flatMap(({ id, name }) => [id, name]),
+ },
+ }),
+ ).toEqual({
+ answeredCategoryIds: [category.id],
+ matchedIntegrationIds: SETUP_INTEGRATIONS.map(({ id }) => id),
+ unsupportedTools: [],
+ });
+ }
+ });
+
+ it('never restores providers from legacy answers or model-extracted hints', () => {
+ expect(
+ matchSetupIntegrationAnswers({
+ 'setup-tools-communication': {
+ answers: ['slack', 'Discord', 'Notion'],
+ },
+ communication: { answers: ['Microsoft Teams'] },
+ documents: {
+ answers: [
+ 'Vercel',
+ 'slack',
+ 'Microsoft Teams',
+ 'github',
+ 'Granola',
+ 'Google Docs',
+ ],
+ },
+ }),
+ ).toEqual({
+ answeredCategoryIds: ['documents'],
+ matchedIntegrationIds: ['vercel', 'granola'],
+ unsupportedTools: ['Google Docs'],
+ });
+ expect(
+ isSetupIntegrationDiscoveryQuestionId('setup-tools-communication'),
+ ).toBe(true);
+ expect(isSetupIntegrationDiscoveryQuestionId('setup-tools-documents')).toBe(
+ true,
+ );
+ expect(isSetupIntegrationDiscoveryQuestionId('unrelated')).toBe(false);
+ });
+
+ it('matches whole names only and deduplicates in homepage order', () => {
+ expect(
+ matchSetupIntegrationAnswers({
+ documents: { answers: ['Notion Calendar', 'notion', 'notion'] },
+ monitoring: { answers: ['Grafana, Sentry; PostHog\nDatadog'] },
+ 'project-tracking': { answers: ['asana', 'linear', 'none', 'skip'] },
+ unrelated: { answers: ['jira'] },
+ }),
+ ).toEqual({
+ answeredCategoryIds: ['documents', 'monitoring', 'project-tracking'],
+ matchedIntegrationIds: [
+ 'notion',
+ 'sentry',
+ 'linear',
+ 'posthog',
+ 'grafana',
+ 'asana',
+ ],
+ unsupportedTools: ['Notion Calendar', 'Datadog'],
+ });
+ expect(
+ matchSetupIntegrationAnswers({
+ documents: { answers: ['We do not use Notion'] },
+ }).matchedIntegrationIds,
+ ).toEqual([]);
+ });
+});
diff --git a/packages/types/src/onboarding-integrations.ts b/packages/types/src/onboarding-integrations.ts
new file mode 100644
index 0000000000..13bb8a5353
--- /dev/null
+++ b/packages/types/src/onboarding-integrations.ts
@@ -0,0 +1,203 @@
+import {
+ communicationProviders,
+ communicationProviderDisplayNames,
+} from './communication';
+import { MCP_INTEGRATIONS } from './mcp-oauth';
+import { sourceControlProviders } from './source-control';
+import { computeProviders } from './compute-providers/compute-provider';
+import { SETUP_MODEL_PROVIDER_IDS } from './model-provider-config';
+import type { AcpRequestUserInputAnswers } from './acp';
+
+export const ADMIN_INTEGRATION_ORDER = [
+ 'notion',
+ 'sentry',
+ 'linear',
+ 'jira',
+ 'monday',
+ 'vercel',
+ 'supabase',
+ 'posthog',
+ 'grafana',
+ 'asana',
+] as const;
+
+// The homepage account-linking provider is named microsoft, while chat uses teams.
+export const COMMUNICATION_PROVIDER_ORDER = [
+ 'slack',
+ 'microsoft',
+ 'telegram',
+ 'discord',
+] as const;
+export const SOURCE_CONTROL_PROVIDER_ORDER = [
+ 'github',
+ 'gitlab',
+ 'gitea',
+ 'bitbucket',
+ 'ado',
+] as const;
+
+export const SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS: ReadonlySet =
+ new Set([
+ ...sourceControlProviders,
+ ...communicationProviders,
+ ...computeProviders,
+ // Vercel's deployments connector is distinct from Vercel AI Gateway inference.
+ ...SETUP_MODEL_PROVIDER_IDS.filter((id) => id !== 'vercel'),
+ ]);
+
+export type SetupIntegrationId = (typeof MCP_INTEGRATIONS)[number]['id'];
+
+export const SETUP_INTEGRATIONS = [
+ ...new Set([
+ ...ADMIN_INTEGRATION_ORDER,
+ ...MCP_INTEGRATIONS.map((integration) => integration.id),
+ ]),
+]
+ .filter((id) => !SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS.has(id))
+ .flatMap<{
+ id: SetupIntegrationId;
+ name: string;
+ kind: 'mcp';
+ }>((id) => {
+ const integration = MCP_INTEGRATIONS.find(
+ (candidate) => candidate.id === id,
+ );
+ return integration
+ ? [{ id, name: integration.name, kind: 'mcp' as const }]
+ : [];
+ });
+
+const setupIntegrationCategories = [
+ {
+ id: 'documents',
+ label: 'Documents',
+ question: 'Where do you keep team documents and knowledge?',
+ integrationIds: ['notion', 'granola', 'supermemory'],
+ },
+ {
+ id: 'monitoring',
+ label: 'Monitoring',
+ question: 'What do you use for monitoring and product analytics?',
+ integrationIds: [
+ 'sentry',
+ 'posthog',
+ 'grafana',
+ 'betterstack',
+ 'braintrust',
+ ],
+ },
+ {
+ id: 'project-tracking',
+ label: 'Project tracking',
+ question: 'Where do you track projects and issues?',
+ integrationIds: ['linear', 'jira', 'monday', 'asana'],
+ },
+] as const;
+
+export type SetupIntegrationCategoryId =
+ (typeof setupIntegrationCategories)[number]['id'];
+
+export function getSetupIntegrationCategories(
+ homepageOrder: readonly string[] = ADMIN_INTEGRATION_ORDER,
+) {
+ const order = [
+ ...new Set([
+ ...homepageOrder,
+ ...SETUP_INTEGRATIONS.map((integration) => integration.id),
+ ]),
+ ].filter((id) =>
+ SETUP_INTEGRATIONS.some((integration) => integration.id === id),
+ );
+ return setupIntegrationCategories
+ .map((category) => ({
+ ...category,
+ integrationIds: order.filter((id) =>
+ (category.integrationIds as readonly string[]).includes(id),
+ ),
+ }))
+ .filter((category) => category.integrationIds.length > 0)
+ .sort(
+ (left, right) =>
+ order.indexOf(left.integrationIds[0]!) -
+ order.indexOf(right.integrationIds[0]!),
+ );
+}
+
+export const SETUP_INTEGRATION_CATEGORIES = getSetupIntegrationCategories();
+
+export const SETUP_INTEGRATIONS_QUESTION_ID = 'setup-integrations';
+export const SETUP_INTEGRATIONS_CONTINUE_OPTION = {
+ id: 'continue',
+ label: 'Continue',
+ description:
+ 'Continue with or without connecting tools. You can connect them later in Settings.',
+} as const;
+
+export function getSetupIntegrationQuestionId(
+ categoryId: SetupIntegrationCategoryId,
+): string {
+ return `setup-tools-${categoryId}`;
+}
+
+/** Older sessions can still have an unanswered communication discovery question. */
+export function isSetupIntegrationDiscoveryQuestionId(
+ questionId: string,
+): boolean {
+ return (
+ questionId === 'setup-tools-communication' ||
+ SETUP_INTEGRATION_CATEGORIES.some(
+ (category) => getSetupIntegrationQuestionId(category.id) === questionId,
+ )
+ );
+}
+
+/** Only whole catalog IDs/names match; unsupported tools never become a guessed connector. */
+export function matchSetupIntegrationAnswers(
+ answers: AcpRequestUserInputAnswers,
+) {
+ const matched = new Set();
+ const unsupported = new Set();
+ const answeredCategoryIds: SetupIntegrationCategoryId[] = [];
+ for (const category of SETUP_INTEGRATION_CATEGORIES) {
+ const response =
+ answers[getSetupIntegrationQuestionId(category.id)] ??
+ answers[category.id];
+ if (!response) continue;
+ answeredCategoryIds.push(category.id);
+ for (const value of response.answers.flatMap((answer) =>
+ answer.split(/[,;\n]/),
+ )) {
+ const token = value.trim().toLowerCase();
+ if (
+ !token ||
+ ['none', 'skip', 'skip for now', 'not sure'].includes(token)
+ )
+ continue;
+ const integration = SETUP_INTEGRATIONS.find(
+ (candidate) =>
+ candidate.id.toLowerCase() === token ||
+ candidate.name.toLowerCase() === token,
+ );
+ if (integration) matched.add(integration.id);
+ else if (
+ !SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS.has(token) &&
+ !Object.values(communicationProviderDisplayNames).some(
+ (name) => name.toLowerCase() === token,
+ ) &&
+ !MCP_INTEGRATIONS.some(
+ (candidate) =>
+ candidate.id.toLowerCase() === token ||
+ candidate.name.toLowerCase() === token,
+ )
+ )
+ unsupported.add(value.trim());
+ }
+ }
+ return {
+ answeredCategoryIds,
+ matchedIntegrationIds: SETUP_INTEGRATIONS.filter((integration) =>
+ matched.has(integration.id),
+ ).map((integration) => integration.id),
+ unsupportedTools: [...unsupported],
+ };
+}
diff --git a/packages/types/src/setup-new.test.ts b/packages/types/src/setup-new.test.ts
index 9048de2688..8d6e756fd5 100644
--- a/packages/types/src/setup-new.test.ts
+++ b/packages/types/src/setup-new.test.ts
@@ -25,6 +25,23 @@ import {
} from './setup-new';
describe('setup-session metadata', () => {
+ it('adds pending discovery only to new sessions and preserves continuation on resume', () => {
+ const session = createSetupNewSetupSession({ sessionId: 'session' });
+ expect(
+ normalizeSetupNewSetupSession(session)?.integrationDiscoveryCompletedAt,
+ ).toBeNull();
+ const completedAt = '2026-09-09T12:00:00.000Z';
+ expect(
+ normalizeSetupNewSetupSession({
+ ...session,
+ integrationDiscoveryCompletedAt: completedAt,
+ })?.integrationDiscoveryCompletedAt,
+ ).toBe(completedAt);
+ const { integrationDiscoveryCompletedAt: _, ...legacy } = session;
+ expect(normalizeSetupNewSetupSession(legacy)).not.toHaveProperty(
+ 'integrationDiscoveryCompletedAt',
+ );
+ });
it('normalizes state without setup-session metadata to null', () => {
const state = normalizeSetupNewState({});
diff --git a/packages/types/src/setup-new.ts b/packages/types/src/setup-new.ts
index 647c53f4f0..1f2dcbd063 100644
--- a/packages/types/src/setup-new.ts
+++ b/packages/types/src/setup-new.ts
@@ -158,6 +158,8 @@ export function isSetupStarterTaskId(
*/
export type SetupNewSetupSession = {
workflowVersion: number;
+ /** Missing on pre-discovery sessions; null means the optional conversation is pending. */
+ integrationDiscoveryCompletedAt?: string | null;
/** Unified (canonical) session ID shown in routes and transcript. */
sessionId: string;
startedAt: string;
@@ -177,6 +179,7 @@ export function createSetupNewSetupSession(input: {
sessionId: input.sessionId,
startedAt: input.startedAt ?? new Date().toISOString(),
starterTaskSelection: null,
+ integrationDiscoveryCompletedAt: null,
};
}
@@ -228,6 +231,15 @@ export function normalizeSetupNewSetupSession(
sessionId,
startedAt,
starterTaskSelection,
+ ...(record.integrationDiscoveryCompletedAt === null
+ ? { integrationDiscoveryCompletedAt: null }
+ : asIsoTimestamp(record.integrationDiscoveryCompletedAt)
+ ? {
+ integrationDiscoveryCompletedAt: asIsoTimestamp(
+ record.integrationDiscoveryCompletedAt,
+ ),
+ }
+ : {}),
};
}
From 57a91e4a8ca01adaef069479398a0eb0aec264dd Mon Sep 17 00:00:00 2001
From: Roomote
Date: Wed, 9 Sep 2026 15:43:57 +0000
Subject: [PATCH 2/2] improve: suggest only named tools during setup
---
apps/docs/self-hosting.mdx | 7 +-
.../SetupIntegrationsCard.client.test.tsx | 240 +++++++++++++-----
.../setup/SetupIntegrationsCard.tsx | 162 ++++++------
.../server/fast-agent/fast-agent-prompt.ts | 2 +-
.../fast-agent/fast-agent-setup-tools.test.ts | 4 +-
5 files changed, 259 insertions(+), 156 deletions(-)
diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx
index 71c27e9cdd..1d93032ae6 100644
--- a/apps/docs/self-hosting.mdx
+++ b/apps/docs/self-hosting.mdx
@@ -204,10 +204,11 @@ chat.
The conversation also asks briefly about the tools your team uses for
documents, monitoring, and project tracking, one topic at a time.
-You can skip these questions. The optional integrations card highlights matching
-available connectors and opens their secure configuration without leaving setup.
+You can skip these questions. The optional integrations card lists only supported
+tools you said you use and opens their secure configuration without leaving setup.
+If there are no eligible matches, setup moves on without showing suggestions.
Tools without a built-in connector are not presented as supported. Use
-**Continue without connections** to move on; you can connect tools later in
+**Keep going** to move on without connecting; you can connect tools later in
Settings. Integration choices do not change the starter tasks offered.
Services that are also source-control, communications, inference, or sandbox
providers are excluded from this optional step; their separate setup is unchanged.
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx
index 3c1bba9220..e3e05451bc 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx
@@ -1,4 +1,5 @@
import {
+ act,
fireEvent,
render,
screen,
@@ -6,6 +7,7 @@ import {
within,
} from '@testing-library/react';
import {
+ SETUP_INTEGRATIONS,
SETUP_INTEGRATIONS_CONTINUE_OPTION,
type AcpRequestUserInputPayload,
} from '@roomote/types';
@@ -19,6 +21,7 @@ const mocks = vi.hoisted(() => ({
pending: false,
submitError: false,
submitPending: false,
+ onSuccess: () => {},
enabled: true,
connections: [] as { mcpId: string; authStatus: string }[],
enablements: [] as { mcpId: string; enabled: boolean }[],
@@ -37,7 +40,14 @@ vi.mock('@/hooks/useTelemetry', () => ({
vi.mock('@/trpc/client', () => ({
useTRPC: () => ({
onboarding: { status: { queryOptions: () => ({}) } },
- setup: { submitSessionUserInput: { mutationOptions: () => ({}) } },
+ setup: {
+ submitSessionUserInput: {
+ mutationOptions: (options: { onSuccess: () => void }) => {
+ mocks.onSuccess = options.onSuccess;
+ return options;
+ },
+ },
+ },
}),
}));
vi.mock('@tanstack/react-query', () => ({
@@ -118,7 +128,7 @@ beforeEach(() => {
});
});
-it('highlights catalog matches without claiming support for unknown options, in homepage order', () => {
+it('shows only eligible option IDs in catalog order without badges or unmentioned defaults', () => {
render( );
const rows = within(
screen.getByRole('list', { name: 'Available integrations' }),
@@ -127,13 +137,12 @@ it('highlights catalog matches without claiming support for unknown options, in
rows.map((row) =>
within(row).getByRole('button').getAttribute('aria-label'),
),
- ).toEqual([
- 'Configure Notion',
- 'Configure Sentry',
- 'Configure Linear',
- 'Configure Jira',
- ]);
- expect(screen.getAllByText('Your tools')).toHaveLength(2);
+ ).toEqual(['Connect Notion', 'Connect Jira']);
+ expect(screen.queryByText('Your tools')).not.toBeInTheDocument();
+ expect(screen.queryByText('Available')).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /Refresh|See all/ }),
+ ).not.toBeInTheDocument();
expect(screen.queryByText('Google Docs')).not.toBeInTheDocument();
expect(mocks.capture).toHaveBeenCalledWith('setup_integrations_shown', {
matchedCount: 2,
@@ -142,9 +151,7 @@ it('highlights catalog matches without claiming support for unknown options, in
it('continues with zero connections using the durable setup input contract', () => {
render( );
- fireEvent.click(
- screen.getByRole('button', { name: 'Continue without connections' }),
- );
+ fireEvent.click(screen.getByRole('button', { name: 'Keep going' }));
expect(mocks.mutate).toHaveBeenCalledWith({
sessionId: 's',
requestId: 'integration-request',
@@ -156,12 +163,14 @@ it('shows live connected, disabled and attention states instead of inferring a c
mocks.connections = [{ mcpId: 'notion', authStatus: 'authenticated' }];
mocks.enablements = [
{ mcpId: 'notion', enabled: true },
- { mcpId: 'sentry', enabled: true },
+ { mcpId: 'jira', enabled: true },
];
render( );
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.getByText('Needs connection')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Continue setup' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Manage Notion' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Connect Jira' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled();
});
it('keeps continuation available during loading, status failure and operator disablement', () => {
@@ -169,34 +178,46 @@ it('keeps continuation available during loading, status failure and operator dis
mocks.error = true;
mocks.enabled = false;
render( );
+ expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeDisabled();
expect(
- screen.getByRole('button', { name: 'Continue without connections' }),
- ).toBeEnabled();
+ screen.getByText(/couldn't load connection status/),
+ ).toBeInTheDocument();
expect(
- screen.getByRole('button', { name: 'Configure Notion' }),
- ).toBeDisabled();
- fireEvent.click(screen.getByRole('button', { name: 'Refresh status' }));
- expect(mocks.refetch).toHaveBeenCalledTimes(4);
+ screen.queryByRole('button', { name: /Refresh/ }),
+ ).not.toBeInTheDocument();
+ expect(mocks.refetch).not.toHaveBeenCalled();
});
-it('opens existing secure configuration inline and refreshes after cancellation', async () => {
- render( );
- fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' }));
- await waitFor(() =>
- expect(
- screen.getByText('Secure configuration: notion'),
- ).toBeInTheDocument(),
- );
- expect(mocks.capture).toHaveBeenCalledWith(
- 'setup_integration_configuration_opened',
- { integration_id: 'notion' },
- );
- fireEvent.click(screen.getByRole('button', { name: 'Back to setup' }));
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
- expect(mocks.refetch).toHaveBeenCalledTimes(4);
-});
+it.each(['Back to setup', 'Escape'])(
+ 'opens secure configuration inline and automatically refreshes on %s',
+ async (closeAction) => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Connect Notion' }));
+ await waitFor(() =>
+ expect(
+ screen.getByText('Secure configuration: notion'),
+ ).toBeInTheDocument(),
+ );
+ expect(mocks.capture).toHaveBeenCalledWith(
+ 'setup_integration_configuration_opened',
+ { integration_id: 'notion' },
+ );
+ expect(mocks.refetch).not.toHaveBeenCalled();
+ if (closeAction === 'Escape') {
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
+ } else {
+ fireEvent.click(screen.getByRole('button', { name: 'Back to setup' }));
+ }
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
+ );
+ expect(mocks.refetch).toHaveBeenCalledTimes(4);
+ expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled();
+ },
+);
-it('never reintroduces provider overlaps from an old pending request', () => {
+it('filters provider overlaps from an old request while allowing matched Vercel', () => {
const oldRequest = {
...request,
questions: request.questions.map((question) => ({
@@ -204,35 +225,42 @@ it('never reintroduces provider overlaps from an old pending request', () => {
options: [
...(question.options ?? []),
{ id: 'slack', label: 'Slack', description: '' },
+ { id: 'github', label: 'GitHub', description: '' },
+ { id: 'gitlab', label: 'GitLab', description: '' },
+ { id: 'teams', label: 'Teams', description: '' },
+ { id: 'discord', label: 'Discord', description: '' },
+ { id: 'telegram', label: 'Telegram', description: '' },
{ id: 'vercel', label: 'Vercel', description: '' },
],
})),
};
render( );
- fireEvent.click(screen.getByRole('button', { name: /See all/ }));
- expect(
- screen.queryByRole('button', { name: 'Configure Slack' }),
- ).not.toBeInTheDocument();
- expect(
- screen.getByRole('button', { name: 'Configure Vercel' }),
- ).toBeInTheDocument();
+ for (const name of [
+ 'Slack',
+ 'GitHub',
+ 'GitLab',
+ 'Teams',
+ 'Discord',
+ 'Telegram',
+ ]) {
+ expect(
+ screen.queryByRole('button', { name: `Connect ${name}` }),
+ ).not.toBeInTheDocument();
+ }
expect(
- screen.getByRole('button', { name: 'Configure Supabase' }),
+ screen.getByRole('button', { name: 'Connect Vercel' }),
).toBeInTheDocument();
expect(
- screen.getByRole('button', { name: 'Continue without connections' }),
- ).toBeEnabled();
+ screen.queryByRole('button', { name: 'Connect Supabase' }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled();
});
it('requires an administrator for configuration, not for displaying the optional continue action', () => {
mocks.isAdmin = false;
render( );
- expect(
- screen.getByRole('button', { name: 'Configure Notion' }),
- ).toBeDisabled();
- expect(
- screen.getByRole('button', { name: 'Continue without connections' }),
- ).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled();
});
it('allows retry after a failed continue and does not disclose callback reason values', () => {
@@ -242,19 +270,109 @@ it('allows retry after a failed continue and does not disclose callback reason v
expect(screen.getByText(/Authorization didn't finish/)).toBeInTheDocument();
expect(screen.queryByText(/private-value/)).not.toBeInTheDocument();
expect(screen.getByText(/Couldn't continue setup/)).toBeInTheDocument();
- fireEvent.click(
- screen.getByRole('button', { name: 'Continue without connections' }),
+ fireEvent.click(screen.getByRole('button', { name: 'Keep going' }));
+ expect(mocks.mutate).toHaveBeenCalledTimes(1);
+});
+
+it('shows every match immediately even when many connectors match', () => {
+ const manyRequest = {
+ ...request,
+ questions: request.questions.map((question) => ({
+ ...question,
+ options: SETUP_INTEGRATIONS.map((integration) => ({
+ id: integration.id,
+ label: integration.name,
+ description: '',
+ })),
+ })),
+ };
+ render( );
+ expect(screen.getAllByRole('listitem')).toHaveLength(
+ SETUP_INTEGRATIONS.length,
+ );
+ for (const integration of SETUP_INTEGRATIONS) {
+ expect(
+ screen.getByRole('button', { name: `Connect ${integration.name}` }),
+ ).toBeVisible();
+ }
+ expect(
+ screen.queryByRole('button', { name: /See all|Show less/ }),
+ ).not.toBeInTheDocument();
+});
+
+it.each([
+ ['no mentions', []],
+ [
+ 'unsupported mentions only',
+ [{ id: 'google-docs', label: 'Google Docs', description: '' }],
+ ],
+] as const)(
+ 'auto-skips %s once per request ID without rendering a card',
+ (_name, options) => {
+ const skipped = {
+ ...request,
+ questions: request.questions.map((question) => ({
+ ...question,
+ options: [...options, SETUP_INTEGRATIONS_CONTINUE_OPTION],
+ })),
+ };
+ const { container, rerender } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ expect(mocks.mutate).toHaveBeenCalledExactlyOnceWith({
+ sessionId: 's',
+ requestId: request.requestId,
+ answers: { 'setup-integrations': { answers: ['Continue'] } },
+ });
+ expect(mocks.capture).not.toHaveBeenCalled();
+ rerender( );
+ expect(mocks.mutate).toHaveBeenCalledTimes(1);
+ rerender(
+ ,
+ );
+ expect(mocks.mutate).toHaveBeenCalledTimes(2);
+ },
+);
+
+it('shows retry only after auto-continue fails and hides it after success', () => {
+ const skipped = { ...request, questions: [] };
+ const { container, rerender } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ mocks.submitError = true;
+ rerender( );
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ /Couldn't continue setup/,
);
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
expect(mocks.mutate).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole('button', { name: 'Keep going' }));
+ expect(mocks.mutate).toHaveBeenCalledTimes(2);
+ act(() => mocks.onSuccess());
+ expect(container).toBeEmptyDOMElement();
+ expect(mocks.capture).toHaveBeenCalledWith('setup_integrations_continued');
});
-it('makes the rest of the supported catalog available on demand', () => {
+it('shows unavailable status without offering manual refresh after a status error', () => {
+ mocks.error = true;
render( );
+ expect(screen.getAllByText('Status unavailable')).toHaveLength(2);
+ expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled();
expect(
- screen.queryByRole('button', { name: 'Configure Granola' }),
+ screen.queryByRole('button', { name: /Refresh/ }),
).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: /See all/ }));
- expect(
- screen.getByRole('button', { name: 'Configure Granola' }),
- ).toBeInTheDocument();
+});
+
+it('does not treat an authenticated but disabled connector as connected', () => {
+ mocks.connections = [{ mcpId: 'notion', authStatus: 'authenticated' }];
+ render( );
+ expect(screen.getByText('Not enabled')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeEnabled();
+ expect(screen.queryByText('Connected')).not.toBeInTheDocument();
});
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx
index b9f01d492b..95761fb3ed 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx
@@ -8,7 +8,6 @@ import { toast } from 'sonner';
import {
MCP_INTEGRATIONS,
SETUP_INTEGRATIONS,
- SETUP_INTEGRATION_CATEGORIES,
SETUP_INTEGRATIONS_CONTINUE_OPTION,
SETUP_INTEGRATIONS_QUESTION_ID,
isDeploymentScopedMcpIntegration,
@@ -16,7 +15,7 @@ import {
} from '@roomote/types';
import {
- Badge,
+ ArrowRight,
Button,
Dialog,
DialogContent,
@@ -25,7 +24,6 @@ import {
DialogHeader,
DialogTitle,
Plug,
- RefreshCw,
Skeleton,
} from '@/components/system';
import { McpIcon } from '@/components/settings/McpIcon';
@@ -66,22 +64,28 @@ export function SetupIntegrationsCard({
const connections = useUserMcpConnections();
const availability = useCuratedIntegrationsAvailability();
const connectMcp = useConnectMcp();
- const [showAll, setShowAll] = useState(false);
const [activeId, setActiveId] = useState(null);
const [continued, setContinued] = useState(false);
const shownRequest = useRef(null);
+ const skippedRequest = useRef(null);
const matchedIds = new Set(
request.questions
.find((question) => question.id === SETUP_INTEGRATIONS_QUESTION_ID)
?.options?.map((option) => option.id) ?? [],
);
- const matchedCount = SETUP_INTEGRATIONS.filter((integration) =>
+ const visibleIntegrations = SETUP_INTEGRATIONS.filter((integration) =>
matchedIds.has(integration.id),
- ).length;
+ );
+ const matchedCount = visibleIntegrations.length;
useEffect(() => {
- if (!enabled || shownRequest.current === request.requestId) return;
+ if (
+ !enabled ||
+ matchedCount === 0 ||
+ shownRequest.current === request.requestId
+ )
+ return;
shownRequest.current = request.requestId;
capture('setup_integrations_shown', { matchedCount });
}, [capture, enabled, matchedCount, request.requestId]);
@@ -95,6 +99,21 @@ export function SetupIntegrationsCard({
onError: (error) => toast.error(error.message),
}),
);
+ const { mutate } = submit;
+ useEffect(() => {
+ if (matchedCount !== 0 || skippedRequest.current === request.requestId)
+ return;
+ skippedRequest.current = request.requestId;
+ mutate({
+ sessionId,
+ requestId: request.requestId,
+ answers: {
+ [SETUP_INTEGRATIONS_QUESTION_ID]: {
+ answers: [SETUP_INTEGRATIONS_CONTINUE_OPTION.label],
+ },
+ },
+ });
+ }, [matchedCount, mutate, request.requestId, sessionId]);
const refresh = () => {
void onboarding.refetch();
void enablements.refetch();
@@ -127,45 +146,56 @@ export function SetupIntegrationsCard({
const getStatus = (integration: (typeof SETUP_INTEGRATIONS)[number]) => {
if (statusPending || statusError) return null;
if (integration.id === 'linear')
- return onboarding.data?.orgHasLinear ? 'Connected' : 'Available';
+ return onboarding.data?.orgHasLinear ? 'Connected' : null;
if (authenticatedIds.has(integration.id))
return enabledIds.has(integration.id) ? 'Connected' : 'Not enabled';
- return enabledIds.has(integration.id) ? 'Needs connection' : 'Available';
+ return enabledIds.has(integration.id) ? 'Needs connection' : null;
};
- const hasConnections = SETUP_INTEGRATIONS.some(
- (integration) => getStatus(integration) === 'Connected',
- );
- const previewIds = new Set(
- SETUP_INTEGRATION_CATEGORIES.map((category) => category.integrationIds[0]),
- );
- const visibleIntegrations = SETUP_INTEGRATIONS.filter(
- (integration) =>
- showAll ||
- matchedIds.has(integration.id) ||
- previewIds.has(integration.id),
- );
const authFailed =
searchParams.get('mcp') === 'error' || searchParams.get('error') !== null;
- if (continued)
- return (
-
- You can connect more tools any time in Settings.
-
- );
+ if (continued) return null;
+
+ const keepGoing = (
+
+ mutate({
+ sessionId,
+ requestId: request.requestId,
+ answers: {
+ [SETUP_INTEGRATIONS_QUESTION_ID]: {
+ answers: [SETUP_INTEGRATIONS_CONTINUE_OPTION.label],
+ },
+ },
+ })
+ }
+ >
+ Keep going
+
+ );
+ const continuationError = submit.isError ? (
+
+ Couldn't continue setup. Please try again.
+
+ ) : null;
+ if (matchedCount === 0) {
+ return submit.isError ? (
+
+ {continuationError}
+ {keepGoing}
+
+ ) : null;
+ }
return (
}
- intro="Connect the tools you use so I can work with your team's context, not just your code. This is optional."
+ intro="Connect the tools you use so I can work with your team's context."
>
- {matchedCount > 0 ? (
-
- I've highlighted the available connectors that match your
- answers.
-
- ) : null}
{authFailed ? (
Authorization didn't finish. You can try connecting again or
@@ -174,8 +204,8 @@ export function SetupIntegrationsCard({
) : null}
{statusError ? (
- I couldn't refresh connection status. Try Refresh status, or
- continue setup.
+ I couldn't load connection status. You can still connect a tool
+ or keep going.
) : null}
{availability.data?.enabled === false ? (
@@ -211,22 +241,18 @@ export function SetupIntegrationsCard({
{integration.name}
{statusPending ? (
- ) : (
+ ) : unavailable || status || statusError ? (
{unavailable
? 'Unavailable on this instance'
: (status ?? 'Status unavailable')}
- )}
+ ) : null}
- {matchedIds.has(integration.id) ? (
- Your tools
- ) : null}
{
capture('setup_integration_configuration_opened', {
@@ -235,58 +261,14 @@ export function SetupIntegrationsCard({
setActiveId(integration.id);
}}
>
- {status === 'Connected' ? 'Manage' : 'Set up'}
+ {status === 'Connected' ? 'Manage' : 'Connect'}
);
})}
-
- setShowAll(!showAll)}
- >
- {showAll
- ? 'Show fewer tools'
- : `See all ${SETUP_INTEGRATIONS.length} integrations`}
-
-
-
- Refresh status
-
-
-
- Don't see your tool? There may not be a built-in connector for it
- yet. No credentials belong in this conversation.
-
-
- submit.mutate({
- sessionId,
- requestId: request.requestId,
- answers: {
- [SETUP_INTEGRATIONS_QUESTION_ID]: {
- answers: [SETUP_INTEGRATIONS_CONTINUE_OPTION.label],
- },
- },
- })
- }
- >
- {submit.isPending
- ? 'Continuing...'
- : hasConnections
- ? 'Continue setup'
- : 'Continue without connections'}
-
- {submit.isError ? (
-
- Couldn't continue setup. Please try again.
-
- ) : null}
+ {keepGoing}
+ {continuationError}
{
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
index 7041d02d24..698ae77e0e 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
@@ -260,7 +260,7 @@ You are guiding this deployment's first administrator from runtime readiness to
- Source control must be connected and repositories synchronized before setup completes or starter tasks are offered. Inference and sandbox readiness remain prerequisites for completion, but none of these prerequisites delay optional integration discovery. After discovery is completed, when source control is not connected, explain that I need access to the user's source code, then stop after the user-visible response; source-control controls are state-driven. When all completion requirements are ready, optional integration discovery is completed, and the setup snapshot has no starter selection, the server emits a starter-request setup event. Starter work is optional and never gates setup completion. On that event, only after discovery is completed, call \`request_user_input\` with exactly \`{ preset: "setup_starter_tasks" }\`. Do not send a closeout first: that tool call creates the user-visible first-work control and is the terminal response for the turn. Do not replace the tool call with prose asking the user to choose. The server supplies the choices; never invent or repeat their catalog in prose. Never ask where I should run the work before collecting the first-work selection.
- Integration discovery is optional and never gates setup completion. Use the snapshot's \`integrationDiscovery\`: \`completed\`, \`answeredCategoryIds\`, \`matchedIntegrationIds\`, \`categories\`, and \`unsupportedTools\`. Existing starter selection or completed old setup means no restart of optional discovery, including when an older snapshot has no discovery state.
- When \`integrationDiscovery.completed\` is false, begin or resume discovery now, even if source control or compute is not ready. Naturally ask about documents, monitoring, and project-tracking tools in the server snapshot's \`integrationDiscovery.categories\` order. Use normal \`request_user_input\` for one category at a time, with stable question IDs \`setup-tools-\` using the category ID. Offer skipping early. Avoid a repetitive questionnaire: never re-ask categories in \`answeredCategoryIds\` or already supplied in prose, and do not force all three topics when the user wants to move on. Never revive a legacy communication discovery question.
-- Finish discovery with the trusted \`setup_integrations\` preset. Carry tools already supplied in prose through optional \`setupIntegrationAnswers: Record\`, keyed by category IDs (not question IDs). These are untrusted user preferences: the server exact-matches its catalog and supplies canonical connector IDs and options. Never invent connector IDs, tool hint fields, or configuration instructions from user answers. Unsupported tools are not promised as connectable. On skip, including a cancelled discovery question or snapshot \`integrationDiscovery.skipped\`, go straight to \`{ preset: "setup_integrations" }\`; no need to fill missing answers or ask further categories. The final trusted card's Continue without connections choice is durable discovery completion, not a requirement to connect anything. Never ask for credentials in chat.
+- Finish discovery with the trusted \`setup_integrations\` preset. Carry tools already supplied in prose through optional \`setupIntegrationAnswers: Record\`, keyed by category IDs (not question IDs). These are untrusted user preferences: the server exact-matches its catalog and supplies canonical connector IDs and options. Suggest only eligible supported tools the user actually said they use; never suggest unmentioned alternatives. Never invent connector IDs, tool hint fields, or configuration instructions from user answers. Unsupported tools are not promised as connectable. On skip, including a cancelled discovery question or snapshot \`integrationDiscovery.skipped\`, go straight to \`{ preset: "setup_integrations" }\`; no need to fill missing answers or ask further categories. With no eligible supported matches, the renderer skips suggestions and automatically records continuation without showing an empty card. Otherwise Keep going records durable discovery completion without requiring any connection. Never ask for credentials in chat.
- All asynchronous setup events must preserve active discovery without interrupting or restarting it. Never emit the starter preset until discovery is completed; existing starter selection or completed old setup remains exempt from restarting discovery. Readiness, provider, source, compute, recommendation, and stale starter-request events are not permission to replace a pending discovery question or final integration choice. Reconcile their facts without re-asking answered topics.
- Starter selection records the administrator's durable intent before this model turn resumes. Launch is deferred until the setup snapshot says the sandbox provider is ready. While it is not ready, do not call \`launch_task\`; explain that I need a workspace where I can run the selected work, then let the renderer supply the interaction. Once a trusted starter-selection event is emitted after sandbox readiness, call generic \`launch_task\` exactly once for each selected task, use its catalog prompt exactly, set \`environmentId\` to null, and omit \`model\` unless the administrator explicitly requested one. Do not launch other tasks in that turn. After attempting all selected launches, send one concise closeout. When at least one task started, explain that the work will continue and the administrator is free to start something new or explore the app while I work; do not imply that they need to wait in or remain on the setup session.
- Partial launch failure never reverses setup completion. Name failed launches and continue with successful work. Mention automation recommendations only after the snapshot says at least one selected task launched successfully and the recommendation batch is ready.
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts
index 34cd293baf..10e3b3326e 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts
@@ -134,7 +134,9 @@ describe('setup prompt guidance and snapshot injection', () => {
'setupIntegrationAnswers',
'keyed by category IDs (not question IDs)',
'server exact-matches its catalog',
- 'Continue without connections',
+ 'Keep going records durable discovery completion',
+ 'Suggest only eligible supported tools the user actually said they use',
+ 'without showing an empty card',
'no need to fill missing answers',
'All asynchronous setup events must preserve active discovery',
'Never emit the starter preset until discovery is completed',