From bf58c80d214b300cb0edb267e4b14684da447713 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Wed, 9 Sep 2026 15:26:27 +0000
Subject: [PATCH 01/23] 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 | 20 +
.../__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, 2550 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 bad93df68c..0d0391db32 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
@@ -245,6 +245,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[] = [];
@@ -563,60 +566,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 2a790ddc64..73e5107176 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -68,6 +68,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 {
@@ -1314,6 +1315,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 ebfcc657f1..74e63b7880 100644
--- a/apps/web/src/components/settings/Integrations.test.tsx
+++ b/apps/web/src/components/settings/Integrations.test.tsx
@@ -99,6 +99,7 @@ const state = vi.hoisted(() => ({
},
},
linearRedirectPath: '',
+ pathname: '/settings/integrations',
searchParams: '',
}));
@@ -157,7 +158,7 @@ function cloneMcpToolsData() {
}
vi.mock('next/navigation', () => ({
- usePathname: () => '/settings/integrations',
+ usePathname: () => state.pathname,
useSearchParams: () => new URLSearchParams(state.searchParams),
}));
@@ -536,6 +537,7 @@ describe('Integrations settings', () => {
linearOrganizationName: 'Roomote',
};
state.linearRedirectPath = '';
+ state.pathname = '/settings/integrations';
state.asanaConnection = null;
state.notionConnection = null;
state.ripplingConnection = null;
@@ -573,6 +575,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 87f79ce5fa..58f53797c3 100644
--- a/apps/web/src/components/settings/Integrations.tsx
+++ b/apps/web/src/components/settings/Integrations.tsx
@@ -1559,7 +1559,11 @@ function VercelConnectionFields({
);
}
-export function Integrations() {
+export function Integrations({
+ integrationIds,
+}: {
+ integrationIds?: readonly string[];
+} = {}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const { isAdmin } = useAuthorizedUser();
@@ -1667,7 +1671,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();
@@ -2478,7 +2484,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,
@@ -2511,6 +2523,7 @@ export function Integrations() {
saveVercelConnection.isPending,
deploymentEnablements.data,
pathname,
+ integrationIds,
setDeploymentEnabled,
saveSnowflakeConnection.isPending,
asanaConnection.isPending,
@@ -3222,7 +3235,7 @@ export function Integrations() {
instance.
- {customMcpEnabled ? (
+ {integrationIds === undefined && customMcpEnabled ? (
<>
{customMcpDialogs}
@@ -3506,32 +3519,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 579d3b376a..39ca15f49f 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 b57e408aed..494b0c1778 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,
@@ -682,6 +683,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;
@@ -726,6 +730,7 @@ export async function replyToFastSessionCommand(
currentMessageId: input.clientMessageId,
durableSessionId: session.id,
...(input.voiceMode ? { voiceMode: true } : {}),
+ ...setupContext,
});
return { success: true };
@@ -796,6 +801,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({
@@ -850,7 +859,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'
@@ -879,6 +900,9 @@ export async function submitFastSessionUserInputCommand(
question: `${JSON.stringify({
requestId: input.requestId,
answers,
+ ...(responseResolution === 'cancelled'
+ ? { resolution: responseResolution }
+ : {}),
})} `,
turnSource: 'platform_event',
platformEventKind: 'input_response',
@@ -896,6 +920,7 @@ export async function submitFastSessionUserInputCommand(
? { setupSnapshot: options.setupSnapshot }
: {}),
setupSession: options.setupSession ?? false,
+ ...freshSetupContext,
});
};
@@ -903,17 +928,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.');
}
@@ -959,8 +987,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 cfbce3227d..af5080b744 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
@@ -515,6 +515,26 @@ 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;
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 47a32f2a0d..9e57deea03 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
@@ -1400,6 +1400,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 c2e46fd888..9f0245c228 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
@@ -174,12 +174,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 = {
@@ -210,6 +210,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 4eaf6d4559..26d80562b9 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
@@ -624,7 +624,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),
@@ -638,7 +638,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 309628ffd1..62966f5d50 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
@@ -341,9 +341,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.
@@ -390,7 +394,7 @@ ${surface === 'slack' ? '- Charts supplied to "send_chat_reply" render as Slack
- 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}
${emailCadenceGuidance}- 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.
@@ -494,7 +498,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."
: ''
}
${
@@ -542,7 +546,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 e20b1d5f12..cfd907d36e 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
@@ -560,7 +560,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.
@@ -568,16 +575,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,
@@ -4494,9 +4518,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 43dfc41095..f7280ca144 100644
--- a/packages/types/src/acp.ts
+++ b/packages/types/src/acp.ts
@@ -166,6 +166,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;
}
@@ -235,7 +237,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 {
@@ -335,7 +337,8 @@ function parseAcpRequestUserInputQuestionOption(
return null;
}
- return { label, description };
+ const id = asStringOrNull(record?.id);
+ return { label, description, ...(id ? { id } : {}) };
}
export function parseAcpRequestUserInputQuestion(
@@ -433,7 +436,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 0e84b543ce..4f2363688d 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -82,6 +82,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 d4c2f5173353f8a9e4f8d2cc242e1395cc792205 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Wed, 9 Sep 2026 15:43:57 +0000
Subject: [PATCH 02/23] 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 62966f5d50..93558f7ffb 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
@@ -346,7 +346,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',
From 94948011016197ab97cadb4cb3457dc5310fd5e7 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 10 Sep 2026 15:39:12 +0000
Subject: [PATCH 03/23] feat: make guided interactions reliable
---
.../__tests__/callback-actions.test.ts | 4 +
.../__tests__/request-user-input.test.ts | 221 +++++++++++++
.../handlers/discord/request-user-input.ts | 88 +++++-
.../FastSessionTranscript.client.test.tsx | 299 +++++++++++++++++-
.../[sessionId]/FastSessionTranscript.tsx | 262 +++++++++++++--
.../SetupIntegrationsCard.client.test.tsx | 42 ++-
.../setup/SetupIntegrationsCard.tsx | 63 ++--
.../components/settings/Integrations.test.tsx | 29 ++
.../src/components/settings/Integrations.tsx | 85 +++--
apps/web/src/hooks/linear/useConnectLinear.ts | 6 +-
.../src/hooks/linear/useDisconnectLinear.ts | 6 +-
.../linear/useInvalidateLinearOauthSetup.ts | 8 +-
apps/web/src/hooks/mcp-connections/index.ts | 4 +-
...alidateMcpIntegrationStatusQueries.test.ts | 32 ++
.../invalidateMcpIntegrationStatusQueries.ts | 26 ++
.../hooks/mcp-connections/useConnectMcp.ts | 5 +-
.../hooks/mcp-connections/useDisconnectMcp.ts | 8 +-
...lity.ts => useEffectiveMcpIntegrations.ts} | 4 +-
.../mcp-connections/useMcpOauthReadiness.ts | 11 -
.../mcp-connections/useSaveAsanaConnection.ts | 8 +-
.../useSaveElevenLabsConnection.ts | 8 +-
.../useSaveGrafanaConnection.ts | 8 +-
.../useSaveGranolaConnection.ts | 8 +-
.../useSaveNotionConnection.ts | 8 +-
.../useSaveRipplingConnection.ts | 8 +-
.../useSaveSnowflakeConnection.ts | 8 +-
.../useSaveVercelConnection.ts | 8 +-
.../mcp-connections/useSaveXConnection.ts | 8 +-
.../useSetDeploymentMcpEnabled.ts | 8 +-
.../mcp-connections/useSetDisabledMcpTools.ts | 2 +
apps/web/src/lib/server/mcp-static-oauth.ts | 12 +-
.../trpc/commands/fast-sessions/index.test.ts | 72 ++++-
.../src/trpc/commands/fast-sessions/index.ts | 43 +--
.../commands/mcp-connections/index.test.ts | 47 +++
.../trpc/commands/mcp-connections/index.ts | 103 ++++++
.../trpc/commands/setup/setup-session.test.ts | 65 ++--
.../src/trpc/commands/setup/setup-session.ts | 157 ++++-----
apps/web/src/trpc/routers/_app.ts | 5 +
...fast-agent-conversation-repository.test.ts | 23 +-
.../fast-agent-integration-broker.test.ts | 23 +-
.../__tests__/fast-agent-service.test.ts | 43 +++
.../__tests__/fast-agent-session.test.ts | 2 +-
.../fast-agent-conversation-repository.ts | 33 +-
.../fast-agent/fast-agent-conversation.ts | 2 +
.../fast-agent-integration-broker.ts | 2 +-
.../server/fast-agent/fast-agent-prompt.ts | 23 +-
.../server/fast-agent/fast-agent-service.ts | 7 +
.../server/fast-agent/fast-agent-session.ts | 3 +
.../fast-agent/fast-agent-setup-context.ts | 157 +++++++++
.../fast-agent/fast-agent-setup-tools.test.ts | 70 +---
.../src/server/fast-agent/index.ts | 1 +
.../discord-request-user-input.test.ts | 80 ++++-
.../src/__tests__/request-user-input.test.ts | 165 ++++++++++
.../src/discord-request-user-input.ts | 25 +-
.../communication/src/request-user-input.ts | 110 ++++++-
.../lib/fast-agent-parent-event.test.ts | 12 +
.../src/server/lib/fast-agent-parent-event.ts | 7 +
.../server/routers/mcp-connections.test.ts | 12 +
.../sdk/src/server/routers/mcp-connections.ts | 25 +-
.../types/src/acp-request-user-input.test.ts | 37 +++
packages/types/src/acp.ts | 54 +++-
packages/types/src/fast-agent.ts | 20 ++
packages/types/src/mcp-oauth.ts | 32 ++
63 files changed, 2241 insertions(+), 516 deletions(-)
create mode 100644 apps/api/src/handlers/discord/__tests__/request-user-input.test.ts
create mode 100644 apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.test.ts
create mode 100644 apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.ts
rename apps/web/src/hooks/mcp-connections/{useCuratedIntegrationsAvailability.ts => useEffectiveMcpIntegrations.ts} (52%)
delete mode 100644 apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts
create mode 100644 packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts
diff --git a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts
index 53e44604a1..82f7cc91c1 100644
--- a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts
@@ -3,6 +3,7 @@ import * as suggestionLaunch from '../../tasks/suggestion-launch.js';
const mocks = vi.hoisted(() => ({
findRun: vi.fn(),
+ findActiveCommunicationRun: vi.fn(),
stopTaskRun: vi.fn(),
reply: vi.fn(),
findMappedUser: vi.fn(),
@@ -49,6 +50,9 @@ vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply }));
vi.mock('@roomote/sdk/server', () => ({
findDiscordMappedUserId: mocks.findMappedUser,
}));
+vi.mock('@roomote/sdk/server/communication', () => ({
+ findActiveCommunicationTaskRun: mocks.findActiveCommunicationRun,
+}));
vi.mock('../../fast-agent-entry.js', () => ({
resolveFastAgentEntryMode: ({
userDefaultEnabled,
diff --git a/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts b/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts
new file mode 100644
index 0000000000..cad410b6c2
--- /dev/null
+++ b/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts
@@ -0,0 +1,221 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { TaskPayloadKind } from '@roomote/types';
+
+const mocks = vi.hoisted(() => ({
+ findActiveRun: vi.fn(),
+ getPending: vi.fn(),
+ rebindPending: vi.fn(),
+ reply: vi.fn(),
+ setActingUserOnSuccess: vi.fn(),
+ submitAnswer: vi.fn(),
+}));
+
+vi.mock('@roomote/communication', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getPendingCommunicationRequestUserInput: mocks.getPending,
+ rebindPendingCommunicationRequestUserInputRun: mocks.rebindPending,
+ submitPendingCommunicationRequestUserInputAnswer: mocks.submitAnswer,
+}));
+
+vi.mock('@roomote/db/server', () => ({
+ setTrustedRunActingUserOnSuccess: mocks.setActingUserOnSuccess,
+}));
+
+vi.mock('@roomote/sdk/server/communication', () => ({
+ findActiveCommunicationTaskRun: mocks.findActiveRun,
+}));
+
+vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply }));
+
+import { buildDiscordRequestUserInputAnswerCallbackData } from '@roomote/communication';
+
+import { tryHandleDiscordRequestUserInputCallback } from '../request-user-input.js';
+
+const pendingRequest = {
+ requestId: 'rui:session:turn:callid12',
+ runId: 42,
+ taskId: 'task-1',
+ provider: 'discord' as const,
+ conversationId: 'thread-1',
+ questions: [
+ {
+ id: 'q1',
+ header: 'Bump',
+ question: 'What bump level should I cut?',
+ isOther: false,
+ isSecret: false,
+ options: [{ label: 'minor', description: 'Recommended' }],
+ },
+ ],
+ status: 'pending' as const,
+ promptMessageId: 'prompt-1',
+ currentQuestionIndex: 0,
+ answers: {},
+ createdAt: 123,
+};
+
+const channel = {
+ channelId: 'thread-1',
+ channelName: 'Task thread',
+ channelType: 11,
+ guildId: 'guild-1',
+ parentChannelId: 'channel-1',
+ isDirectMessage: false,
+ isThread: true,
+};
+
+const interaction = {
+ id: 'interaction-1',
+ application_id: 'app-1',
+ type: 3,
+ token: 'token-1',
+ channel_id: 'thread-1',
+ user: { id: 'discord-user-1', username: 'matt' },
+ data: { component_type: 2 },
+};
+
+function answerCustomId(): string {
+ return buildDiscordRequestUserInputAnswerCallbackData({
+ runId: 42,
+ requestId: pendingRequest.requestId,
+ questionIndex: 0,
+ optionIndex: 0,
+ });
+}
+
+describe('Discord request_user_input callbacks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getPending.mockResolvedValue(pendingRequest);
+ mocks.rebindPending.mockResolvedValue(true);
+ mocks.reply.mockResolvedValue({ messageId: 'response-1' });
+ mocks.submitAnswer.mockResolvedValue(true);
+ mocks.setActingUserOnSuccess.mockImplementation(
+ async ({ operation }: { operation: () => Promise }) =>
+ operation(),
+ );
+ });
+
+ it('rejects a structured answer unless the task owns the active reply target', async () => {
+ mocks.findActiveRun.mockResolvedValue(undefined);
+ const provider = { editMessage: vi.fn() } as never;
+
+ await expect(
+ tryHandleDiscordRequestUserInputCallback({
+ provider,
+ applicationId: 'app-1',
+ channel,
+ interaction: interaction as never,
+ interactionDeferred: true,
+ customId: answerCustomId(),
+ userId: 'user-1',
+ }),
+ ).resolves.toBe(true);
+
+ expect(mocks.findActiveRun).toHaveBeenCalledWith({
+ provider: 'discord',
+ channelId: 'channel-1',
+ threadId: 'thread-1',
+ taskId: 'task-1',
+ });
+ expect(mocks.setActingUserOnSuccess).not.toHaveBeenCalled();
+ expect(mocks.submitAnswer).not.toHaveBeenCalled();
+ expect(mocks.reply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ }),
+ );
+ });
+
+ it('accepts an authorized answer without rebinding the current run', async () => {
+ mocks.findActiveRun.mockResolvedValue({ id: 42 });
+ const editMessage = vi.fn().mockResolvedValue(undefined);
+
+ await tryHandleDiscordRequestUserInputCallback({
+ provider: { editMessage } as never,
+ applicationId: 'app-1',
+ channel,
+ interaction: interaction as never,
+ interactionDeferred: true,
+ customId: answerCustomId(),
+ userId: 'user-1',
+ });
+
+ expect(mocks.rebindPending).not.toHaveBeenCalled();
+ expect(mocks.setActingUserOnSuccess).toHaveBeenCalledWith(
+ expect.objectContaining({ runId: 42, userId: 'user-1' }),
+ );
+ expect(mocks.submitAnswer).toHaveBeenCalledWith(
+ 'discord',
+ 'thread-1',
+ pendingRequest,
+ expect.objectContaining({ userId: 'user-1' }),
+ );
+ expect(editMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ channelId: 'thread-1',
+ messageId: 'prompt-1',
+ buttons: [],
+ }),
+ );
+ });
+
+ it('atomically rebinds an authorized legacy prompt to its resumed run', async () => {
+ mocks.findActiveRun.mockResolvedValue({
+ id: 84,
+ payloadKind: TaskPayloadKind.SnapshotResume,
+ payload: { sourceRunId: 42 },
+ });
+
+ await tryHandleDiscordRequestUserInputCallback({
+ provider: { editMessage: vi.fn().mockResolvedValue(undefined) } as never,
+ applicationId: 'app-1',
+ channel,
+ interaction: interaction as never,
+ interactionDeferred: true,
+ customId: 'discord:rui:42:0:0:callid12',
+ userId: 'user-1',
+ });
+
+ expect(mocks.rebindPending).toHaveBeenCalledWith({
+ provider: 'discord',
+ conversationId: 'thread-1',
+ taskId: 'task-1',
+ sourceRunId: 42,
+ resumedRunId: 84,
+ });
+ expect(mocks.setActingUserOnSuccess).toHaveBeenCalledWith(
+ expect.objectContaining({ runId: 84, userId: 'user-1' }),
+ );
+ expect(mocks.submitAnswer).toHaveBeenCalledWith(
+ 'discord',
+ 'thread-1',
+ { ...pendingRequest, runId: 84 },
+ expect.objectContaining({ userId: 'user-1' }),
+ );
+ });
+
+ it('does not rebind a later run without snapshot-resume lineage', async () => {
+ mocks.findActiveRun.mockResolvedValue({ id: 84, payload: {} });
+
+ await tryHandleDiscordRequestUserInputCallback({
+ provider: { editMessage: vi.fn() } as never,
+ applicationId: 'app-1',
+ channel,
+ interaction: interaction as never,
+ interactionDeferred: true,
+ customId: answerCustomId(),
+ userId: 'user-1',
+ });
+
+ expect(mocks.rebindPending).not.toHaveBeenCalled();
+ expect(mocks.submitAnswer).not.toHaveBeenCalled();
+ expect(mocks.reply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ }),
+ );
+ });
+});
diff --git a/apps/api/src/handlers/discord/request-user-input.ts b/apps/api/src/handlers/discord/request-user-input.ts
index 3441447cd6..7f6e0be69e 100644
--- a/apps/api/src/handlers/discord/request-user-input.ts
+++ b/apps/api/src/handlers/discord/request-user-input.ts
@@ -3,15 +3,21 @@ import {
buildDiscordCancelledRequestUserInputText,
getDiscordRequestUserInputCurrentQuestion,
getPendingCommunicationRequestUserInput,
+ matchesDiscordRequestUserInputRequestToken,
parseDiscordRequestUserInputAnswerCallbackData,
parseDiscordRequestUserInputCancelCallbackData,
+ rebindPendingCommunicationRequestUserInputRun,
submitPendingCommunicationRequestUserInputAnswer,
type PendingCommunicationRequestUserInput,
} from '@roomote/communication';
import type { DiscordInteraction } from '@roomote/communication/discord-event';
import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider';
-import { type AcpRequestUserInputAnswers } from '@roomote/types';
+import {
+ TaskPayloadKind,
+ type AcpRequestUserInputAnswers,
+} from '@roomote/types';
import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server';
+import { findActiveCommunicationTaskRun } from '@roomote/sdk/server/communication';
import { apiLogger } from '../../logging.js';
import { replyToDiscordEvent } from './replies.js';
@@ -186,7 +192,7 @@ export async function tryHandleDiscordRequestUserInputCallback(params: {
}
const conversationId = conversationIdForChannel(params.channel);
- const pendingRequest = await getPendingCommunicationRequestUserInput(
+ let pendingRequest = await getPendingCommunicationRequestUserInput(
'discord',
conversationId,
);
@@ -207,10 +213,15 @@ export async function tryHandleDiscordRequestUserInputCallback(params: {
return true;
}
- const expectedToken = pendingRequest.requestId.slice(-8);
const receivedToken =
answerCallback?.requestToken ?? cancelCallback?.requestToken;
- if (receivedToken !== expectedToken) {
+ if (
+ !receivedToken ||
+ !matchesDiscordRequestUserInputRequestToken(
+ pendingRequest.requestId,
+ receivedToken,
+ )
+ ) {
await replyToDiscordEvent({
provider: params.provider,
applicationId: params.applicationId,
@@ -225,6 +236,75 @@ export async function tryHandleDiscordRequestUserInputCallback(params: {
return true;
}
+ const activeRun = await findActiveCommunicationTaskRun({
+ provider: 'discord',
+ channelId: params.channel.parentChannelId ?? params.channel.channelId,
+ ...(params.channel.parentChannelId
+ ? { threadId: params.channel.channelId }
+ : {}),
+ taskId: pendingRequest.taskId,
+ });
+ if (!activeRun) {
+ await replyToDiscordEvent({
+ provider: params.provider,
+ applicationId: params.applicationId,
+ channel: params.channel,
+ interaction: {
+ interaction: params.interaction,
+ interactionDeferred: params.interactionDeferred,
+ },
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ });
+ return true;
+ }
+
+ if (activeRun.id !== pendingRequest.runId) {
+ const sourceRunId =
+ activeRun.payloadKind === TaskPayloadKind.SnapshotResume &&
+ activeRun.payload &&
+ typeof activeRun.payload === 'object'
+ ? (activeRun.payload as { sourceRunId?: unknown }).sourceRunId
+ : undefined;
+ if (sourceRunId !== pendingRequest.runId) {
+ await replyToDiscordEvent({
+ provider: params.provider,
+ applicationId: params.applicationId,
+ channel: params.channel,
+ interaction: {
+ interaction: params.interaction,
+ interactionDeferred: params.interactionDeferred,
+ },
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ });
+ return true;
+ }
+
+ const rebound = await rebindPendingCommunicationRequestUserInputRun({
+ provider: 'discord',
+ conversationId,
+ taskId: pendingRequest.taskId,
+ sourceRunId: pendingRequest.runId,
+ resumedRunId: activeRun.id,
+ });
+ if (!rebound) {
+ await replyToDiscordEvent({
+ provider: params.provider,
+ applicationId: params.applicationId,
+ channel: params.channel,
+ interaction: {
+ interaction: params.interaction,
+ interactionDeferred: params.interactionDeferred,
+ },
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ });
+ return true;
+ }
+ pendingRequest = { ...pendingRequest, runId: activeRun.id };
+ }
+
if (pendingRequest.status === 'submitted') {
await postAlreadyReceivedNotice({
provider: params.provider,
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 0d0391db32..d67ec08e65 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
@@ -628,15 +628,308 @@ describe('FastSessionTranscript', () => {
,
);
expect(screen.queryByText('Structured input request')).toBeNull();
- expect(screen.queryByText('Structured response')).toBeNull();
+ expect(screen.getByText('Structured response')).toBeInTheDocument();
+ expect(screen.getByLabelText('Test User')).toBeInTheDocument();
expect(screen.queryByText(cardLabel)).toBeNull();
},
);
+ it('renders a structured response once in chronology as human-authored text', () => {
+ const requestId = 'rui:chronology';
+ const question = 'Which direction should I take?';
+ const request = {
+ ...textMessage({
+ id: 'input-request',
+ role: 'assistant',
+ text: question,
+ ts: 2,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId,
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ questions: [
+ {
+ id: 'direction',
+ header: 'Direction',
+ question,
+ isOther: true,
+ isSecret: false,
+ },
+ ],
+ },
+ };
+ const response = {
+ ...textMessage({
+ id: 'input-response',
+ role: 'user',
+ text: 'Legacy persisted answer',
+ ts: 3,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
+ payload: {
+ requestId,
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ answers: { direction: { answers: ['Use the narrow path'] } },
+ resolution: 'submitted',
+ },
+ };
+
+ render(
+ ,
+ );
+
+ const before = screen.getByText('Before the question');
+ const answer = screen.getByText('Use the narrow path');
+ const after = screen.getByText('After the answer');
+ expect(before.compareDocumentPosition(answer)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(answer.compareDocumentPosition(after)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(screen.getAllByText(question)).toHaveLength(1);
+ expect(screen.queryByText('Legacy persisted answer')).toBeNull();
+ expect(screen.getByLabelText('Transcript Owner')).toBeInTheDocument();
+ });
+
+ it('hides request_user_input tool lifecycle rows while keeping the interaction card', () => {
+ const requestId = 'rui:hidden-tools';
+ const toolPayload = {
+ toolCallId: 'turn-1:tool:0',
+ title: 'request_user_input',
+ kind: 'tool',
+ status: 'completed',
+ isExecute: false,
+ isRead: false,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ toolName: 'request_user_input',
+ command: null,
+ rawInput: { arguments: { question: 'Hidden tool question' } },
+ };
+ const toolBase = {
+ id: 'request-tool',
+ eventId: 'turn-1:tool:0',
+ turnId: 'turn-1',
+ turnSeq: 1,
+ ts: 1,
+ role: 'tool' as const,
+ metadata: { visibleInTranscript: true },
+ source: 'web',
+ nativeSessionId: 'opencode-1',
+ nativeMessageId: null,
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ };
+ const request = {
+ ...textMessage({
+ id: 'input-request',
+ role: 'assistant',
+ text: 'Choose a path',
+ ts: 2,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId,
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ questions: [
+ {
+ id: 'path',
+ header: 'Path',
+ question: 'Choose a path',
+ isOther: true,
+ isSecret: false,
+ },
+ ],
+ },
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Structured input request')).toBeInTheDocument();
+ expect(screen.queryByText('Asked for')).toBeNull();
+ expect(screen.queryByText('human guidance')).toBeNull();
+ expect(screen.queryByText('Hidden tool result')).toBeNull();
+ expect(screen.queryByText('Choose a path')).toBeNull();
+ });
+
+ it('places a pending interaction at its chronological position', () => {
+ const request = {
+ ...textMessage({
+ id: 'input-request',
+ role: 'assistant',
+ text: 'Choose a path',
+ ts: 2,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId: 'rui:pending-order',
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ questions: [
+ {
+ id: 'path',
+ header: 'Path',
+ question: 'Choose a path',
+ isOther: true,
+ isSecret: false,
+ },
+ ],
+ },
+ };
+ render(
+ ,
+ );
+
+ const before = screen.getByText('Before pending input');
+ const interaction = screen.getByText('Structured input request');
+ const after = screen.getByText('Later transcript activity');
+ expect(before.compareDocumentPosition(interaction)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(interaction.compareDocumentPosition(after)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ });
+
+ it('keeps the composer available for non-preset input requests', () => {
+ const request = {
+ ...textMessage({
+ id: 'input-request',
+ role: 'assistant',
+ text: 'Choose or write another direction',
+ ts: 1,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId: 'rui:optional',
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ questions: [
+ {
+ id: 'direction',
+ header: 'Direction',
+ question: 'Choose or write another direction',
+ isOther: true,
+ isSecret: false,
+ },
+ ],
+ },
+ };
+
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByPlaceholderText('Message agent')).toBeInTheDocument();
+
+ unmount();
+ render(
+ ,
+ );
+ expect(screen.queryByPlaceholderText('Message agent')).toBeNull();
+ expect(screen.getByText('Setup starter tasks')).toBeInTheDocument();
+ });
+
it.each([
[1, '1 task running'],
[2, '2 tasks running'],
@@ -2054,7 +2347,7 @@ describe('FastSessionTranscript', () => {
expect(input.value).toBe('Do not lose me');
});
- it('shows structured input instead of the ordinary composer while pending', () => {
+ it('shows structured input with the ordinary composer while non-preset input is pending', () => {
render(
{
);
expect(screen.getByText('Structured input request')).toBeVisible();
- expect(screen.queryByPlaceholderText('Message agent')).toBeNull();
+ expect(screen.getByPlaceholderText('Message agent')).toBeInTheDocument();
});
it('updates the header title from the session stream event', () => {
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index 73e5107176..f42ad44095 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -12,9 +12,12 @@ import {
import {
ACP_ENVELOPE_EVENT_TYPES,
SETUP_RECEIPT_INPUT_KIND,
+ formatRequestUserInputResponseText,
getImageUrisFromContentBlocks,
getTextFromContentBlocks,
inferAcpMessageKind,
+ parseAcpRequestUserInputPayload,
+ parseAcpRequestUserInputResponsePayload,
parsePrReviewActionOffer,
getTaskModelDisplayName,
type AcpMessage,
@@ -107,6 +110,25 @@ function getTranscriptMessageText(message: TranscriptMessage) {
: text;
}
+function isRequestUserInputToolMessage(message: TranscriptMessage) {
+ if (
+ message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCall &&
+ message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCallUpdate &&
+ message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolResult
+ ) {
+ return false;
+ }
+
+ const payload = message.payload as {
+ toolName?: unknown;
+ title?: unknown;
+ } | null;
+ return (
+ payload?.toolName === 'request_user_input' ||
+ payload?.title === 'request_user_input'
+ );
+}
+
type PendingResponseState = {
pendingAfter: TranscriptOrder | null;
latestVisibleResponse: TranscriptOrder | null;
@@ -695,6 +717,133 @@ export function FastSessionTranscript({
}),
[messages, owner],
);
+
+ const pendingInputRequest = useMemo(
+ () => findPendingSessionInputRequest(messages),
+ [messages],
+ );
+ const pendingInputRequestOrder = useMemo(() => {
+ if (!pendingInputRequest) return null;
+
+ return (
+ messages.find((message) => {
+ if (message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) {
+ return false;
+ }
+ return (
+ parseAcpRequestUserInputPayload(message.payload)?.requestId ===
+ pendingInputRequest.requestId
+ );
+ }) ?? null
+ );
+ }, [messages, pendingInputRequest]);
+ const requestUserInputById = useMemo(() => {
+ const requests = new Map<
+ string,
+ NonNullable>
+ >();
+ for (const message of messages) {
+ if (message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) {
+ continue;
+ }
+ const request = parseAcpRequestUserInputPayload(message.payload);
+ if (request) requests.set(request.requestId, request);
+ }
+ return requests;
+ }, [messages]);
+ const { persistedBeforeInput, persistedAfterInput } = useMemo(() => {
+ const before: AcpUiMessage[] = [];
+ const after: AcpUiMessage[] = [];
+
+ for (const message of messages) {
+ if (
+ (message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage &&
+ (message.payload as { taskNavigation?: unknown } | null)
+ ?.taskNavigation === true) ||
+ message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInput ||
+ isRequestUserInputToolMessage(message)
+ ) {
+ continue;
+ }
+
+ let uiMessage = toAcpUiMessage({
+ // A reply keeps the id its streamed chunks rendered under, so the
+ // persisted row reconciles in place instead of remounting.
+ id:
+ message.role === 'assistant' &&
+ message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage
+ ? `assistant:${message.eventId}`
+ : message.id,
+ ts: message.ts,
+ eventType: message.eventType as AcpEventType,
+ role: message.role,
+ kind: inferAcpMessageKind(message.eventType),
+ contentBlocks: message.contentBlocks,
+ metadata: message.metadata,
+ payload: message.payload,
+ text: getTranscriptMessageText(message),
+ userName: message.userName,
+ userEmail: message.userEmail,
+ userImageUrl: message.userImageUrl,
+ });
+
+ if (
+ message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse
+ ) {
+ const response = parseAcpRequestUserInputResponsePayload(
+ message.payload,
+ );
+ const requestId =
+ response?.requestId ??
+ (typeof message.payload?.requestId === 'string'
+ ? message.payload.requestId
+ : null);
+ const request = requestId
+ ? (requestUserInputById.get(requestId) ?? null)
+ : null;
+ uiMessage = {
+ ...uiMessage,
+ role: 'user',
+ kind: 'text',
+ text:
+ response !== null
+ ? formatRequestUserInputResponseText(request, response)
+ : (getTranscriptMessageText(message) ??
+ 'Submitted input response'),
+ data: request
+ ? { ...(message.payload ?? {}), request }
+ : (message.payload ?? {}),
+ userId: uiMessage.userId ?? owner?.userId,
+ userName: uiMessage.userName ?? owner?.name,
+ userEmail: uiMessage.userEmail ?? owner?.email,
+ userImageUrl: uiMessage.userImageUrl ?? owner?.imageUrl,
+ };
+ } else if (
+ uiMessage.role === 'user' &&
+ owner &&
+ uiMessage.userId === owner.userId
+ ) {
+ uiMessage = {
+ ...uiMessage,
+ userName: uiMessage.userName ?? owner.name,
+ userEmail: uiMessage.userEmail ?? owner.email,
+ userImageUrl: uiMessage.userImageUrl ?? owner.imageUrl,
+ };
+ }
+
+ const target =
+ pendingInputRequestOrder &&
+ compareTranscriptOrder(message, pendingInputRequestOrder) > 0
+ ? after
+ : before;
+ target.push(uiMessage);
+ }
+
+ return {
+ persistedBeforeInput: before,
+ persistedAfterInput: after,
+ };
+ }, [messages, owner, pendingInputRequestOrder, requestUserInputById]);
const hasVisibleAssistantMessage = useMemo(
() =>
messages.some(
@@ -705,10 +854,6 @@ export function FastSessionTranscript({
),
[messages],
);
- const pendingInputRequest = useMemo(
- () => findPendingSessionInputRequest(messages),
- [messages],
- );
const reviewOffers = useMemo(
() =>
messages.flatMap((message) => {
@@ -781,12 +926,53 @@ export function FastSessionTranscript({
}
return turns;
}, [liveVoiceTurns, owner]);
- const uiMessages = useMemo(
- () => [...persistedUiMessages, ...streamMessages, ...liveVoiceUiMessages],
- [persistedUiMessages, streamMessages, liveVoiceUiMessages],
- );
- const { renderBlocks, suppressMessage } = useAcpTranscriptBlocks({
- messages: uiMessages,
+ const { uiMessagesBeforeInput, uiMessagesAfterInput } = useMemo(() => {
+ if (!pendingInputRequestOrder) {
+ return {
+ uiMessagesBeforeInput: [
+ ...persistedBeforeInput,
+ ...persistedAfterInput,
+ ...streamMessages,
+ ...liveVoiceUiMessages,
+ ],
+ uiMessagesAfterInput: [],
+ };
+ }
+
+ const before = [...persistedBeforeInput];
+ const after = [...persistedAfterInput];
+ for (const message of streamMessages) {
+ (message.ts <= pendingInputRequestOrder.ts ? before : after).push(
+ message,
+ );
+ }
+ return { uiMessagesBeforeInput: before, uiMessagesAfterInput: after };
+ }, [
+ pendingInputRequestOrder,
+ persistedAfterInput,
+ persistedBeforeInput,
+ streamMessages,
+ liveVoiceUiMessages,
+ ]);
+ const {
+ renderBlocks: renderBlocksBeforeInput,
+ suppressMessage: suppressMessageBeforeInput,
+ } = useAcpTranscriptBlocks({
+ messages: uiMessagesBeforeInput,
+ artifacts: [],
+ displayMode,
+ initialPrompt: null,
+ shouldHideFirstMessage: false,
+ showInternalMessages: false,
+ hasLeadingTextBoundary: false,
+ keepDelegatedTasksVisible: true,
+ resetKey: `before:${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`,
+ });
+ const {
+ renderBlocks: renderBlocksAfterInput,
+ suppressMessage: suppressMessageAfterInput,
+ } = useAcpTranscriptBlocks({
+ messages: uiMessagesAfterInput,
artifacts: [],
displayMode,
initialPrompt: null,
@@ -794,7 +980,7 @@ export function FastSessionTranscript({
showInternalMessages: false,
hasLeadingTextBoundary: false,
keepDelegatedTasksVisible: true,
- resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`,
+ resetKey: `after:${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`,
});
// Every Fast turn started by the call, keyed by its turn id (the client
@@ -1275,9 +1461,36 @@ export function FastSessionTranscript({
) : null}
+ {pendingInputRequest ? (
+
+ {pendingInputRequest.preset === 'setup_starter_tasks' ? (
+
+ ) : pendingInputRequest.preset === 'setup_integrations' ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
{hasVisibleAssistantMessage ? timelineExtras : null}
@@ -1308,31 +1521,10 @@ export function FastSessionTranscript({
}
/>
))}
- {pendingInputRequest ? (
-
- {pendingInputRequest.preset === 'setup_starter_tasks' ? (
-
- ) : pendingInputRequest.preset === 'setup_integrations' ? (
-
- ) : (
-
- )}
-
- ) : null}
- {canReply && !pendingInputRequest ? (
+ {canReply && !pendingInputRequest?.preset ? (
({
}));
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 },
+ useEffectiveMcpIntegrations: () => ({
+ data: SETUP_INTEGRATIONS.map((integration) => {
+ const enabled = mocks.enablements.some(
+ (entry) => entry.mcpId === integration.id && entry.enabled,
+ );
+ const connected = mocks.connections.some(
+ (entry) =>
+ entry.mcpId === integration.id &&
+ entry.authStatus === 'authenticated',
+ );
+ return {
+ id: integration.id,
+ authStatus: connected ? 'authenticated' : null,
+ status: !mocks.enabled
+ ? 'unavailable'
+ : enabled
+ ? connected
+ ? 'connected'
+ : 'needs_connection'
+ : 'not_enabled',
+ };
+ }),
refetch: mocks.refetch,
+ isPending: mocks.pending,
+ isError: mocks.error,
}),
}));
vi.mock('@/components/settings/Integrations', () => ({
@@ -212,7 +226,7 @@ it.each(['Back to setup', 'Escape'])(
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
- expect(mocks.refetch).toHaveBeenCalledTimes(4);
+ expect(mocks.refetch).toHaveBeenCalledOnce();
expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled();
},
);
@@ -372,7 +386,11 @@ it('shows unavailable status without offering manual refresh after a status erro
it('does not treat an authenticated but disabled connector as connected', () => {
mocks.connections = [{ mcpId: 'notion', authStatus: 'authenticated' }];
render( );
- expect(screen.getByText('Not enabled')).toBeInTheDocument();
+ const notionRow = screen
+ .getByRole('button', { name: 'Connect Notion' })
+ .closest('li');
+ expect(notionRow).not.toBeNull();
+ expect(within(notionRow!).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 95761fb3ed..af861cfcee 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx
@@ -3,7 +3,7 @@
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 { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
MCP_INTEGRATIONS,
@@ -29,9 +29,7 @@ import {
import { McpIcon } from '@/components/settings/McpIcon';
import {
useConnectMcp,
- useCuratedIntegrationsAvailability,
- useDeploymentMcpEnablements,
- useUserMcpConnections,
+ useEffectiveMcpIntegrations,
} from '@/hooks/mcp-connections';
import { useAuthorizedUser } from '@/hooks/useUser';
import { useTelemetry } from '@/hooks/useTelemetry';
@@ -59,10 +57,7 @@ export function SetupIntegrationsCard({
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 effectiveIntegrations = useEffectiveMcpIntegrations();
const connectMcp = useConnectMcp();
const [activeId, setActiveId] = useState(null);
const [continued, setContinued] = useState(false);
@@ -115,41 +110,33 @@ export function SetupIntegrationsCard({
});
}, [matchedCount, mutate, request.requestId, sessionId]);
const refresh = () => {
- void onboarding.refetch();
- void enablements.refetch();
- void connections.refetch();
- void availability.refetch();
+ void effectiveIntegrations.refetch();
};
- const statusPending =
- onboarding.isPending || enablements.isPending || connections.isPending;
- const statusError =
- onboarding.isError ||
- enablements.isError ||
- connections.isError ||
- availability.isError;
+ const statusPending = effectiveIntegrations.isPending;
+ const statusError = effectiveIntegrations.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 effectiveById = new Map(
+ (effectiveIntegrations.data ?? []).map((integration) => [
+ integration.id,
+ integration,
+ ]),
);
const getStatus = (integration: (typeof SETUP_INTEGRATIONS)[number]) => {
if (statusPending || statusError) return null;
- if (integration.id === 'linear')
- 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' : null;
+ const status = effectiveById.get(integration.id)?.status;
+ if (status === 'connected') return 'Connected';
+ if (
+ status === 'not_enabled' &&
+ effectiveById.get(integration.id)?.authStatus === 'authenticated'
+ )
+ return 'Not enabled';
+ if (status === 'needs_connection') return 'Needs connection';
+ return null;
};
const authFailed =
searchParams.get('mcp') === 'error' || searchParams.get('error') !== null;
@@ -208,7 +195,9 @@ export function SetupIntegrationsCard({
or keep going.
) : null}
- {availability.data?.enabled === false ? (
+ {effectiveIntegrations.data?.some(
+ (integration) => integration.status === 'unavailable',
+ ) ? (
Tool integrations are disabled by the deployment operator. You can
still continue setup.
@@ -228,7 +217,8 @@ export function SetupIntegrationsCard({
(entry) => entry.id === integration.id,
);
const status = getStatus(integration);
- const unavailable = availability.data?.enabled === false;
+ const effective = effectiveById.get(integration.id);
+ const unavailable = effective?.status === 'unavailable';
return (
diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx
index 74e63b7880..59ed7f3c4a 100644
--- a/apps/web/src/components/settings/Integrations.test.tsx
+++ b/apps/web/src/components/settings/Integrations.test.tsx
@@ -8,6 +8,7 @@ import type {
} from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { toast } from 'sonner';
+import { MCP_INTEGRATIONS } from '@roomote/types';
import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors';
@@ -235,6 +236,34 @@ vi.mock('@/hooks/mcp-connections', () => ({
data: state.userConnections,
isPending: false,
}),
+ useEffectiveMcpIntegrations: () => ({
+ data: MCP_INTEGRATIONS.map((integration) => {
+ const enabled = state.deploymentEnablements.some(
+ (entry) => entry.mcpId === integration.id && entry.enabled,
+ );
+ const connection = state.userConnections.find(
+ (entry) => entry.mcpId === integration.id,
+ );
+ const oauthReadiness =
+ state.oauthReadiness.find((entry) => entry.mcpId === integration.id)
+ ?.status ?? 'not_required';
+ return {
+ id: integration.id,
+ available: state.integrationsEnabled,
+ enabled,
+ authStatus: connection?.authStatus ?? null,
+ oauthReadiness,
+ status: !state.integrationsEnabled
+ ? 'unavailable'
+ : enabled
+ ? connection?.authStatus === 'authenticated'
+ ? 'connected'
+ : 'needs_connection'
+ : 'not_enabled',
+ };
+ }),
+ isPending: false,
+ }),
useMcpConnectionTools: () => ({
data: cloneMcpToolsData(),
isPending: false,
diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx
index 58f53797c3..6e15efeb4d 100644
--- a/apps/web/src/components/settings/Integrations.tsx
+++ b/apps/web/src/components/settings/Integrations.tsx
@@ -24,7 +24,6 @@ import {
import {
useAsanaConnection,
useConnectMcp,
- useCuratedIntegrationsAvailability,
useDisconnectMcp,
useGrafanaConnection,
useGranolaConnection,
@@ -32,6 +31,7 @@ import {
useVoiceConnection,
useDeploymentMcpEnablements,
useMcpOauthReadiness,
+ useEffectiveMcpIntegrations,
useNotionConnection,
useRipplingConnection,
useSaveAsanaConnection,
@@ -47,7 +47,6 @@ import {
useSaveXConnection,
useSetDeploymentMcpEnabled,
useSnowflakeConnection,
- useUserMcpConnections,
useVercelConnection,
useXConnection,
} from '@/hooks/mcp-connections';
@@ -1676,19 +1675,16 @@ export function Integrations({
);
const disconnectLinear = useDisconnectLinear();
- const deploymentEnablements = useDeploymentMcpEnablements();
- const integrationsAvailability = useCuratedIntegrationsAvailability();
- const oauthReadiness = useMcpOauthReadiness();
- const linearOauthStatus = oauthReadiness.data?.find(
- (entry) => entry.mcpId === 'linear',
- )?.status;
+ const effectiveIntegrations = useEffectiveMcpIntegrations();
+ const linearOauthStatus = effectiveIntegrations.data?.find(
+ (entry) => entry.id === 'linear',
+ )?.oauthReadiness;
const linearOauthUnavailable =
linearOauthStatus === 'missing' || linearOauthStatus === 'partial';
const linearOauthSetup = useLinearOauthSetup(
isAdmin && (linearOauthUnavailable || isLinearOauthSetupOpen),
);
const setDeploymentEnabled = useSetDeploymentMcpEnabled();
- const userMcpConnections = useUserMcpConnections();
const connectMcp = useConnectMcp();
const disconnectMcp = useDisconnectMcp();
const saveAsanaConnection = useSaveAsanaConnection();
@@ -1702,12 +1698,12 @@ export function Integrations({
const saveVercelConnection = useSaveVercelConnection();
const saveXConnection = useSaveXConnection();
const asanaConnectionSummary = useMemo(() => {
- const connection = (userMcpConnections.data ?? []).find(
- (entry) => entry.mcpId === 'asana',
+ const connection = (effectiveIntegrations.data ?? []).find(
+ (entry) => entry.id === 'asana',
);
return connection;
- }, [userMcpConnections.data]);
+ }, [effectiveIntegrations.data]);
const isAsanaConnected =
asanaConnectionSummary?.authStatus === 'authenticated';
const asanaConnection = useAsanaConnection(
@@ -1715,8 +1711,8 @@ export function Integrations({
);
const notionConnectionSummary = useMemo(
() =>
- (userMcpConnections.data ?? []).find((entry) => entry.mcpId === 'notion'),
- [userMcpConnections.data],
+ (effectiveIntegrations.data ?? []).find((entry) => entry.id === 'notion'),
+ [effectiveIntegrations.data],
);
const notionConnection = useNotionConnection(
isAdmin &&
@@ -1728,10 +1724,10 @@ export function Integrations({
notionConnection.data?.authStatus === 'authenticated';
const ripplingConnectionSummary = useMemo(
() =>
- (userMcpConnections.data ?? []).find(
- (entry) => entry.mcpId === 'rippling',
+ (effectiveIntegrations.data ?? []).find(
+ (entry) => entry.id === 'rippling',
),
- [userMcpConnections.data],
+ [effectiveIntegrations.data],
);
const ripplingConnection = useRipplingConnection(
isAdmin &&
@@ -1742,12 +1738,12 @@ export function Integrations({
ripplingConnectionSummary?.authStatus === 'authenticated' &&
ripplingConnection.data?.authStatus === 'authenticated';
const granolaConnectionSummary = useMemo(() => {
- const connection = (userMcpConnections.data ?? []).find(
- (entry) => entry.mcpId === 'granola',
+ const connection = (effectiveIntegrations.data ?? []).find(
+ (entry) => entry.id === 'granola',
);
return connection;
- }, [userMcpConnections.data]);
+ }, [effectiveIntegrations.data]);
const isGranolaConnected =
granolaConnectionSummary?.authStatus === 'authenticated';
const granolaConnection = useGranolaConnection(
@@ -1766,60 +1762,60 @@ export function Integrations({
const voiceConfiguredByEnvironment =
voiceConnection.data?.source === 'environment';
const elevenLabsConnectionSummary = useMemo(() => {
- const connection = (userMcpConnections.data ?? []).find(
- (entry) => entry.mcpId === 'elevenlabs',
+ const connection = (effectiveIntegrations.data ?? []).find(
+ (entry) => entry.id === 'elevenlabs',
);
return connection;
- }, [userMcpConnections.data]);
+ }, [effectiveIntegrations.data]);
const isElevenLabsConnected =
elevenLabsConnectionSummary?.authStatus === 'authenticated';
const elevenLabsConnection = useElevenLabsConnection(
isAdmin && (isElevenLabsConnected || isElevenLabsDialogOpen),
);
const grafanaConnectionSummary = useMemo(() => {
- const connection = (userMcpConnections.data ?? []).find(
- (entry) => entry.mcpId === 'grafana',
+ const connection = (effectiveIntegrations.data ?? []).find(
+ (entry) => entry.id === 'grafana',
);
return connection;
- }, [userMcpConnections.data]);
+ }, [effectiveIntegrations.data]);
const isGrafanaConnected =
grafanaConnectionSummary?.authStatus === 'authenticated';
const grafanaConnection = useGrafanaConnection(
isAdmin && (isGrafanaConnected || isGrafanaDialogOpen),
);
const snowflakeConnectionSummary = useMemo(() => {
- const connection = (userMcpConnections.data ?? []).find(
- (entry) => entry.mcpId === 'snowflake',
+ const connection = (effectiveIntegrations.data ?? []).find(
+ (entry) => entry.id === 'snowflake',
);
return connection;
- }, [userMcpConnections.data]);
+ }, [effectiveIntegrations.data]);
const isSnowflakeConnected =
snowflakeConnectionSummary?.authStatus === 'authenticated';
const snowflakeConnection = useSnowflakeConnection(
isAdmin && (isSnowflakeConnected || isSnowflakeDialogOpen),
);
const vercelConnectionSummary = useMemo(() => {
- const connection = (userMcpConnections.data ?? []).find(
- (entry) => entry.mcpId === 'vercel',
+ const connection = (effectiveIntegrations.data ?? []).find(
+ (entry) => entry.id === 'vercel',
);
return connection;
- }, [userMcpConnections.data]);
+ }, [effectiveIntegrations.data]);
const isVercelConnected =
vercelConnectionSummary?.authStatus === 'authenticated';
const vercelConnection = useVercelConnection(
isAdmin && (isVercelConnected || isVercelDialogOpen),
);
const xConnectionSummary = useMemo(() => {
- const connection = (userMcpConnections.data ?? []).find(
- (entry) => entry.mcpId === 'x',
+ const connection = (effectiveIntegrations.data ?? []).find(
+ (entry) => entry.id === 'x',
);
return connection;
- }, [userMcpConnections.data]);
+ }, [effectiveIntegrations.data]);
const isXConnected = xConnectionSummary?.authStatus === 'authenticated';
const xConnection = useXConnection(
isAdmin && (isXConnected || isXDialogOpen),
@@ -2006,13 +2002,13 @@ export function Integrations({
const items = useMemo(() => {
const visibleMcpIntegrations = MCP_INTEGRATIONS;
const orgEnablementMap = new Map(
- (deploymentEnablements.data ?? []).map((entry) => [
- entry.mcpId,
+ (effectiveIntegrations.data ?? []).map((entry) => [
+ entry.id,
entry.enabled,
]),
);
const userConnectionMap = new Map(
- (userMcpConnections.data ?? []).map((entry) => [entry.mcpId, entry]),
+ (effectiveIntegrations.data ?? []).map((entry) => [entry.id, entry]),
);
const canSetUpLinearOauth = isAdmin && linearOauthUnavailable;
const canConfigureLinearOauth = isAdmin && !linearOauthUnavailable;
@@ -2071,7 +2067,7 @@ export function Integrations({
isMcpBased: false,
isPending:
linearInstallation.isPending ||
- (!linearInstallation.data && oauthReadiness.isPending) ||
+ (!linearInstallation.data && effectiveIntegrations.isPending) ||
connectLinear.isPending ||
disconnectLinear.isPending,
status: linearOauthUnavailable
@@ -2506,7 +2502,7 @@ export function Integrations({
linearOauthSetup.isPending,
linearOauthStatus,
linearOauthUnavailable,
- oauthReadiness.isPending,
+ effectiveIntegrations.isPending,
isAdmin,
isGrafanaDialogOpen,
isGranolaDialogOpen,
@@ -2521,7 +2517,7 @@ export function Integrations({
saveElevenLabsConnection.isPending,
saveVoiceConnection.isPending,
saveVercelConnection.isPending,
- deploymentEnablements.data,
+ effectiveIntegrations.data,
pathname,
integrationIds,
setDeploymentEnabled,
@@ -2542,7 +2538,6 @@ export function Integrations({
xConnection.isPending,
isXDialogOpen,
highlightedIntegrationId,
- userMcpConnections.data,
]);
const {
@@ -3225,7 +3220,11 @@ export function Integrations({
});
};
- if (integrationsAvailability.data?.enabled === false) {
+ if (
+ effectiveIntegrations.data?.some(
+ (integration) => integration.status === 'unavailable',
+ )
+ ) {
return (
diff --git a/apps/web/src/hooks/linear/useConnectLinear.ts b/apps/web/src/hooks/linear/useConnectLinear.ts
index af34ee615d..14b6e0ccf1 100644
--- a/apps/web/src/hooks/linear/useConnectLinear.ts
+++ b/apps/web/src/hooks/linear/useConnectLinear.ts
@@ -5,6 +5,7 @@ import {
} from '@tanstack/react-query';
import { useTRPC, useTRPCClient } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from '@/hooks/mcp-connections';
type UseConnectLinearOptions = Omit<
UseMutationOptions,
@@ -28,13 +29,10 @@ export const useConnectLinear = (
});
},
onSuccess: (data, variables, onMutateResult, context) => {
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.linear.installation.queryKey(),
});
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
-
options?.onSuccess?.(data, variables, onMutateResult, context);
},
onError: options?.onError,
diff --git a/apps/web/src/hooks/linear/useDisconnectLinear.ts b/apps/web/src/hooks/linear/useDisconnectLinear.ts
index 49bcde905d..fc6c2542c9 100644
--- a/apps/web/src/hooks/linear/useDisconnectLinear.ts
+++ b/apps/web/src/hooks/linear/useDisconnectLinear.ts
@@ -5,6 +5,7 @@ import {
} from '@tanstack/react-query';
import { useTRPC, useTRPCClient } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from '@/hooks/mcp-connections';
type UseDisconnectLinearOptions = Omit<
UseMutationOptions,
@@ -28,13 +29,10 @@ export const useDisconnectLinear = (options?: UseDisconnectLinearOptions) => {
}
},
onSuccess: (data, variables, onMutateResult, context) => {
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.linear.installation.queryKey(),
});
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
-
options?.onSuccess?.(data, variables, onMutateResult, context);
},
onError: options?.onError,
diff --git a/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts b/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts
index 81d6413de5..14432f15ee 100644
--- a/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts
+++ b/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts
@@ -3,6 +3,7 @@
import { useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from '@/hooks/mcp-connections';
export function useInvalidateLinearOauthSetup() {
const trpc = useTRPC();
@@ -10,18 +11,13 @@ export function useInvalidateLinearOauthSetup() {
return async () => {
await Promise.all([
+ invalidateMcpIntegrationStatusQueries(queryClient, trpc),
queryClient.invalidateQueries({
queryKey: trpc.linear.oauthSetup.queryKey(),
}),
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.oauthReadiness.queryKey(),
- }),
queryClient.invalidateQueries({
queryKey: trpc.linear.installation.queryKey(),
}),
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- }),
]);
};
}
diff --git a/apps/web/src/hooks/mcp-connections/index.ts b/apps/web/src/hooks/mcp-connections/index.ts
index 553cf91b28..4cf9382734 100644
--- a/apps/web/src/hooks/mcp-connections/index.ts
+++ b/apps/web/src/hooks/mcp-connections/index.ts
@@ -1,9 +1,9 @@
// Queries
export { useDeploymentMcpEnablements } from './useDeploymentMcpEnablements';
-export { useCuratedIntegrationsAvailability } from './useCuratedIntegrationsAvailability';
export { useUserMcpConnections } from './useUserMcpConnections';
export { useMcpConnectionTools } from './useMcpConnectionTools';
-export { useMcpOauthReadiness } from './useMcpOauthReadiness';
+export { useEffectiveMcpIntegrations } from './useEffectiveMcpIntegrations';
+export { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
// Mutations
export { useSetDeploymentMcpEnabled } from './useSetDeploymentMcpEnabled';
diff --git a/apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.test.ts b/apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.test.ts
new file mode 100644
index 0000000000..3739c365ea
--- /dev/null
+++ b/apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.test.ts
@@ -0,0 +1,32 @@
+import type { QueryClient } from '@tanstack/react-query';
+
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
+
+it('invalidates every integration status projection', async () => {
+ const invalidateQueries = vi.fn().mockResolvedValue(undefined);
+ const query = (key: string) => ({ queryKey: () => [key] });
+ const trpc = {
+ mcpConnections: {
+ effectiveIntegrations: query('effective'),
+ deploymentEnablements: query('enablements'),
+ userConnections: query('connections'),
+ oauthReadiness: query('oauth'),
+ availability: query('availability'),
+ },
+ };
+
+ await invalidateMcpIntegrationStatusQueries(
+ { invalidateQueries } as unknown as QueryClient,
+ trpc as never,
+ );
+
+ expect(
+ invalidateQueries.mock.calls.map(([options]) => options.queryKey),
+ ).toEqual([
+ ['effective'],
+ ['enablements'],
+ ['connections'],
+ ['oauth'],
+ ['availability'],
+ ]);
+});
diff --git a/apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.ts b/apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.ts
new file mode 100644
index 0000000000..c4124e7148
--- /dev/null
+++ b/apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.ts
@@ -0,0 +1,26 @@
+import type { QueryClient } from '@tanstack/react-query';
+
+import type { useTRPC } from '@/trpc/client';
+
+export function invalidateMcpIntegrationStatusQueries(
+ queryClient: QueryClient,
+ trpc: ReturnType,
+) {
+ return Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: trpc.mcpConnections.effectiveIntegrations.queryKey(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.mcpConnections.userConnections.queryKey(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.mcpConnections.oauthReadiness.queryKey(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.mcpConnections.availability.queryKey(),
+ }),
+ ]);
+}
diff --git a/apps/web/src/hooks/mcp-connections/useConnectMcp.ts b/apps/web/src/hooks/mcp-connections/useConnectMcp.ts
index 29f7f2be4a..17faefca50 100644
--- a/apps/web/src/hooks/mcp-connections/useConnectMcp.ts
+++ b/apps/web/src/hooks/mcp-connections/useConnectMcp.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useConnectMcp() {
const trpc = useTRPC();
@@ -11,9 +12,7 @@ export function useConnectMcp() {
return useMutation(
trpc.mcpConnections.connect.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
},
}),
);
diff --git a/apps/web/src/hooks/mcp-connections/useDisconnectMcp.ts b/apps/web/src/hooks/mcp-connections/useDisconnectMcp.ts
index 21f0f46626..0c19dc5b10 100644
--- a/apps/web/src/hooks/mcp-connections/useDisconnectMcp.ts
+++ b/apps/web/src/hooks/mcp-connections/useDisconnectMcp.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useDisconnectMcp() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useDisconnectMcp() {
return useMutation(
trpc.mcpConnections.disconnect.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.snowflakeConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useCuratedIntegrationsAvailability.ts b/apps/web/src/hooks/mcp-connections/useEffectiveMcpIntegrations.ts
similarity index 52%
rename from apps/web/src/hooks/mcp-connections/useCuratedIntegrationsAvailability.ts
rename to apps/web/src/hooks/mcp-connections/useEffectiveMcpIntegrations.ts
index b0ad570a64..6bc00a87ad 100644
--- a/apps/web/src/hooks/mcp-connections/useCuratedIntegrationsAvailability.ts
+++ b/apps/web/src/hooks/mcp-connections/useEffectiveMcpIntegrations.ts
@@ -4,8 +4,8 @@ import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
-export function useCuratedIntegrationsAvailability() {
+export function useEffectiveMcpIntegrations() {
const trpc = useTRPC();
- return useQuery(trpc.mcpConnections.availability.queryOptions());
+ return useQuery(trpc.mcpConnections.effectiveIntegrations.queryOptions());
}
diff --git a/apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts b/apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts
deleted file mode 100644
index e4567e2134..0000000000
--- a/apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-'use client';
-
-import { useQuery } from '@tanstack/react-query';
-
-import { useTRPC } from '@/trpc/client';
-
-export function useMcpOauthReadiness() {
- const trpc = useTRPC();
-
- return useQuery(trpc.mcpConnections.oauthReadiness.queryOptions());
-}
diff --git a/apps/web/src/hooks/mcp-connections/useSaveAsanaConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveAsanaConnection.ts
index be18d88d7b..317db4d84e 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveAsanaConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveAsanaConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveAsanaConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveAsanaConnection() {
return useMutation(
trpc.mcpConnections.saveAsanaConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.asanaConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSaveElevenLabsConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveElevenLabsConnection.ts
index ea7fa55152..df6b6f3be1 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveElevenLabsConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveElevenLabsConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveElevenLabsConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveElevenLabsConnection() {
return useMutation(
trpc.mcpConnections.saveElevenLabsConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.elevenLabsConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSaveGrafanaConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveGrafanaConnection.ts
index 55deaa5f36..0229892a53 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveGrafanaConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveGrafanaConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveGrafanaConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveGrafanaConnection() {
return useMutation(
trpc.mcpConnections.saveGrafanaConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.grafanaConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts
index bc1240c860..ad09490321 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveGranolaConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveGranolaConnection() {
return useMutation(
trpc.mcpConnections.saveGranolaConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.granolaConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSaveNotionConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveNotionConnection.ts
index 73fe32e0bc..07a3abc305 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveNotionConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveNotionConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveNotionConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveNotionConnection() {
return useMutation(
trpc.mcpConnections.saveNotionConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.notionConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSaveRipplingConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveRipplingConnection.ts
index 8758ecd567..94c3ab4d8e 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveRipplingConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveRipplingConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveRipplingConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveRipplingConnection() {
return useMutation(
trpc.mcpConnections.saveRipplingConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.ripplingConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSaveSnowflakeConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveSnowflakeConnection.ts
index 63b09d8fb2..378a4a8a75 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveSnowflakeConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveSnowflakeConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveSnowflakeConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveSnowflakeConnection() {
return useMutation(
trpc.mcpConnections.saveSnowflakeConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.snowflakeConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSaveVercelConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveVercelConnection.ts
index bd77967faf..d88fbef0be 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveVercelConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveVercelConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveVercelConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveVercelConnection() {
return useMutation(
trpc.mcpConnections.saveVercelConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.vercelConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSaveXConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveXConnection.ts
index fdc17cbeb3..1c29ccbfc2 100644
--- a/apps/web/src/hooks/mcp-connections/useSaveXConnection.ts
+++ b/apps/web/src/hooks/mcp-connections/useSaveXConnection.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSaveXConnection() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSaveXConnection() {
return useMutation(
trpc.mcpConnections.saveXConnection.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.xConnection.queryKey(),
});
diff --git a/apps/web/src/hooks/mcp-connections/useSetDeploymentMcpEnabled.ts b/apps/web/src/hooks/mcp-connections/useSetDeploymentMcpEnabled.ts
index 2109e07ab2..ea1e30ab22 100644
--- a/apps/web/src/hooks/mcp-connections/useSetDeploymentMcpEnabled.ts
+++ b/apps/web/src/hooks/mcp-connections/useSetDeploymentMcpEnabled.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSetDeploymentMcpEnabled() {
const trpc = useTRPC();
@@ -11,12 +12,7 @@ export function useSetDeploymentMcpEnabled() {
return useMutation(
trpc.mcpConnections.setDeploymentEnabled.mutationOptions({
onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(),
- });
- queryClient.invalidateQueries({
- queryKey: trpc.mcpConnections.userConnections.queryKey(),
- });
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
},
}),
);
diff --git a/apps/web/src/hooks/mcp-connections/useSetDisabledMcpTools.ts b/apps/web/src/hooks/mcp-connections/useSetDisabledMcpTools.ts
index 6ca96f8e9b..e3938fa343 100644
--- a/apps/web/src/hooks/mcp-connections/useSetDisabledMcpTools.ts
+++ b/apps/web/src/hooks/mcp-connections/useSetDisabledMcpTools.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
+import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries';
export function useSetDisabledMcpTools() {
const trpc = useTRPC();
@@ -11,6 +12,7 @@ export function useSetDisabledMcpTools() {
return useMutation(
trpc.mcpConnections.setDisabledTools.mutationOptions({
onSuccess: (_data, variables) => {
+ void invalidateMcpIntegrationStatusQueries(queryClient, trpc);
queryClient.invalidateQueries({
queryKey: trpc.mcpConnections.listTools.queryKey({
mcpId: variables.mcpId,
diff --git a/apps/web/src/lib/server/mcp-static-oauth.ts b/apps/web/src/lib/server/mcp-static-oauth.ts
index 9ef3be6ab9..413668553f 100644
--- a/apps/web/src/lib/server/mcp-static-oauth.ts
+++ b/apps/web/src/lib/server/mcp-static-oauth.ts
@@ -1,4 +1,8 @@
-import { MCP_INTEGRATIONS, type McpIntegration } from '@roomote/types';
+import {
+ MCP_INTEGRATIONS,
+ type McpIntegration,
+ type McpIntegrationOauthReadiness,
+} from '@roomote/types';
type StaticOauthClientEnv = NonNullable;
type StaticOauthPairResolution =
@@ -10,11 +14,7 @@ type StaticOauthPairResolution =
status: 'missing' | 'partial';
};
-export type StaticOauthReadiness =
- | 'not_required'
- | 'ready'
- | 'missing'
- | 'partial';
+export type StaticOauthReadiness = McpIntegrationOauthReadiness;
const STATIC_OAUTH_FALLBACKS: Partial> =
{};
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 39ca15f49f..29defe4f78 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.test.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.test.ts
@@ -34,6 +34,7 @@ vi.mock('next/server', () => ({ after: mocks.after }));
vi.mock('@roomote/cloud-agents/server', () => ({
acquireFastAgentTurnLock: mocks.acquireTurnLock,
answerFastAgentQuestion: mocks.answerQuestion,
+ buildFastAgentSetupAdapter: vi.fn(() => ({})),
createFastAgentWebTaskLauncher: mocks.createWebTaskLauncher,
FastAgentDurableRetryScheduledError: class FastAgentDurableRetryScheduledError extends Error {},
getOrCreateFastAgentSession: mocks.getOrCreateSession,
@@ -169,6 +170,12 @@ describe('setup context on ordinary Fast session input', () => {
setupSession: true,
adapterExtensions: { resolveUserInputPreset: resolvePreset },
setupSnapshot: initialSnapshot,
+ setupContext: {
+ sessionId: 'session-1',
+ fastConversationId: 'session-1',
+ setupSnapshot: initialSnapshot,
+ starterTaskOptions: [],
+ },
};
const question = {
id: 'setup-tools-documents',
@@ -197,6 +204,7 @@ describe('setup context on ordinary Fast session input', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mocks.after.mockReset();
mocks.resolveSetupContext.mockReset().mockResolvedValue(null);
mocks.upsertMessage.mockReset().mockResolvedValue(undefined);
mocks.findAccessibleSession.mockResolvedValue(session);
@@ -247,6 +255,15 @@ describe('setup context on ordinary Fast session input', () => {
adapter: { resolveUserInputPreset: resolvePreset },
});
expect(mocks.resolveSetupContext).toHaveBeenCalledWith(auth, session.id);
+ const { persistFastAgentInlineHumanTurn } =
+ await import('@roomote/sdk/server');
+ expect(vi.mocked(persistFastAgentInlineHumanTurn)).toHaveBeenCalledWith({
+ parent: expect.objectContaining({ sessionId: session.id }),
+ event: expect.objectContaining({
+ setupSession: true,
+ setupContext: setupContext.setupContext,
+ }),
+ });
});
it('leaves ordinary non-setup replies unchanged', async () => {
@@ -268,7 +285,14 @@ describe('setup context on ordinary Fast session input', () => {
.mockResolvedValueOnce(setupContext)
.mockImplementation(async () => {
expect(mocks.upsertMessage).toHaveBeenCalledOnce();
- return { ...setupContext, setupSnapshot: freshSnapshot };
+ return {
+ ...setupContext,
+ setupSnapshot: freshSnapshot,
+ setupContext: {
+ ...setupContext.setupContext,
+ setupSnapshot: freshSnapshot,
+ },
+ };
});
await submitFastSessionUserInputCommand(auth, input, {
setupSession: true,
@@ -290,6 +314,17 @@ describe('setup context on ordinary Fast session input', () => {
}),
}),
);
+ const { persistFastAgentInlineHumanTurn } =
+ await import('@roomote/sdk/server');
+ expect(vi.mocked(persistFastAgentInlineHumanTurn)).toHaveBeenCalledWith({
+ parent: expect.objectContaining({ sessionId: session.id }),
+ event: expect.objectContaining({
+ turnSource: 'platform_event',
+ platformEventKind: 'input_response',
+ setupSession: true,
+ setupContext: expect.objectContaining({ setupSnapshot: freshSnapshot }),
+ }),
+ });
});
it.each(['documents', 'communication'])(
@@ -313,7 +348,14 @@ describe('setup context on ordinary Fast session input', () => {
.mockResolvedValueOnce(setupContext)
.mockImplementation(async () => {
expect(mocks.upsertMessage).toHaveBeenCalledOnce();
- return { ...setupContext, setupSnapshot: skippedSnapshot };
+ return {
+ ...setupContext,
+ setupSnapshot: skippedSnapshot,
+ setupContext: {
+ ...setupContext.setupContext,
+ setupSnapshot: skippedSnapshot,
+ },
+ };
});
await submitFastSessionUserInputCommand(auth, {
...input,
@@ -353,7 +395,7 @@ describe('setup context on ordinary Fast session input', () => {
expect(mocks.after).not.toHaveBeenCalled();
});
- it('replays a saved setup category response with a fresh snapshot without persisting twice', async () => {
+ it('treats a duplicate saved setup category response as successful without scheduling twice', async () => {
const saved = {
eventId: 'response-event',
payload: {
@@ -371,13 +413,31 @@ describe('setup context on ordinary Fast session input', () => {
mocks.resolveSetupContext.mockResolvedValue({
...setupContext,
setupSnapshot: freshSnapshot,
+ setupContext: {
+ ...setupContext.setupContext,
+ setupSnapshot: freshSnapshot,
+ },
});
await submitFastSessionUserInputCommand(auth, input);
expect(mocks.upsertMessage).not.toHaveBeenCalled();
- expect(await runScheduled()).toMatchObject({
- setupSession: true,
- setupSnapshot: freshSnapshot,
+ expect(mocks.after).not.toHaveBeenCalled();
+ });
+
+ it('does not schedule when another generic response-row claimant won', async () => {
+ mocks.dbSelectLimit
+ .mockResolvedValueOnce([request])
+ .mockResolvedValueOnce([]);
+ mocks.upsertMessage.mockResolvedValueOnce({
+ initialHumanTurn: false,
+ inserted: false,
});
+
+ await expect(
+ submitFastSessionUserInputCommand(auth, input),
+ ).resolves.toEqual({ success: true });
+
+ expect(mocks.upsertMessage).toHaveBeenCalledOnce();
+ expect(mocks.after).not.toHaveBeenCalled();
});
it('routes final presets through setup-specific persistence, not ordinary response writes', async () => {
diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts
index 494b0c1778..6979fe6e76 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.ts
@@ -6,6 +6,7 @@ import { after } from 'next/server';
import {
acquireFastAgentTurnLock,
answerFastAgentQuestion,
+ buildFastAgentSetupAdapter,
createFastAgentWebTaskLauncher,
FastAgentDurableRetryScheduledError,
getOrCreateFastAgentSession,
@@ -46,9 +47,10 @@ import {
isSetupIntegrationDiscoveryQuestionId,
parseAcpRequestUserInputAnswers,
parseAcpRequestUserInputPayload,
- parseAcpRequestUserInputResponsePayload,
+ normalizeAcpRequestUserInputAnswers,
type AcpRequestUserInputAnswers,
type AcpRequestUserInputPayload,
+ type FastAgentSetupTurnContext,
type ReasoningEffort,
} from '@roomote/types';
import type { FastAgentTurnAdapter } from '@roomote/cloud-agents/server';
@@ -162,6 +164,7 @@ type WebFastAgentTurnInput = {
/** Spoken on a voice call: Fast returns its result to the voice instead
* of writing a chat reply. */
voiceMode?: boolean;
+ setupContext?: FastAgentSetupTurnContext;
adapterExtensions?: Partial;
};
@@ -213,6 +216,7 @@ async function runWebFastAgentTurn({
setupSnapshot,
setupSession,
voiceMode,
+ setupContext,
adapterExtensions,
durableSessionId,
}: WebFastAgentTurnInput): Promise {
@@ -261,11 +265,10 @@ async function runWebFastAgentTurn({
const turnMessageId = currentMessageId ?? `web-${randomUUID()}`;
// Durable admission: a web turn is persisted under this process's claim
// before it runs, so an interruption hands it to the queue. Platform
- // events ride the same row with their framing recorded; the ones that
- // need adapter extensions or a setup snapshot cannot be rebuilt by the
- // queue and stay process-bound.
+ // events ride the same row with their framing recorded. Setup context is
+ // serializable, so its trusted adapter can be rebuilt by queue recovery.
const durableTurn =
- durableSessionId && !adapterExtensions && !setupSnapshot
+ durableSessionId && (!adapterExtensions || setupContext)
? await persistFastAgentInlineHumanTurn({
parent: { sessionId: durableSessionId, conversation },
event: {
@@ -287,6 +290,7 @@ async function runWebFastAgentTurn({
: {}),
...(setupSession ? { setupSession: true } : {}),
...(voiceMode ? { voiceMode: true } : {}),
+ ...(setupContext ? { setupContext } : {}),
},
}).catch((error) => {
console.error(
@@ -327,7 +331,9 @@ async function runWebFastAgentTurn({
...(platformEventVisibility ? { platformEventVisibility } : {}),
}
: {}),
- ...(setupSnapshot ? { setupSnapshot } : {}),
+ ...(setupContext?.setupSnapshot || setupSnapshot
+ ? { setupSnapshot: setupContext?.setupSnapshot ?? setupSnapshot }
+ : {}),
setupSession,
...(voiceMode ? { voiceMode: true } : {}),
adapter: {
@@ -355,6 +361,7 @@ async function runWebFastAgentTurn({
}
: {}),
...delivery.adapter,
+ ...(setupContext ? buildFastAgentSetupAdapter(setupContext) : {}),
...adapterExtensions,
},
});
@@ -786,6 +793,7 @@ export async function submitFastSessionUserInputCommand(
adapterExtensions?: Partial;
setupSnapshot?: string;
setupSession?: boolean;
+ setupContext?: FastAgentSetupTurnContext;
persistSetupPresetResponse?: (input: {
fastConversationId: string;
request: {
@@ -845,7 +853,11 @@ export async function submitFastSessionUserInputCommand(
if (!requestPayload) {
throw new Error('This input request is no longer valid.');
}
- const submitted = parseAcpRequestUserInputAnswers(input.answers) ?? {};
+ const parsedAnswers = parseAcpRequestUserInputAnswers(input.answers) ?? {};
+ const submitted = normalizeAcpRequestUserInputAnswers(
+ requestPayload.questions,
+ parsedAnswers,
+ );
const resolution = input.resolution ?? 'submitted';
if (requestPayload.preset && resolution === 'cancelled') {
throw new Error('This required setup choice cannot be cancelled.');
@@ -919,21 +931,13 @@ export async function submitFastSessionUserInputCommand(
...(options.setupSnapshot
? { setupSnapshot: options.setupSnapshot }
: {}),
+ ...(options.setupContext ? { setupContext: options.setupContext } : {}),
setupSession: options.setupSession ?? false,
...freshSetupContext,
});
};
if (existingResponse) {
- const persistedResponse = parseAcpRequestUserInputResponsePayload(
- existingResponse.payload,
- );
- if (!requestPayload.preset && persistedResponse) {
- await scheduleResponseTurn(
- persistedResponse.answers,
- persistedResponse.resolution,
- );
- }
return { success: true };
}
@@ -956,8 +960,9 @@ export async function submitFastSessionUserInputCommand(
});
return { success: true };
}
- await upsertFastAgentMessage({
+ const responseClaim = await upsertFastAgentMessage({
sessionId: session.id,
+ insertOnly: true,
message: {
eventId: responseEventId,
turnId: request.turnId,
@@ -987,7 +992,9 @@ export async function submitFastSessionUserInputCommand(
},
});
- await scheduleResponseTurn(submitted, resolution);
+ if (responseClaim?.inserted !== false) {
+ await scheduleResponseTurn(submitted, resolution);
+ }
return { success: true };
}
diff --git a/apps/web/src/trpc/commands/mcp-connections/index.test.ts b/apps/web/src/trpc/commands/mcp-connections/index.test.ts
index 96517fdaf1..fa4c6fd099 100644
--- a/apps/web/src/trpc/commands/mcp-connections/index.test.ts
+++ b/apps/web/src/trpc/commands/mcp-connections/index.test.ts
@@ -27,6 +27,7 @@ import type { UserAuthSuccess } from '@/types';
import {
connectMcpCommand,
getVoiceConnectionCommand,
+ getEffectiveMcpIntegrationsCommand,
saveAsanaConnectionCommand,
saveVoiceConnectionCommand,
setDeploymentMcpEnabledCommand,
@@ -38,6 +39,11 @@ const adminAuth = {
userId: 'mcp-connections-admin',
isAdmin: true,
} as UserAuthSuccess;
+const memberAuth = {
+ ...adminAuth,
+ userId: 'mcp-connections-member',
+ isAdmin: false,
+} as UserAuthSuccess;
async function cleanup() {
await db.delete(mcpConnections);
@@ -47,6 +53,7 @@ async function cleanup() {
describe('MCP connection lifecycle telemetry', () => {
beforeAll(async () => {
await userFactory.create({ id: adminAuth.userId });
+ await userFactory.create({ id: memberAuth.userId });
});
beforeEach(async () => {
@@ -195,4 +202,44 @@ describe('MCP connection lifecycle telemetry', () => {
});
expect(reconnected?.refreshToken).toBeTruthy();
});
+
+ it('projects effective status from correctly scoped connections', async () => {
+ await db.insert(deploymentMcpEnablements).values([
+ { mcpId: 'sentry', enabled: true, enabledByUserId: adminAuth.userId },
+ { mcpId: 'monday', enabled: true, enabledByUserId: adminAuth.userId },
+ ]);
+ await db.insert(mcpConnections).values([
+ {
+ userId: null,
+ mcpId: 'sentry',
+ enabled: true,
+ authStatus: 'authenticated',
+ },
+ {
+ userId: memberAuth.userId,
+ mcpId: 'monday',
+ enabled: true,
+ authStatus: 'authenticated',
+ },
+ ]);
+
+ const integrations = await getEffectiveMcpIntegrationsCommand(adminAuth);
+
+ expect(integrations.find(({ id }) => id === 'sentry')).toMatchObject({
+ connectionScope: 'deployment',
+ enabled: true,
+ authStatus: 'authenticated',
+ status: 'connected',
+ capabilities: { agentTools: true, toolManagement: true },
+ });
+ expect(integrations.find(({ id }) => id === 'monday')).toMatchObject({
+ connectionScope: 'user',
+ enabled: true,
+ authStatus: null,
+ status: 'needs_connection',
+ });
+ expect(integrations.find(({ id }) => id === 'rippling')).toMatchObject({
+ capabilities: { agentTools: false, toolManagement: false },
+ });
+ });
});
diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts
index cc673a62b5..a9b72a2657 100644
--- a/apps/web/src/trpc/commands/mcp-connections/index.ts
+++ b/apps/web/src/trpc/commands/mcp-connections/index.ts
@@ -15,6 +15,7 @@ import {
getDefaultMcpConnectionRole,
getAllowedIntegrationMcpToolNames,
getMcpIntegration,
+ getMcpIntegrationConnectionMode,
getMcpIntegrationConnectionScope,
getMcpIntegrationDefaultDisabledTools,
type McpConnectionRole,
@@ -35,6 +36,7 @@ import {
MCP_INTEGRATIONS,
normalizeGrafanaBaseUrl,
type McpIntegration,
+ type EffectiveMcpIntegration,
type McpToolsListJsonRpcPayload,
parseMcpJsonRpcPayload,
} from '@roomote/types';
@@ -576,6 +578,107 @@ export function getCuratedIntegrationsAvailabilityCommand() {
};
}
+/** Resolve catalog metadata and actor-scoped state without exposing credentials. */
+export async function getEffectiveMcpIntegrationsCommand(
+ auth: UserAuthSuccess,
+): Promise {
+ const integrationIds = getMcpIntegrationIds();
+ const deploymentScopedIds = integrationIds.filter((id) =>
+ isDeploymentScopedMcpIntegration(id),
+ );
+ const userScopedIds = integrationIds.filter(
+ (id) => !isDeploymentScopedMcpIntegration(id),
+ );
+ const visibilityFilters = [
+ ...(deploymentScopedIds.length > 0
+ ? [
+ and(
+ isNull(mcpConnections.userId),
+ inArray(mcpConnections.mcpId, deploymentScopedIds),
+ ),
+ ]
+ : []),
+ ...(userScopedIds.length > 0
+ ? [
+ and(
+ eq(mcpConnections.userId, auth.userId),
+ inArray(mcpConnections.mcpId, userScopedIds),
+ ),
+ ]
+ : []),
+ ];
+ const [enablements, connections, oauthReadiness] = await Promise.all([
+ db.query.deploymentMcpEnablements.findMany({
+ where: inArray(deploymentMcpEnablements.mcpId, integrationIds),
+ columns: { mcpId: true, enabled: true },
+ }),
+ visibilityFilters.length > 0
+ ? db.query.mcpConnections.findMany({
+ where: or(...visibilityFilters),
+ orderBy: (table, { desc }) => [desc(table.createdAt)],
+ columns: {
+ mcpId: true,
+ enabled: true,
+ authStatus: true,
+ },
+ })
+ : Promise.resolve([]),
+ Promise.all(
+ MCP_INTEGRATIONS.map((integration) =>
+ getDeploymentStaticOauthReadiness(Env, integration),
+ ),
+ ),
+ ]);
+ const enabledById = new Map(
+ enablements.map((entry) => [entry.mcpId, entry.enabled]),
+ );
+ const connectionById = new Map();
+ for (const connection of connections) {
+ if (!connectionById.has(connection.mcpId)) {
+ connectionById.set(connection.mcpId, connection);
+ }
+ }
+ const available = !areCuratedIntegrationsDisabled(
+ Env.R_CURATED_INTEGRATIONS_DISABLED,
+ );
+
+ return MCP_INTEGRATIONS.map((integration, index) => {
+ const enabled = enabledById.get(integration.id) ?? false;
+ const connection = connectionById.get(integration.id);
+ const authStatus = connection?.enabled
+ ? (connection.authStatus ?? null)
+ : null;
+ const connected = authStatus === 'authenticated';
+ const serverMode = integration.serverMode ?? 'upstream_proxy';
+ const status = !available
+ ? 'unavailable'
+ : enabled
+ ? connected
+ ? 'connected'
+ : 'needs_connection'
+ : 'not_enabled';
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ description: integration.description,
+ icon: integration.icon,
+ connectionScope: getMcpIntegrationConnectionScope(integration),
+ connectionMode: getMcpIntegrationConnectionMode(integration),
+ serverMode,
+ available,
+ enabled,
+ authStatus,
+ oauthReadiness: oauthReadiness[index]!,
+ status,
+ capabilities: {
+ agentTools: serverMode !== 'credential_only',
+ toolManagement: serverMode === 'upstream_proxy',
+ },
+ } satisfies EffectiveMcpIntegration;
+ });
+}
+
/**
* Return public-safe OAuth setup status for integrations that require a
* deployment-configured client. Credential names and values never leave the
diff --git a/apps/web/src/trpc/commands/setup/setup-session.test.ts b/apps/web/src/trpc/commands/setup/setup-session.test.ts
index 37fb0c9c62..16945b6dfa 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.test.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts
@@ -19,7 +19,8 @@ vi.mock('@roomote/sdk/server', () => ({
buildFastAgentArtifactCreator: vi.fn(),
LINEAR_ORG_CONNECTION_ROLE: 'organization',
}));
-vi.mock('@roomote/cloud-agents/server', () => ({
+vi.mock('@roomote/cloud-agents/server', async (importOriginal) => ({
+ ...(await importOriginal()),
createFastAgentWebTaskLauncher: vi.fn(),
}));
vi.mock('@roomote/telemetry/server', () => ({ captureEvent: vi.fn() }));
@@ -126,7 +127,7 @@ describe('optional setup integration discovery', () => {
},
});
}
- async function continueDiscovery() {
+ async function continueDiscovery(answer = 'continue') {
const questions = await (
await context()
).adapterExtensions.resolveUserInputPreset!('setup_integrations');
@@ -150,7 +151,7 @@ describe('optional setup integration discovery', () => {
return submitSetupSessionUserInputCommand(auth, {
sessionId,
requestId: 'integrations',
- answers: { 'setup-integrations': { answers: ['Continue'] } },
+ answers: { 'setup-integrations': { answers: [answer] } },
});
}
@@ -212,7 +213,7 @@ describe('optional setup integration discovery', () => {
await db.delete(users).where(eq(users.id, auth.userId));
});
- it('continues without any connector or source connection and persists completion', async () => {
+ it('completes zero-match discovery server-side without a browser response', async () => {
mocks.getStatus.mockImplementation(async () => ({
setupNewState: await readState(),
setupCompletedAt: null,
@@ -223,10 +224,7 @@ describe('optional setup integration discovery', () => {
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(questions).toEqual([]);
expect(
(await readState()).setupSession?.integrationDiscoveryCompletedAt,
).toEqual(expect.any(String));
@@ -239,11 +237,7 @@ describe('optional setup integration discovery', () => {
.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'] } },
- });
+ expect(responses).toHaveLength(0);
await expect(
(await context()).adapterExtensions.resolveUserInputPreset!(
'setup_integrations',
@@ -251,7 +245,7 @@ describe('optional setup integration discovery', () => {
).rejects.toThrow('already complete');
});
- it('persists cancellation as an early skip while leaving final continuation optional', async () => {
+ it('persists cancellation as an early skip and completes an empty final match server-side', async () => {
await answeredCategory('communication', [], 'cancelled');
const snapshot = JSON.parse(
(await context()).setupSnapshot,
@@ -262,14 +256,11 @@ describe('optional setup integration discovery', () => {
matchedIntegrationIds: [],
});
await reconcileSetupPlatformEvents(auth);
- expect(mocks.schedule).not.toHaveBeenCalled();
+ expect(mocks.schedule).toHaveBeenCalledOnce();
const questions = await (
await context()
).adapterExtensions.resolveUserInputPreset!('setup_integrations');
- expect(questions[0]?.options?.map((option) => option.id)).toEqual([
- 'continue',
- ]);
- await continueDiscovery();
+ expect(questions).toEqual([]);
expect(
JSON.parse((await context()).setupSnapshot).integrationDiscovery
.completed,
@@ -321,6 +312,16 @@ describe('optional setup integration discovery', () => {
});
});
+ it('accepts the legacy continuation label for an existing setup card', async () => {
+ await answeredCategory('documents', ['Notion']);
+ await expect(continueDiscovery('Continue')).resolves.toEqual({
+ success: true,
+ });
+ expect(
+ (await readState()).setupSession?.integrationDiscoveryCompletedAt,
+ ).toEqual(expect.any(String));
+ });
+
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']);
@@ -402,7 +403,7 @@ describe('optional setup integration discovery', () => {
]);
});
- it('suppresses async setup events and starter choices during discovery without gating setup completion', async () => {
+ it('coalesces setup changes into one deterministic turn without discovery-first dropping', async () => {
expect(await reconcileSetupPlatformEvents(auth)).toBe(true);
expect(mocks.complete).toHaveBeenCalled();
expect(
@@ -410,7 +411,15 @@ describe('optional setup integration discovery', () => {
([turn]) =>
JSON.parse(turn.question.replace(/<\/?platform_event>/g, '')).type,
),
- ).toEqual(['session_creation']);
+ ).toEqual(['setup_state_changed']);
+ expect(
+ JSON.parse(
+ mocks.schedule.mock.calls[0]![0].question.replace(
+ /<\/?platform_event>/g,
+ '',
+ ),
+ ).changes.map((change: { type: string }) => change.type),
+ ).toEqual(['session_creation', 'source_connection', 'starter_request']);
await answeredCategory('documents', ['Notion']);
mocks.schedule.mockClear();
await reconcileSetupPlatformEvents(auth);
@@ -427,14 +436,13 @@ describe('optional setup integration discovery', () => {
fingerprint: 'test',
payload: {},
}),
- ).toEqual({ scheduled: false });
+ ).toEqual({ scheduled: true });
}
- expect(mocks.schedule).not.toHaveBeenCalled();
- await expect(
- (await context()).adapterExtensions.resolveUserInputPreset!(
- 'setup_starter_tasks',
- ),
- ).rejects.toThrow('optional tool discovery');
+ expect(mocks.schedule).toHaveBeenCalledTimes(6);
+ const starterQuestions = await (
+ await context()
+ ).adapterExtensions.resolveUserInputPreset!('setup_starter_tasks');
+ expect(starterQuestions).toHaveLength(1);
await continueDiscovery();
expect(
mocks.schedule.mock.calls.some(([turn]) =>
@@ -446,6 +454,7 @@ describe('optional setup integration discovery', () => {
).adapterExtensions.resolveUserInputPreset!('setup_starter_tasks');
expect(questions[0]?.options).toEqual(
SETUP_STARTER_TASKS.map((task) => ({
+ id: task.id,
label: task.title,
description: task.description,
})),
diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts
index a93156a476..cf86a48fcb 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
-import { type FastAgentTurnAdapter } from '@roomote/cloud-agents/server';
import { buildFastAgentArtifactCreator } from '@roomote/sdk/server';
+import { buildFastAgentSetupAdapter } from '@roomote/cloud-agents/server';
import {
and,
db,
@@ -34,6 +34,7 @@ import {
type AcpRequestUserInputAnswers,
type AcpRequestUserInputPayload,
type AutomationRecommendationBatch,
+ type FastAgentSetupTurnContext,
} from '@roomote/types';
import { captureEvent } from '@roomote/telemetry/server';
@@ -60,6 +61,7 @@ const SETUP_SESSION_ADVISORY_LOCK = 'setup-session';
const SETUP_SESSION_TITLE = 'Set up Roomote';
type SetupPlatformEventKind =
+ | 'setup_state_changed'
| 'session_creation'
| 'provider_selection'
| 'source_connection'
@@ -98,14 +100,6 @@ 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.');
}
@@ -263,6 +257,22 @@ async function resolveSetupSnapshot(auth: UserAuthSuccess): Promise {
});
}
+function buildSetupTurnContext(
+ conversation: SetupSessionConversation,
+ setupSnapshot: string,
+): FastAgentSetupTurnContext {
+ return {
+ sessionId: conversation.sessionId,
+ fastConversationId: conversation.fastConversationId,
+ setupSnapshot,
+ starterTaskOptions: SETUP_STARTER_TASKS.map((task) => ({
+ id: task.id,
+ label: task.title,
+ description: task.description,
+ })),
+ };
+}
+
async function readSetupIntegrationDiscovery(
auth: UserAuthSuccess,
suppliedAnswers: AcpRequestUserInputAnswers = {},
@@ -424,69 +434,6 @@ function deriveSetupRailMilestones(
};
}
-async function buildSetupSessionAdapterExtensions(
- auth: UserAuthSuccess,
-): Promise> {
- return {
- 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.');
- }
- await assertSetupStarterWorkReady(auth);
- return [
- {
- id: 'setup-starter-tasks',
- header: 'First work',
- question: 'What should Roomote work on first?',
- isOther: false,
- isSecret: false,
- multiple: true,
- options: SETUP_STARTER_TASKS.map((task) => ({
- label: task.title,
- description: task.description,
- })),
- },
- ];
- },
- assertTaskLaunch: () =>
- assertSetupStarterWorkReady(auth, {
- requireStarterSelection: true,
- requireCompute: true,
- }),
- };
-}
-
export async function scheduleSetupPlatformEvent(
auth: UserAuthSuccess,
input: {
@@ -511,9 +458,6 @@ async function buildSetupPlatformEventTurn(
prepared?: {
conversation: SetupSessionConversation;
setupSnapshot: string;
- integrationDiscovery: Awaited<
- ReturnType
- >;
},
): Promise[0] | null> {
assertAdmin(auth);
@@ -521,15 +465,6 @@ 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,
@@ -568,10 +503,12 @@ async function buildSetupPlatformEventTurn(
conversationId: conversation.fastConversationId,
turnId: currentMessageId,
},
- adapterExtensions: await buildSetupSessionAdapterExtensions(auth),
setupSession: true,
- setupSnapshot:
+ setupContext: buildSetupTurnContext(
+ conversation,
prepared?.setupSnapshot ?? (await resolveSetupSnapshot(auth)),
+ ),
+ durableSessionId: conversation.fastConversationId,
};
}
@@ -786,14 +723,27 @@ export async function reconcileSetupPlatformEvents(
{ allowAfterSetupCompletion: true },
);
- for (const event of events) {
- const turn = await buildSetupPlatformEventTurn(auth, event, {
- conversation,
- setupSnapshot,
- integrationDiscovery,
- });
- if (turn) scheduleWebFastAgentTurn(turn);
- }
+ const changes = events.map((event) => ({
+ type: event.kind,
+ ...event.payload,
+ }));
+ const fingerprint = createHash('sha256')
+ .update(JSON.stringify({ setupSnapshot, changes }))
+ .digest('hex')
+ .slice(0, 24);
+ const turn = await buildSetupPlatformEventTurn(
+ auth,
+ {
+ kind: 'setup_state_changed',
+ fingerprint,
+ payload: {
+ snapshot: JSON.parse(setupSnapshot),
+ changes,
+ },
+ },
+ { conversation, setupSnapshot },
+ );
+ if (turn) scheduleWebFastAgentTurn(turn);
return setupCompleted;
}
@@ -980,8 +930,12 @@ async function persistSetupPresetResponse(input: {
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
+ !(
+ [
+ SETUP_INTEGRATIONS_CONTINUE_OPTION.id,
+ SETUP_INTEGRATIONS_CONTINUE_OPTION.label,
+ ] as readonly string[]
+ ).includes(input.answers[SETUP_INTEGRATIONS_QUESTION_ID]!.answers[0]!)
) {
throw new Error('Continue with or without connecting tools.');
}
@@ -1141,9 +1095,9 @@ export async function submitSetupSessionUserInputCommand(
) {
throw new Error('This input request does not belong to the setup Session.');
}
+ const setupSnapshot = await resolveSetupSnapshot(auth);
return submitFastSessionUserInputCommand(auth, input, {
- adapterExtensions: await buildSetupSessionAdapterExtensions(auth),
- setupSnapshot: await resolveSetupSnapshot(auth),
+ setupContext: buildSetupTurnContext(setupConversation, setupSnapshot),
setupSession: true,
persistSetupPresetResponse: async (details) => {
const result = await persistSetupPresetResponse({ auth, ...details });
@@ -1166,9 +1120,12 @@ export async function resolveSetupSessionTurnContext(
)
return null;
assertAdmin(auth);
+ const setupSnapshot = await resolveSetupSnapshot(auth);
+ const setupContext = buildSetupTurnContext(conversation, setupSnapshot);
return {
- adapterExtensions: await buildSetupSessionAdapterExtensions(auth),
- setupSnapshot: await resolveSetupSnapshot(auth),
+ adapterExtensions: buildFastAgentSetupAdapter(setupContext),
+ setupSnapshot,
+ setupContext,
setupSession: true as const,
};
}
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index 5f9e1fe6e8..acd8d8179e 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -256,6 +256,7 @@ import {
} from '../commands/sandbox-session';
import {
getDeploymentMcpEnablementsCommand,
+ getEffectiveMcpIntegrationsCommand,
getCuratedIntegrationsAvailabilityCommand,
getMcpOauthReadinessCommand,
setDeploymentMcpEnabledCommand,
@@ -1938,6 +1939,10 @@ export const appRouter = createRouter({
getDeploymentMcpEnablementsCommand(auth),
),
+ effectiveIntegrations: protectedProcedure.query(({ ctx: { auth } }) =>
+ getEffectiveMcpIntegrationsCommand(auth),
+ ),
+
oauthReadiness: protectedProcedure.query(({ ctx: { auth } }) =>
getMcpOauthReadinessCommand(auth),
),
diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts
index 9dddfd3527..18e1e9b85d 100644
--- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts
@@ -699,7 +699,6 @@ describe('Fast conversation repository', () => {
messages: [visibleMessage],
}),
]);
-
const stored = await fastAgentConversationRepository.findById({
id: canonical.id,
});
@@ -757,10 +756,11 @@ describe('Fast conversation repository', () => {
source: 'slack',
};
- await Promise.all([
+ const claimResults = await Promise.all([
fastAgentConversationRepository.upsertMessage({
conversationId: session.id,
message: baseMessage,
+ insertOnly: true,
}),
fastAgentConversationRepository.upsertMessage({
conversationId: session.id,
@@ -768,8 +768,13 @@ describe('Fast conversation repository', () => {
...baseMessage,
contentBlocks: [{ type: 'text', text: 'Recovered' }],
},
+ insertOnly: true,
}),
]);
+ expect(claimResults.map((result) => result.inserted).sort()).toEqual([
+ false,
+ true,
+ ]);
const rows = await db
.select()
@@ -820,7 +825,7 @@ describe('Fast conversation repository', () => {
conversationId: session.id,
message: prompt('platform-event', 'platform_event'),
}),
- ).resolves.toEqual({ initialHumanTurn: false });
+ ).resolves.toMatchObject({ initialHumanTurn: false });
await expect(
fastAgentConversationRepository.upsertMessage({
conversationId: session.id,
@@ -830,25 +835,25 @@ describe('Fast conversation repository', () => {
FAST_AGENT_REACTION_INPUT_TYPE,
),
}),
- ).resolves.toEqual({ initialHumanTurn: false });
+ ).resolves.toMatchObject({ initialHumanTurn: false });
await expect(
fastAgentConversationRepository.upsertMessage({
conversationId: session.id,
message: prompt('first-human', 'human'),
}),
- ).resolves.toEqual({ initialHumanTurn: true });
+ ).resolves.toMatchObject({ initialHumanTurn: true });
await expect(
fastAgentConversationRepository.upsertMessage({
conversationId: session.id,
message: prompt('first-human', 'human'),
}),
- ).resolves.toEqual({ initialHumanTurn: true });
+ ).resolves.toMatchObject({ initialHumanTurn: true });
await expect(
fastAgentConversationRepository.upsertMessage({
conversationId: session.id,
message: prompt('later-human', 'human'),
}),
- ).resolves.toEqual({ initialHumanTurn: false });
+ ).resolves.toMatchObject({ initialHumanTurn: false });
});
it('lets only one concurrent human prompt claim the initial turn', async () => {
@@ -910,7 +915,7 @@ describe('Fast conversation repository', () => {
source: 'slack',
},
}),
- ).resolves.toEqual({ initialHumanTurn: false });
+ ).resolves.toMatchObject({ initialHumanTurn: false });
});
it('does not treat legacy platform-event history as a human turn', async () => {
@@ -950,7 +955,7 @@ describe('Fast conversation repository', () => {
source: 'slack',
},
}),
- ).resolves.toEqual({ initialHumanTurn: true });
+ ).resolves.toMatchObject({ initialHumanTurn: true });
});
it('reconciles a persisted legacy retry notice after its turn stops', async () => {
diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-integration-broker.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-integration-broker.test.ts
index 3d6adffc9e..589a1ad356 100644
--- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-integration-broker.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-integration-broker.test.ts
@@ -1,7 +1,12 @@
const mocks = vi.hoisted(() => ({
configuredServers: {} as Record<
string,
- { url: string; headers: Record; disabledTools?: string[] }
+ {
+ url: string;
+ headers: Record;
+ disabledTools?: string[];
+ cacheRevision?: string;
+ }
>,
createAuthToken: vi.fn(),
listMcpTools: vi.fn(),
@@ -1313,6 +1318,22 @@ describe('fast-agent integration broker', () => {
expect(mocks.listMcpTools).toHaveBeenCalledOnce();
});
+ it('rediscovers tools when the persisted integration revision changes', async () => {
+ mocks.configuredServers = {
+ notion: {
+ url: 'https://api.example.com/api/mcp/notion',
+ headers: {},
+ cacheRevision: '1',
+ },
+ };
+
+ await listFastAgentIntegrations(auditContext);
+ mocks.configuredServers.notion!.cacheRevision = '2';
+ await listFastAgentIntegrations(auditContext);
+
+ expect(mocks.listMcpTools).toHaveBeenCalledTimes(2);
+ });
+
it('does not share cached tool catalogs across acting users', async () => {
mocks.configuredServers = {
notion: {
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 9e57deea03..273acabcf5 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
@@ -1465,6 +1465,49 @@ describe('answerFastAgentQuestion native OpenCode tools', () => {
},
);
+ it('closes a server-completed setup preset without persisting a pending request', async () => {
+ let toolResult: unknown;
+ const requestUserInput = vi.fn();
+ const resolveUserInputPreset = vi.fn(async () => []);
+ mocks.generateText.mockImplementation(
+ async (_params, _session, options) => {
+ await options.onSessionReady('opencode-session-1');
+ toolResult = await invokeTool(nativeToolNames.requestUserInput, {
+ preset: 'setup_integrations',
+ });
+ 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(toolResult).toEqual({
+ success: true,
+ completed: true,
+ closed: true,
+ });
+ expect(requestUserInput).not.toHaveBeenCalled();
+ expect(mocks.upsertMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.objectContaining({
+ eventType: 'roomote_runtime.request_user_input',
+ }),
+ }),
+ );
+ });
+
it.each(['setup_starter_tasks', undefined])(
'rejects integration preferences outside their preset: %s',
async (preset) => {
diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-session.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-session.test.ts
index f386887697..002bc83de2 100644
--- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-session.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-session.test.ts
@@ -33,7 +33,7 @@ describe('upsertFastAgentMessage', () => {
await expect(
upsertFastAgentMessage({ sessionId: 'session-1', message }),
- ).resolves.toEqual({ initialHumanTurn: true });
+ ).resolves.toMatchObject({ initialHumanTurn: true });
expect(upsertMessageMock).toHaveBeenCalledTimes(2);
});
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts
index 00c8e12773..e5f596d83f 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts
@@ -67,6 +67,8 @@ export type FastAgentMessageWrite = Omit<
export type FastAgentMessageUpsertResult = {
initialHumanTurn: boolean;
+ /** True only for the transaction that created this canonical event row. */
+ inserted?: boolean;
};
export const INTERRUPTED_INFERENCE_RETRY_MESSAGE =
@@ -840,6 +842,7 @@ export interface FastAgentConversationRepository {
upsertMessage(input: {
conversationId: string;
message: FastAgentMessageWrite;
+ insertOnly?: boolean;
}): Promise;
/** `null` forgets the native session so the next turn rebuilds it. */
setOpenCodeSession(input: {
@@ -1236,7 +1239,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository =
});
},
- async upsertMessage({ conversationId: requestedId, message }) {
+ async upsertMessage({ conversationId: requestedId, message, insertOnly }) {
return db.transaction(async (tx) => {
const conversationId = await resolveCanonicalId(tx, requestedId);
await tx.execute(
@@ -1254,6 +1257,17 @@ export const fastAgentConversationRepository: FastAgentConversationRepository =
throw new Error('Fast conversation was not found.');
}
+ const [existingEvent] = await tx
+ .select({ id: fastAgentMessages.id })
+ .from(fastAgentMessages)
+ .where(
+ and(
+ eq(fastAgentMessages.conversationId, conversationId),
+ eq(fastAgentMessages.eventId, message.eventId),
+ ),
+ )
+ .limit(1);
+
const isSubstantiveHumanPrompt =
message.eventType === ACP_ENVELOPE_EVENT_TYPES.UserPrompt &&
message.role === 'user' &&
@@ -1307,10 +1321,18 @@ export const fastAgentConversationRepository: FastAgentConversationRepository =
(Boolean(currentHumanPrompt) || !hasCompatibilityHumanPrompt);
}
- await tx
+ const insert = tx
.insert(fastAgentMessages)
- .values({ conversationId, ...message })
- .onConflictDoUpdate({
+ .values({ conversationId, ...message });
+ if (insertOnly) {
+ await insert.onConflictDoNothing({
+ target: [
+ fastAgentMessages.conversationId,
+ fastAgentMessages.eventId,
+ ],
+ });
+ } else {
+ await insert.onConflictDoUpdate({
target: [
fastAgentMessages.conversationId,
fastAgentMessages.eventId,
@@ -1330,6 +1352,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository =
updatedAt: sql`now()`,
},
});
+ }
await tx
.update(fastAgentConversations)
.set({ updatedAt: sql`now()` })
@@ -1371,7 +1394,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository =
}
}
- return { initialHumanTurn };
+ return { initialHumanTurn, inserted: !existingEvent };
});
},
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 9f0245c228..8c7f3d61bd 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
@@ -162,6 +162,8 @@ export type FastAgentMcpServerConfig = {
url: string;
headers: Record;
disabledTools?: string[];
+ /** Opaque, non-secret revision used to invalidate process-local tool catalogs. */
+ cacheRevision?: string;
};
/** Structured input request issued with the Fast-native request_user_input tool. */
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-integration-broker.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-integration-broker.ts
index eee25ffca8..ba2ed12318 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-integration-broker.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-integration-broker.ts
@@ -501,7 +501,7 @@ export async function listFastAgentIntegrations(
...integration,
tools: (
await listCachedIntegrationTools({
- cacheKey: `${context.userId}:${integration.endpoint!.url}`,
+ cacheKey: `${context.userId}:${integration.endpoint!.url}:${configuredServers[integration.id]?.cacheRevision ?? ''}`,
url: integration.endpoint!.url,
headers: integration.endpoint!.headers,
})
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 93558f7ffb..4bef732acc 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
@@ -341,18 +341,16 @@ 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 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, 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. 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.
+- A useful default agenda is: understand the user's goals and optional tools, connect and synchronize source code, make a sandbox ready, then offer optional starter work. Follow the conversation: the user may skip optional discovery, answer several topics at once, or reorder the agenda. Do not restart answered discovery categories or revive the legacy communication question.
+- Optional integration discovery never gates setup completion. Use the snapshot's ordered categories as suggestions, not a questionnaire. Ask naturally, offer an early skip, and use stable question IDs \`setup-tools-\` for structured category questions. Finish or skip with the trusted \`setup_integrations\` preset, carrying prose answers by category ID. The server validates matches, canonicalizes options, and completes an empty match set without browser input.
+- Source control and a synchronized repository are required before setup completes or starter work is offered. A ready sandbox is required before selected work launches. State the missing capability plainly and let trusted setup controls handle configuration. Environment creation is out of scope.
+- When the snapshot has no starter selection and the current setup state makes starter work available, use the trusted \`setup_starter_tasks\` preset. The server owns its choices and validation; do not invent or repeat the catalog in prose. Starter work is optional and never gates setup completion.
+- A recorded starter selection is durable intent. When the current setup-state change includes selected starter tasks and the snapshot says the sandbox is ready, launch those catalog prompts with generic \`launch_task\`, no environment, and no model override unless the administrator requested one. Partial launch failure never reverses setup completion; name failures and continue with successful work.
+- Setup state-change events are coalesced current facts, not a fixed script. Reconcile the snapshot and listed changes, preserve any pending user decision, and continue with whichever useful setup action fits the conversation.
+- The renderer owns trusted controls. In 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 or locate cards, rails, dialogs, panels, buttons, presets, or setup steps. Never ask for credentials in chat.
- 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.
- In every user-visible setup reply, use ordinary language centered on the user's action and outcome. Say "Your repositories are ready" rather than "repositories synced"; say "Choose what you'd like me to work on first" rather than "choose the first work from the setup options"; and say "I need a workspace where I can run the work you selected" rather than "configure the sandbox provider." Explain what a sandbox means once only if that context helps the user understand why I need it, without referring to the interface.
-- Before \`launch_task\`, describe the work beginning in the user's terms. Do not expose repository-selection heuristics such as "most impactful repository" or narrate setup machinery. For example, say "I'm looking for flaky tests and fixing the ones causing the most trouble."
+- Describe launched work in the user's terms. Do not expose repository-selection heuristics or narrate setup machinery.
`
: ''
}
@@ -545,10 +543,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" }\` 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.
+ ? `- The setup-state-changed event contains the current snapshot and coalesced changes. Use those trusted facts without claiming that I made configuration changes myself. On the first useful turn, introduce myself and explain the next unmet user need in ordinary language.
`
: ''
}
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 cfd907d36e..ad62023d5d 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
@@ -4524,6 +4524,13 @@ export async function answerFastAgentQuestion({
args.setupIntegrationAnswers,
)
: await adapter.resolveUserInputPreset!(args.preset);
+ // Trusted setup presets may complete entirely server-side. In
+ // that case no pending request or browser response is needed.
+ if (preset && questions.length === 0) {
+ visibleUpdatePosted = true;
+ closedInstructionVersions.add(instructionVersion);
+ return { success: true, completed: true, closed: true };
+ }
for (const question of questions) {
if (question.options && question.isSecret) {
return {
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts
index 6c9a06d24b..9f2de331d7 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts
@@ -148,9 +148,11 @@ export async function appendFastAgentVisibleMessages({
export async function upsertFastAgentMessage({
sessionId,
message,
+ insertOnly,
}: {
sessionId: string;
message: FastAgentMessageWrite;
+ insertOnly?: boolean;
}): Promise {
let lastError: unknown;
@@ -159,6 +161,7 @@ export async function upsertFastAgentMessage({
return await fastAgentConversationRepository.upsertMessage({
conversationId: sessionId,
message,
+ insertOnly,
});
} catch (error) {
lastError = error;
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts
new file mode 100644
index 0000000000..3db4c36ed5
--- /dev/null
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts
@@ -0,0 +1,157 @@
+import { db, deploymentSettings, eq, sessions, sql } from '@roomote/db/server';
+import {
+ normalizeSetupNewState,
+ normalizeSetupNewSetupSession,
+ SETUP_INTEGRATIONS,
+ SETUP_INTEGRATIONS_CONTINUE_OPTION,
+ SETUP_INTEGRATIONS_QUESTION_ID,
+ matchSetupIntegrationAnswers,
+ type FastAgentSetupTurnContext,
+} from '@roomote/types';
+
+import type { FastAgentTurnAdapter } from './fast-agent-conversation';
+
+type SetupSnapshot = {
+ integrationDiscovery?: {
+ completed?: boolean;
+ matchedIntegrationIds?: string[];
+ };
+ rail?: {
+ compute?: string;
+ source?: string;
+ firstWork?: string;
+ };
+};
+
+function parseSetupSnapshot(context: FastAgentSetupTurnContext): SetupSnapshot {
+ try {
+ return JSON.parse(context.setupSnapshot) as SetupSnapshot;
+ } catch {
+ throw new Error('The setup snapshot is invalid.');
+ }
+}
+
+async function completeEmptySetupIntegrationDiscovery(
+ context: FastAgentSetupTurnContext,
+): Promise {
+ await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`SELECT pg_advisory_xact_lock(hashtext('setup-session'))`,
+ );
+ const [settings] = await tx
+ .select({ setupNewState: deploymentSettings.setupNewState })
+ .from(deploymentSettings)
+ .where(eq(deploymentSettings.id, 'default'))
+ .limit(1);
+ const state = normalizeSetupNewState(settings?.setupNewState ?? {});
+ const setupSession = normalizeSetupNewSetupSession(state.setupSession);
+ const [session] = setupSession
+ ? await tx
+ .select({ fastConversationId: sessions.fastConversationId })
+ .from(sessions)
+ .where(eq(sessions.id, setupSession.sessionId))
+ .limit(1)
+ : [];
+ if (
+ !setupSession ||
+ setupSession.sessionId !== context.sessionId ||
+ session?.fastConversationId !== context.fastConversationId
+ ) {
+ throw new Error('This request does not belong to the setup Session.');
+ }
+ // Missing means a legacy session that predates discovery and is already complete.
+ if (setupSession.integrationDiscoveryCompletedAt !== null) return;
+ await tx
+ .update(deploymentSettings)
+ .set({
+ setupNewState: {
+ ...state,
+ setupSession: {
+ ...setupSession,
+ integrationDiscoveryCompletedAt: new Date().toISOString(),
+ },
+ },
+ updatedAt: new Date(),
+ })
+ .where(eq(deploymentSettings.id, 'default'));
+ });
+}
+
+/** Rebuild trusted setup-only adapter behavior from durable, serializable data. */
+export function buildFastAgentSetupAdapter(
+ context: FastAgentSetupTurnContext,
+): Pick {
+ return {
+ resolveUserInputPreset: async (preset, setupIntegrationAnswers) => {
+ const snapshot = parseSetupSnapshot(context);
+ if (preset === 'setup_integrations') {
+ if (snapshot.integrationDiscovery?.completed) {
+ throw new Error('Optional tool discovery is already complete.');
+ }
+ const suppliedMatches = matchSetupIntegrationAnswers(
+ setupIntegrationAnswers ?? {},
+ ).matchedIntegrationIds;
+ const matchedIds = new Set([
+ ...(snapshot.integrationDiscovery?.matchedIntegrationIds ?? []),
+ ...suppliedMatches,
+ ]);
+ const options = SETUP_INTEGRATIONS.filter((integration) =>
+ matchedIds.has(integration.id),
+ ).map((integration) => ({
+ id: integration.id,
+ label: integration.name,
+ description: `Connect ${integration.name} in Settings.`,
+ }));
+ if (options.length === 0) {
+ await completeEmptySetupIntegrationDiscovery(context);
+ return [];
+ }
+ return [
+ {
+ id: SETUP_INTEGRATIONS_QUESTION_ID,
+ header: 'Your tools',
+ question:
+ 'Connect any useful tools, or continue without connections.',
+ isOther: false,
+ isSecret: false,
+ options: [...options, SETUP_INTEGRATIONS_CONTINUE_OPTION],
+ },
+ ];
+ }
+ if (preset !== 'setup_starter_tasks') {
+ throw new Error('Unsupported setup input preset.');
+ }
+ const rail = snapshot.rail;
+ if (rail?.source !== 'ready') {
+ throw new Error(
+ 'Connect source control and sync at least one repository before choosing or starting work.',
+ );
+ }
+ return [
+ {
+ id: 'setup-starter-tasks',
+ header: 'First work',
+ question: 'What should Roomote work on first?',
+ isOther: false,
+ isSecret: false,
+ multiple: true,
+ options: context.starterTaskOptions,
+ },
+ ];
+ },
+ assertTaskLaunch: async () => {
+ const rail = parseSetupSnapshot(context).rail;
+ if (rail?.source !== 'ready') {
+ throw new Error(
+ 'Connect source control and sync at least one repository before choosing or starting work.',
+ );
+ }
+ if (rail.firstWork !== 'ready') {
+ throw new Error('Choose your first work before starting a task.');
+ }
+ if (rail.compute !== 'ready') {
+ throw new Error('Set up a sandbox before starting work.');
+ }
+ },
+ };
+}
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 10e3b3326e..c7bfbc0f08 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
@@ -63,48 +63,24 @@ describe('setup prompt guidance and snapshot injection', () => {
"use ordinary language centered on the user's action and outcome",
);
expect(prompt).toContain('Your repositories are ready');
- expect(prompt).toContain(
- "I'm looking for flaky tests and fixing the ones causing the most trouble.",
- );
- expect(prompt).toContain(
- 'the administrator is free to start something new or explore the app while I work',
- );
- expect(prompt).toContain(
- 'do not imply that they need to wait in or remain on the setup session',
- );
+ expect(prompt).toContain("Describe launched work in the user's terms");
expect(prompt).toContain('');
expect(prompt).toContain('request_user_input');
expect(prompt).toContain('setup_starter_tasks');
expect(prompt).toContain('launch_task');
+ expect(prompt).toContain('The renderer owns trusted controls');
expect(prompt).toContain(
- 'The renderer owns presentation of trusted setup controls, but some controls require an explicit tool call from you',
- );
- expect(prompt).toContain(
- 'Keep those controls separate from my side of the conversation',
- );
- expect(prompt).toContain(
- 'Never name, locate, or instruct the user to interact with UI elements',
+ 'Never name or locate cards, rails, dialogs, panels, buttons, presets, or setup steps',
);
expect(prompt).toContain(
"state only the user's goal, the capability I need, the outcome that changed, or the decision the user needs to make",
);
- expect(prompt).toContain(
- 'Launch is deferred until the setup snapshot says',
- );
expect(prompt).toContain(
'I need a workspace where I can run the work you selected',
);
expect(prompt).toContain('Starter work is optional');
- expect(prompt).toContain(
- 'call `request_user_input` with exactly `{ preset: "setup_starter_tasks" }`',
- );
- expect(prompt).toContain('the server emits a starter-request setup event');
- expect(prompt).toContain(
- 'Do not send a closeout first: that tool call creates the user-visible first-work control and is the terminal response for the turn',
- );
- expect(prompt).toContain(
- 'Do not replace the tool call with prose asking the user to choose',
- );
+ expect(prompt).toContain('use the trusted `setup_starter_tasks` preset');
+ expect(prompt).not.toContain('exactly once');
expect(prompt).not.toContain(
'Direct the administrator to the relevant card',
);
@@ -117,33 +93,20 @@ describe('setup prompt guidance and snapshot injection', () => {
expect(prompt).not.toContain('update_plan');
});
- it('keeps discovery optional, ordered, resumable, and server-resolved', () => {
+ it('keeps discovery optional, resumable, reorderable, 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',
+ 'Optional integration discovery never gates setup completion',
+ 'ordered categories as suggestions, not a questionnaire',
+ 'reorder the agenda',
'setup-tools-',
- 'Offer skipping early',
- 'already supplied in prose',
- 'setupIntegrationAnswers',
- 'keyed by category IDs (not question IDs)',
- 'server exact-matches its catalog',
- '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',
- 'Existing starter selection or completed old setup means no restart',
- 'answeredCategoryIds',
- 'matchedIntegrationIds',
- 'unsupportedTools',
+ 'carrying prose answers by category ID',
+ 'completes an empty match set without browser input',
+ 'Do not restart answered discovery categories',
+ 'Setup state-change events are coalesced current facts',
])
expect(prompt).toContain(rule);
expect(prompt).not.toContain('Naturally ask about communication');
@@ -164,12 +127,7 @@ describe('setup prompt guidance and snapshot injection', () => {
});
expect(setupEvent).toContain('Setup Platform Event');
expect(setupEvent).toContain('Reconcile them against the setup snapshot');
- expect(setupEvent).toContain(
- 'For a starter-request event, call `request_user_input` exactly once',
- );
- expect(setupEvent).toContain(
- 'If any selected task started, say that the started work will continue while the user starts something new or explores the app',
- );
+ expect(setupEvent).not.toContain('starter-request event');
const inputResponseEvent = buildFastAgentSystemPrompt({
...baseInput,
diff --git a/packages/cloud-agents/src/server/fast-agent/index.ts b/packages/cloud-agents/src/server/fast-agent/index.ts
index d747be61e2..532b6e54d8 100644
--- a/packages/cloud-agents/src/server/fast-agent/index.ts
+++ b/packages/cloud-agents/src/server/fast-agent/index.ts
@@ -5,6 +5,7 @@ export * from './fast-agent-prompt';
export * from './fast-agent-reply-stream';
export * from './fast-agent-surface-reply-stream';
export * from './fast-agent-service';
+export * from './fast-agent-setup-context';
export * from './fast-agent-turn-lock';
export * from './fast-agent-turn-shutdown';
export * from './fast-agent-session';
diff --git a/packages/communication/src/__tests__/discord-request-user-input.test.ts b/packages/communication/src/__tests__/discord-request-user-input.test.ts
index 87ef40a97f..d9746a9746 100644
--- a/packages/communication/src/__tests__/discord-request-user-input.test.ts
+++ b/packages/communication/src/__tests__/discord-request-user-input.test.ts
@@ -5,6 +5,7 @@ import {
buildDiscordRequestUserInputButtons,
buildDiscordRequestUserInputCancelCallbackData,
buildDiscordRequestUserInputPromptText,
+ matchesDiscordRequestUserInputRequestToken,
parseDiscordRequestUserInputAnswerCallbackData,
parseDiscordRequestUserInputCancelCallbackData,
} from '../discord-request-user-input';
@@ -31,12 +32,20 @@ describe('discord request_user_input helpers', () => {
optionIndex: 2,
});
expect(customId.length).toBeLessThanOrEqual(100);
- expect(parseDiscordRequestUserInputAnswerCallbackData(customId)).toEqual({
+ const parsed = parseDiscordRequestUserInputAnswerCallbackData(customId);
+ expect(parsed).toEqual({
runId: 42,
questionIndex: 0,
optionIndex: 2,
- requestToken: 'callid12',
+ requestToken: expect.stringMatching(/^[a-f0-9]{24}$/u),
});
+ expect(
+ matchesDiscordRequestUserInputRequestToken(
+ 'rui:session:turn:callid12',
+ parsed!.requestToken,
+ ),
+ ).toBe(true);
+ expect(parsed!.requestToken).not.toBe('callid12');
});
it('round-trips cancel callback ids', () => {
@@ -44,10 +53,56 @@ describe('discord request_user_input helpers', () => {
runId: 7,
requestId: 'rui:session:turn:callid12',
});
- expect(parseDiscordRequestUserInputCancelCallbackData(customId)).toEqual({
+ const parsed = parseDiscordRequestUserInputCancelCallbackData(customId);
+ expect(parsed).toEqual({
runId: 7,
- requestToken: 'callid12',
+ requestToken: expect.stringMatching(/^[a-f0-9]{24}$/u),
});
+ expect(
+ matchesDiscordRequestUserInputRequestToken(
+ 'rui:session:turn:callid12',
+ parsed!.requestToken,
+ ),
+ ).toBe(true);
+ });
+
+ it('accepts legacy suffix tokens only when they match the request', () => {
+ expect(
+ matchesDiscordRequestUserInputRequestToken(
+ 'rui:session:turn:callid12',
+ 'callid12',
+ ),
+ ).toBe(true);
+ expect(
+ matchesDiscordRequestUserInputRequestToken(
+ 'rui:session:turn:callid12',
+ 'other-id',
+ ),
+ ).toBe(false);
+ expect(
+ parseDiscordRequestUserInputCancelCallbackData(
+ 'discord:rui_cancel:7:callid12',
+ ),
+ ).toEqual({ runId: 7, requestToken: 'callid12' });
+ });
+
+ it('keeps full-identity tokens within Discord custom_id limits', () => {
+ const customId = buildDiscordRequestUserInputAnswerCallbackData({
+ runId: Number.MAX_SAFE_INTEGER,
+ requestId: `rui:${'session-'.repeat(20)}:${'call-'.repeat(20)}`,
+ questionIndex: Number.MAX_SAFE_INTEGER,
+ optionIndex: Number.MAX_SAFE_INTEGER,
+ });
+
+ expect(customId.length).toBeLessThanOrEqual(100);
+ expect(
+ parseDiscordRequestUserInputAnswerCallbackData(customId),
+ ).not.toBeNull();
+ expect(
+ parseDiscordRequestUserInputAnswerCallbackData(
+ 'discord:rui:42:0:0:token-too-short',
+ ),
+ ).toBeNull();
});
it('builds option buttons and cancel for a single-question prompt', () => {
@@ -95,13 +150,14 @@ describe('discord request_user_input helpers', () => {
questions: [sampleQuestion, { ...sampleQuestion, id: 'q2' }],
},
});
- expect(buttons).toEqual([
- [
- {
- text: 'Cancel',
- callbackData: 'discord:rui_cancel:99:callid12',
- },
- ],
- ]);
+ expect(buttons?.[0]?.[0]?.text).toBe('Cancel');
+ expect(
+ parseDiscordRequestUserInputCancelCallbackData(
+ buttons?.[0]?.[0]?.callbackData,
+ ),
+ ).toEqual({
+ runId: 99,
+ requestToken: expect.stringMatching(/^[a-f0-9]{24}$/u),
+ });
});
});
diff --git a/packages/communication/src/__tests__/request-user-input.test.ts b/packages/communication/src/__tests__/request-user-input.test.ts
index 2797532bce..f01c9d443e 100644
--- a/packages/communication/src/__tests__/request-user-input.test.ts
+++ b/packages/communication/src/__tests__/request-user-input.test.ts
@@ -23,6 +23,71 @@ const { redisLists, redisMock, redisStrings } = vi.hoisted(() => {
del: vi.fn(async (key: string) => deleteKey(key)),
eval: vi.fn(
async (_script: string, keyCount: number, ...args: unknown[]) => {
+ if (keyCount === 1) {
+ const [pendingKey, requestId, runId] = args as [
+ string,
+ string,
+ string,
+ ];
+ const rawRequest = strings.get(pendingKey);
+ if (!rawRequest) {
+ return 0;
+ }
+ const pendingRequest = JSON.parse(rawRequest) as Record<
+ string,
+ unknown
+ >;
+ if (
+ (requestId !== '' && pendingRequest.requestId !== requestId) ||
+ (runId !== '' && String(pendingRequest.runId) !== runId)
+ ) {
+ return 0;
+ }
+ strings.delete(pendingKey);
+ return 1;
+ }
+
+ if (keyCount === 3) {
+ const [
+ pendingKey,
+ sourceQueueKey,
+ resumedQueueKey,
+ taskId,
+ sourceRunId,
+ resumedRunId,
+ ] = args as [string, string, string, string, string, string];
+ const rawRequest = strings.get(pendingKey);
+ if (!rawRequest) {
+ return 0;
+ }
+ const pendingRequest = JSON.parse(rawRequest) as Record<
+ string,
+ unknown
+ >;
+ if (
+ pendingRequest.taskId !== taskId ||
+ String(pendingRequest.runId) !== sourceRunId
+ ) {
+ return 0;
+ }
+
+ const queuedAnswers = lists.get(sourceQueueKey) ?? [];
+ for (const answer of queuedAnswers) {
+ pushListValue(resumedQueueKey, answer);
+ }
+ if (queuedAnswers.length > 0) {
+ lists.delete(sourceQueueKey);
+ }
+ strings.set(
+ pendingKey,
+ JSON.stringify({
+ ...pendingRequest,
+ runId: Number.parseInt(resumedRunId, 10),
+ }),
+ );
+ return 1;
+ }
+
if (keyCount !== 2) {
return 0;
}
@@ -120,6 +185,8 @@ import {
clearPendingCommunicationRequestUserInput,
getCommunicationRequestUserInputAnswers,
getPendingCommunicationRequestUserInput,
+ queueCommunicationRequestUserInputAnswer,
+ rebindPendingCommunicationRequestUserInputRun,
setPendingCommunicationRequestUserInput,
submitPendingCommunicationRequestUserInputAnswer,
} from '../request-user-input';
@@ -212,4 +279,102 @@ describe('communication request_user_input Redis helpers', () => {
answers: answer.answers,
});
});
+
+ it('atomically clears only the matching request and run', async () => {
+ await setPendingCommunicationRequestUserInput('discord', 'channel-1', {
+ requestId: 'request-new',
+ runId: 42,
+ taskId: 'task-1',
+ questions: [],
+ });
+
+ await expect(
+ clearPendingCommunicationRequestUserInput('discord', 'channel-1', {
+ requestId: 'request-old',
+ runId: 42,
+ }),
+ ).resolves.toBe(false);
+ await expect(
+ clearPendingCommunicationRequestUserInput('discord', 'channel-1', {
+ requestId: 'request-new',
+ runId: 41,
+ }),
+ ).resolves.toBe(false);
+ await expect(
+ getPendingCommunicationRequestUserInput('discord', 'channel-1'),
+ ).resolves.toMatchObject({ requestId: 'request-new', runId: 42 });
+
+ await expect(
+ clearPendingCommunicationRequestUserInput('discord', 'channel-1', {
+ requestId: 'request-new',
+ runId: 42,
+ }),
+ ).resolves.toBe(true);
+ await expect(
+ getPendingCommunicationRequestUserInput('discord', 'channel-1'),
+ ).resolves.toBeNull();
+ });
+
+ it('atomically rebinds a pending request and queued answers to a resumed run', async () => {
+ await setPendingCommunicationRequestUserInput('discord', 'channel-1', {
+ requestId: 'request-1',
+ runId: 42,
+ taskId: 'task-1',
+ questions: [],
+ });
+ await queueCommunicationRequestUserInputAnswer('discord', 42, {
+ requestId: 'request-1',
+ answers: {},
+ timestamp: 456,
+ });
+
+ await expect(
+ rebindPendingCommunicationRequestUserInputRun({
+ provider: 'discord',
+ conversationId: 'channel-1',
+ taskId: 'task-1',
+ sourceRunId: 42,
+ resumedRunId: 84,
+ }),
+ ).resolves.toBe(true);
+ await expect(
+ getPendingCommunicationRequestUserInput('discord', 'channel-1'),
+ ).resolves.toMatchObject({ requestId: 'request-1', runId: 84 });
+ await expect(
+ getCommunicationRequestUserInputAnswers('discord', 42),
+ ).resolves.toEqual([]);
+ await expect(
+ getCommunicationRequestUserInputAnswers('discord', 84),
+ ).resolves.toEqual([
+ { requestId: 'request-1', answers: {}, timestamp: 456 },
+ ]);
+ });
+
+ it('does not rebind a different task or the same run', async () => {
+ await setPendingCommunicationRequestUserInput('discord', 'channel-1', {
+ requestId: 'request-1',
+ runId: 42,
+ taskId: 'task-1',
+ questions: [],
+ });
+
+ await expect(
+ rebindPendingCommunicationRequestUserInputRun({
+ provider: 'discord',
+ conversationId: 'channel-1',
+ taskId: 'task-2',
+ sourceRunId: 42,
+ resumedRunId: 84,
+ }),
+ ).resolves.toBe(false);
+ await expect(
+ rebindPendingCommunicationRequestUserInputRun({
+ provider: 'discord',
+ conversationId: 'channel-1',
+ taskId: 'task-1',
+ sourceRunId: 42,
+ resumedRunId: 42,
+ }),
+ ).resolves.toBe(false);
+ });
});
diff --git a/packages/communication/src/discord-request-user-input.ts b/packages/communication/src/discord-request-user-input.ts
index 85c77af618..5447fc723a 100644
--- a/packages/communication/src/discord-request-user-input.ts
+++ b/packages/communication/src/discord-request-user-input.ts
@@ -1,3 +1,5 @@
+import { createHash } from 'node:crypto';
+
import type { AcpRequestUserInputQuestion } from '@roomote/types';
import type { CommunicationMessageButton } from './provider';
@@ -20,7 +22,14 @@ function questionAllowsCustomAnswer(
}
function requestToken(requestId: string): string {
- return requestId.slice(-8);
+ return createHash('sha256').update(requestId).digest('hex').slice(0, 24);
+}
+
+export function matchesDiscordRequestUserInputRequestToken(
+ requestId: string,
+ token: string,
+): boolean {
+ return token === requestToken(requestId) || token === requestId.slice(-8);
}
export function getDiscordRequestUserInputCurrentQuestion(params: {
@@ -70,9 +79,10 @@ export function parseDiscordRequestUserInputAnswerCallbackData(
optionIndex: number;
requestToken: string;
} | null {
- const match = /^discord:rui:(\d+):(\d+):(\d+):([A-Za-z0-9_-]{1,16})$/u.exec(
- value ?? '',
- );
+ const match =
+ /^discord:rui:(\d+):(\d+):(\d+):([a-f0-9]{24}|[A-Za-z0-9_-]{8})$/u.exec(
+ value ?? '',
+ );
if (!match) {
return null;
}
@@ -104,9 +114,10 @@ export function parseDiscordRequestUserInputAnswerCallbackData(
export function parseDiscordRequestUserInputCancelCallbackData(
value: string | undefined,
): { runId: number; requestToken: string } | null {
- const match = /^discord:rui_cancel:(\d+):([A-Za-z0-9_-]{1,16})$/u.exec(
- value ?? '',
- );
+ const match =
+ /^discord:rui_cancel:(\d+):([a-f0-9]{24}|[A-Za-z0-9_-]{8})$/u.exec(
+ value ?? '',
+ );
if (!match) {
return null;
}
diff --git a/packages/communication/src/request-user-input.ts b/packages/communication/src/request-user-input.ts
index b473949056..204f88bd4e 100644
--- a/packages/communication/src/request-user-input.ts
+++ b/packages/communication/src/request-user-input.ts
@@ -82,6 +82,70 @@ end
return 1
`;
+const CLEAR_PENDING_REQUEST_USER_INPUT_SCRIPT = `
+local rawRequest = redis.call('GET', KEYS[1])
+if not rawRequest then
+ return 0
+end
+
+local ok, pendingRequest = pcall(cjson.decode, rawRequest)
+if not ok then
+ return 0
+end
+
+if ARGV[1] ~= '' and pendingRequest['requestId'] ~= ARGV[1] then
+ return 0
+end
+
+if ARGV[2] ~= '' and tostring(pendingRequest['runId']) ~= ARGV[2] then
+ return 0
+end
+
+redis.call('DEL', KEYS[1])
+return 1
+`;
+
+const REBIND_PENDING_REQUEST_USER_INPUT_RUN_SCRIPT = `
+local rawRequest = redis.call('GET', KEYS[1])
+if not rawRequest then
+ return 0
+end
+
+local ok, pendingRequest = pcall(cjson.decode, rawRequest)
+if not ok then
+ return 0
+end
+
+if pendingRequest['taskId'] ~= ARGV[1] then
+ return 0
+end
+
+if tostring(pendingRequest['runId']) ~= ARGV[2] then
+ return 0
+end
+
+pendingRequest['runId'] = tonumber(ARGV[3])
+
+local queuedAnswers = redis.call('LRANGE', KEYS[2], 0, -1)
+for _, answer in ipairs(queuedAnswers) do
+ redis.call('RPUSH', KEYS[3], answer)
+end
+if #queuedAnswers > 0 then
+ redis.call('DEL', KEYS[2])
+ redis.call('EXPIRE', KEYS[3], tonumber(ARGV[5]))
+end
+
+redis.call(
+ 'SET',
+ KEYS[1],
+ cjson.encode(pendingRequest),
+ 'EX',
+ tonumber(ARGV[4])
+)
+
+return 1
+`;
+
function getPendingRequestKey(
provider: CommunicationProvider,
conversationId: string,
@@ -215,24 +279,46 @@ export async function getPendingCommunicationRequestUserInput(
export async function clearPendingCommunicationRequestUserInput(
provider: CommunicationProvider,
conversationId: string,
- options?: { requestId?: string },
+ options?: { requestId?: string; runId?: number },
): Promise {
- if (options?.requestId) {
- const existing = await getPendingCommunicationRequestUserInput(
- provider,
- conversationId,
- );
+ const redis = getRedis();
+ const result = await redis.eval(
+ CLEAR_PENDING_REQUEST_USER_INPUT_SCRIPT,
+ 1,
+ getPendingRequestKey(provider, conversationId),
+ options?.requestId ?? '',
+ options?.runId === undefined ? '' : String(options.runId),
+ );
+ return result === 1;
+}
- if (!existing || existing.requestId !== options.requestId) {
- return false;
- }
+/** Atomically move a pending prompt and any queued answers to a resumed run. */
+export async function rebindPendingCommunicationRequestUserInputRun(params: {
+ provider: CommunicationProvider;
+ conversationId: string;
+ taskId: string;
+ sourceRunId: number;
+ resumedRunId: number;
+}): Promise {
+ if (params.sourceRunId === params.resumedRunId) {
+ return false;
}
const redis = getRedis();
- const deleted = await redis.del(
- getPendingRequestKey(provider, conversationId),
+ const result = await redis.eval(
+ REBIND_PENDING_REQUEST_USER_INPUT_RUN_SCRIPT,
+ 3,
+ getPendingRequestKey(params.provider, params.conversationId),
+ getAnswerQueueKey(params.provider, params.sourceRunId),
+ getAnswerQueueKey(params.provider, params.resumedRunId),
+ params.taskId,
+ String(params.sourceRunId),
+ String(params.resumedRunId),
+ String(PENDING_REQUEST_TTL_SECONDS),
+ String(ANSWER_QUEUE_TTL_SECONDS),
);
- return deleted > 0;
+
+ return result === 1;
}
export async function markPendingCommunicationRequestUserInputSubmitted(
diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts
index ada6cfab2a..f9b62d7850 100644
--- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts
+++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
acquireRootBindingLock: vi.fn(),
releaseRootBindingLock: vi.fn(),
answerQuestion: vi.fn(),
+ buildSetupAdapter: vi.fn(() => ({ assertTaskLaunch: vi.fn() })),
createLauncher: vi.fn(),
launchTask: vi.fn(),
findSession: vi.fn(),
@@ -135,6 +136,7 @@ vi.mock(
vi.mock('@roomote/cloud-agents/server', () => ({
acquireFastAgentTurnLock: mocks.acquireTurnLock,
answerFastAgentQuestion: mocks.answerQuestion,
+ buildFastAgentSetupAdapter: mocks.buildSetupAdapter,
resolveApiBaseUrl: () => 'https://roomote.example.com',
fastAgentConversationRepository: {
findById: mocks.findSession,
@@ -699,6 +701,12 @@ describe('deliverFastAgentParentEvent', () => {
platformEventKind: 'setup',
platformEventVisibility: 'required',
setupSession: true,
+ setupContext: {
+ sessionId: 'session-1',
+ fastConversationId: parent.sessionId,
+ setupSnapshot: '{"rail":{"source":"ready"}}',
+ starterTaskOptions: [],
+ },
},
resumedAfterInterruption: true,
durableAdmission: { eventId: 'row-2' },
@@ -724,6 +732,10 @@ describe('deliverFastAgentParentEvent', () => {
platformEventKind: 'setup',
platformEventVisibility: 'required',
setupSession: true,
+ setupSnapshot: '{"rail":{"source":"ready"}}',
+ adapter: expect.objectContaining({
+ assertTaskLaunch: expect.any(Function),
+ }),
resumedAfterInterruption: true,
durableAdmission: { eventId: 'row-2' },
}),
diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts
index 599aca6b13..d9b192e386 100644
--- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts
+++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts
@@ -4,6 +4,7 @@ import { basename } from 'node:path';
import {
acquireFastAgentTurnLock,
answerFastAgentQuestion,
+ buildFastAgentSetupAdapter,
createFastAgentTaskLauncher,
createFastAgentWebTaskLauncher,
fastAgentConversationRepository,
@@ -2568,6 +2569,9 @@ export async function deliverFastAgentParentEventWithLock(
...(humanFollowUp?.input ? { input: humanFollowUp.input } : {}),
...(humanFollowUp?.setupSession ? { setupSession: true } : {}),
...(humanFollowUp?.voiceMode ? { voiceMode: true } : {}),
+ ...(humanFollowUp?.setupContext
+ ? { setupSnapshot: humanFollowUp.setupContext.setupSnapshot }
+ : {}),
...(humanFollowUp
? { currentDurableHumanFollowUpEventId: humanFollowUp.eventId }
: {}),
@@ -2628,6 +2632,9 @@ export async function deliverFastAgentParentEventWithLock(
createArtifact: buildFastAgentArtifactCreator(params.parent.sessionId),
...parentTurn.adapter,
launchTask: parentTurn.adapter.launchTask,
+ ...(humanFollowUp?.setupContext
+ ? buildFastAgentSetupAdapter(humanFollowUp.setupContext)
+ : {}),
...(wakeupGuard
? {
postReply: wakeupGuard.guardPostReply(
diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts
index 7f8828dee1..28b7259f19 100644
--- a/packages/sdk/src/server/routers/mcp-connections.test.ts
+++ b/packages/sdk/src/server/routers/mcp-connections.test.ts
@@ -210,6 +210,7 @@ function buildJoinedConnectionRow({
return {
enabledMcpId: mcpId,
disabledTools,
+ enablementUpdatedAt: new Date('2026-03-13T00:00:00.000Z'),
connection: {
id,
userId,
@@ -217,6 +218,7 @@ function buildJoinedConnectionRow({
enabled: true,
authConfig: resolvedAuthConfig,
createdAt: new Date('2026-03-12T00:00:00.000Z'),
+ updatedAt: new Date('2026-03-12T00:00:00.000Z'),
},
};
}
@@ -224,6 +226,7 @@ function buildJoinedConnectionRow({
function buildEnabledOnlyRow(mcpId: string) {
return {
enabledMcpId: mcpId,
+ enablementUpdatedAt: new Date('2026-03-13T00:00:00.000Z'),
connection: null,
};
}
@@ -282,6 +285,15 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => {
expect(result.servers.notion?.disabledTools).toEqual(['search']);
});
+ it('includes a non-secret cache revision for Fast server resolution', async () => {
+ const result = await resolveUserMcpServerConfigs({
+ userId: 'owner-user',
+ apiBaseUrl: 'https://api.preview.roomote.run',
+ });
+
+ expect(result.notion?.cacheRevision).toBe('1773360000000:1773273600000');
+ });
+
it('delivers the Brain when an explicit Brain provider key is configured', async () => {
mockEnv.R_GBRAIN_URL = 'http://gbrain:8931';
mockIsBrainEnabled.mockResolvedValue(true);
diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts
index 2de03b3855..82b40a2aa7 100644
--- a/packages/sdk/src/server/routers/mcp-connections.ts
+++ b/packages/sdk/src/server/routers/mcp-connections.ts
@@ -68,6 +68,7 @@ type ResolvedMcpServerConfig = {
url: string;
headers: Record;
disabledTools?: string[];
+ cacheRevision?: string;
};
type ResolvedMcpServerConfigs = Record;
@@ -95,6 +96,7 @@ async function resolveMcpServerConfigs(options: {
auth: Parameters[0];
requestOrigin: string | null;
includeRoomoteMemberTools?: boolean;
+ includeCacheRevision?: boolean;
quiet?: boolean;
}): Promise {
const logInfo: InfoLogger = options.quiet ? () => {} : console.info;
@@ -136,6 +138,12 @@ async function resolveMcpServerConfigs(options: {
};
}
+ if (!options.includeCacheRevision) {
+ for (const server of Object.values(servers)) {
+ delete server.cacheRevision;
+ }
+ }
+
logInfo('[getMcpServerConfigs] Final resolved server keys:', [
...Object.keys(servers),
]);
@@ -152,6 +160,7 @@ export async function resolveUserMcpServerConfigs(options: {
auth: { userId: options.userId },
requestOrigin: getRequestOrigin({ url: options.apiBaseUrl }),
includeRoomoteMemberTools: options.includeRoomoteMemberTools,
+ includeCacheRevision: true,
// This runs on every Fast turn; the per-connection info stream is worker
// config-fetch debugging noise at that frequency.
quiet: true,
@@ -323,13 +332,14 @@ async function buildCustomMcpServerConfigs(
continue;
}
+ let connectionUpdatedAt: Date | undefined;
if (row.authType === 'oauth') {
const connection = await db.query.mcpConnections.findFirst({
where: and(
eq(mcpConnections.mcpId, customMcpConnectionId(row.id)),
isNull(mcpConnections.userId),
),
- columns: { authStatus: true },
+ columns: { authStatus: true, updatedAt: true },
});
if (connection?.authStatus !== 'authenticated') {
@@ -338,6 +348,7 @@ async function buildCustomMcpServerConfigs(
);
continue;
}
+ connectionUpdatedAt = connection.updatedAt;
}
const proxyPath = `${CUSTOM_MCP_PROXY_PATH_PREFIX}${row.id}`;
@@ -345,6 +356,7 @@ async function buildCustomMcpServerConfigs(
servers[row.name] = {
url: requestOrigin ? `${requestOrigin}${proxyPath}` : proxyPath,
headers: { 'X-MCP-Client': PRODUCT_NAME },
+ cacheRevision: `${row.updatedAt?.getTime() ?? 0}:${connectionUpdatedAt?.getTime() ?? ''}`,
};
}
@@ -387,6 +399,7 @@ async function buildCuratedMcpServerConfigs(ctx: {
.select({
enabledMcpId: deploymentMcpEnablements.mcpId,
disabledTools: deploymentMcpEnablements.disabledTools,
+ enablementUpdatedAt: deploymentMcpEnablements.updatedAt,
connection: mcpConnections,
})
.from(deploymentMcpEnablements)
@@ -421,6 +434,12 @@ async function buildCuratedMcpServerConfigs(ctx: {
});
const servers: ResolvedMcpServerConfigs = {};
+ const revisionByMcpId = new Map(
+ enabledConnections.map((entry) => [
+ entry.enabledMcpId,
+ `${entry.enablementUpdatedAt.getTime()}:${entry.connection?.updatedAt.getTime() ?? ''}`,
+ ]),
+ );
const requestOrigin = ctx.requestOrigin;
for (const connection of connections) {
@@ -605,5 +624,9 @@ async function buildCuratedMcpServerConfigs(ctx: {
}
}
+ for (const [mcpId, server] of Object.entries(servers)) {
+ server.cacheRevision = revisionByMcpId.get(mcpId);
+ }
+
return servers;
}
diff --git a/packages/types/src/acp-request-user-input.test.ts b/packages/types/src/acp-request-user-input.test.ts
index d4d3efb40a..44b504bd3c 100644
--- a/packages/types/src/acp-request-user-input.test.ts
+++ b/packages/types/src/acp-request-user-input.test.ts
@@ -1,10 +1,12 @@
import {
getAcpRequestUserInputValidationError,
+ normalizeAcpRequestUserInputAnswers,
parseAcpRequestUserInputAnswers,
parseAcpRequestUserInputPayload,
parseAcpRequestUserInputQuestion,
parseAcpRequestUserInputRequestParams,
parseAcpRequestUserInputResponsePayload,
+ resolveAcpRequestUserInputAnswer,
} from './acp';
const singleQuestion = {
@@ -125,6 +127,41 @@ describe('request_user_input multi-select payloads', () => {
).toBeUndefined();
});
+ it('canonicalizes trusted option IDs while accepting legacy labels', () => {
+ const question = {
+ ...singleQuestion,
+ options: [
+ { id: 'fast', label: 'Fast', description: 'Run fast' },
+ {
+ id: 'thorough',
+ label: 'Thorough',
+ description: 'Run thoroughly',
+ },
+ ],
+ };
+ expect(
+ getAcpRequestUserInputValidationError([question], {
+ mode: { answers: ['fast'] },
+ }),
+ ).toBeNull();
+ expect(
+ normalizeAcpRequestUserInputAnswers([question], {
+ mode: { answers: ['Fast'] },
+ }),
+ ).toEqual({ mode: { answers: ['fast'] } });
+ expect(resolveAcpRequestUserInputAnswer(question, 'Fast')).toBe('fast');
+ expect(resolveAcpRequestUserInputAnswer(question, '2')).toBe('thorough');
+ });
+
+ it('preserves labels for legacy options without IDs', () => {
+ expect(
+ normalizeAcpRequestUserInputAnswers([singleQuestion], {
+ mode: { answers: ['Fast'] },
+ }),
+ ).toEqual({ mode: { answers: ['Fast'] } });
+ expect(resolveAcpRequestUserInputAnswer(singleQuestion, '1')).toBe('Fast');
+ });
+
it('parses answers and response payloads without multi-select changes', () => {
const answers = parseAcpRequestUserInputAnswers({
mode: { answers: ['Fast'] },
diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts
index f7280ca144..9b217fbdab 100644
--- a/packages/types/src/acp.ts
+++ b/packages/types/src/acp.ts
@@ -213,11 +213,13 @@ export function getAcpRequestUserInputValidationError(
return 'This question accepts a single answer.';
}
if (question.options?.length) {
- const optionLabels = new Set(
- question.options.map((option) => option.label),
+ const optionValues = new Set(
+ question.options.flatMap((option) =>
+ option.id ? [option.id, option.label] : [option.label],
+ ),
);
const customAnswerCount = submitted.filter(
- (answer) => !optionLabels.has(answer),
+ (answer) => !optionValues.has(answer),
).length;
if (customAnswerCount > (question.isOther ? 1 : 0)) {
return 'One or more selections are not valid options.';
@@ -227,6 +229,34 @@ export function getAcpRequestUserInputValidationError(
return null;
}
+/** Normalize trusted option selections to stable IDs while accepting labels
+ * persisted or submitted by clients from before option IDs were available. */
+export function normalizeAcpRequestUserInputAnswers(
+ questions: AcpRequestUserInputQuestion[],
+ answers: AcpRequestUserInputAnswers,
+): AcpRequestUserInputAnswers {
+ const questionsById = new Map(
+ questions.map((question) => [question.id, question]),
+ );
+ return Object.fromEntries(
+ Object.entries(answers).map(([questionId, response]) => {
+ const question = questionsById.get(questionId);
+ return [
+ questionId,
+ {
+ answers: response.answers.map((answer) => {
+ const option = question?.options?.find(
+ (candidate) =>
+ candidate.id === answer || candidate.label === answer,
+ );
+ return option?.id ?? answer;
+ }),
+ },
+ ];
+ }),
+ );
+}
+
export interface AcpRequestUserInputRequestParams {
sessionId: string;
turnId: string;
@@ -521,7 +551,9 @@ function resolveAcpRequestUserInputAnswerDetailed(
if (optionIndex >= 0 && optionIndex < question.options.length) {
return {
- answer: question.options[optionIndex]!.label,
+ answer:
+ question.options[optionIndex]!.id ??
+ question.options[optionIndex]!.label,
viaOtherFallback: false,
};
}
@@ -530,12 +562,17 @@ function resolveAcpRequestUserInputAnswerDetailed(
const normalizedAnswer = normalizeAcpRequestUserInputOptionLabel(answer);
const exactMatch = question.options.find(
(option) =>
+ normalizeAcpRequestUserInputOptionLabel(option.id ?? '') ===
+ normalizedAnswer ||
normalizeAcpRequestUserInputOptionLabel(option.label) ===
- normalizedAnswer,
+ normalizedAnswer,
);
if (exactMatch) {
- return { answer: exactMatch.label, viaOtherFallback: false };
+ return {
+ answer: exactMatch.id ?? exactMatch.label,
+ viaOtherFallback: false,
+ };
}
const partialMatches = question.options.filter((option) =>
@@ -545,7 +582,10 @@ function resolveAcpRequestUserInputAnswerDetailed(
);
if (partialMatches.length === 1) {
- return { answer: partialMatches[0]!.label, viaOtherFallback: false };
+ return {
+ answer: partialMatches[0]!.id ?? partialMatches[0]!.label,
+ viaOtherFallback: false,
+ };
}
if (question.isOther) {
diff --git a/packages/types/src/fast-agent.ts b/packages/types/src/fast-agent.ts
index 6384aca364..635a21e138 100644
--- a/packages/types/src/fast-agent.ts
+++ b/packages/types/src/fast-agent.ts
@@ -245,6 +245,23 @@ export const fastAgentPlatformEventVisibilitySchema = z.enum([
'required',
]);
+export const fastAgentSetupTurnContextSchema = z.object({
+ sessionId: z.string().min(1),
+ fastConversationId: z.string().min(1),
+ setupSnapshot: z.string().min(1),
+ starterTaskOptions: z.array(
+ z.object({
+ id: z.string().min(1),
+ label: z.string().min(1),
+ description: z.string(),
+ }),
+ ),
+});
+
+export type FastAgentSetupTurnContext = z.infer<
+ typeof fastAgentSetupTurnContextSchema
+>;
+
export const fastAgentHumanFollowUpEventSchema = z.object({
type: z.literal(FAST_AGENT_HUMAN_FOLLOW_UP_EVENT_TYPE),
eventId: z.string().min(1),
@@ -308,6 +325,9 @@ export const fastAgentHumanFollowUpEventSchema = z.object({
* so a resumed run must keep that framing.
*/
voiceMode: z.boolean().optional(),
+ /** Serializable setup context used to rebuild trusted setup capabilities
+ * when an admitted web turn resumes in another process. */
+ setupContext: fastAgentSetupTurnContextSchema.optional(),
});
export type FastAgentHumanFollowUpEvent = z.infer<
diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts
index 02a2a7baad..5f62a57098 100644
--- a/packages/types/src/mcp-oauth.ts
+++ b/packages/types/src/mcp-oauth.ts
@@ -400,6 +400,38 @@ export type McpIntegrationServerMode =
| 'native'
| 'credential_only';
+export type EffectiveMcpIntegrationStatus =
+ | 'unavailable'
+ | 'not_enabled'
+ | 'needs_connection'
+ | 'connected';
+
+export type McpIntegrationOauthReadiness =
+ | 'not_required'
+ | 'ready'
+ | 'missing'
+ | 'partial';
+
+/** Public-safe, actor-scoped integration state for product UI. */
+export type EffectiveMcpIntegration = {
+ id: string;
+ name: string;
+ description: string;
+ icon: string;
+ connectionScope: 'user' | 'deployment';
+ connectionMode: McpIntegrationConnectionMode;
+ serverMode: McpIntegrationServerMode;
+ available: boolean;
+ enabled: boolean;
+ authStatus: 'pending' | 'authenticated' | 'error' | null;
+ oauthReadiness: McpIntegrationOauthReadiness;
+ status: EffectiveMcpIntegrationStatus;
+ capabilities: {
+ agentTools: boolean;
+ toolManagement: boolean;
+ };
+};
+
export type McpIntegrationCategory = 'memory';
export type McpIntegrationOAuthClientEnv = {
From 7c267b15303c372500fb5f9a2370afd2f33b00e6 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 10 Sep 2026 16:06:40 +0000
Subject: [PATCH 04/23] fix: keep guided interaction responses reliable
---
.../FastSessionTranscript.client.test.tsx | 72 ++++++++++
.../[sessionId]/FastSessionTranscript.tsx | 40 ++++--
.../trpc/commands/fast-sessions/index.test.ts | 94 +++++++++++--
.../src/trpc/commands/fast-sessions/index.ts | 102 +++++++++-----
.../types/src/acp-request-user-input.test.ts | 125 ++++++++++++++++++
packages/types/src/acp.ts | 7 +-
6 files changed, 381 insertions(+), 59 deletions(-)
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 d67ec08e65..0d04ac461d 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
@@ -819,6 +819,78 @@ describe('FastSessionTranscript', () => {
expect(screen.queryByText('Choose a path')).toBeNull();
});
+ it.each([
+ ['failed', 'Failed to Ask for'],
+ ['completed', 'Asked for'],
+ ] as const)(
+ 'keeps a %s request_user_input tool row when no interaction card was persisted',
+ (status, actionLabel) => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(actionLabel)).toBeInTheDocument();
+ expect(screen.getByText('human guidance')).toBeInTheDocument();
+ if (status === 'failed') {
+ expect(screen.getByText('Failed')).toBeInTheDocument();
+ } else {
+ expect(screen.getByText('Completed')).toBeInTheDocument();
+ }
+ expect(screen.queryByText('Structured input request')).toBeNull();
+ },
+ );
+
it('places a pending interaction at its chronological position', () => {
const request = {
...textMessage({
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index f42ad44095..7783722855 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -110,7 +110,10 @@ function getTranscriptMessageText(message: TranscriptMessage) {
: text;
}
-function isRequestUserInputToolMessage(message: TranscriptMessage) {
+function shouldSuppressRequestUserInputToolMessage(
+ message: TranscriptMessage,
+ requestTurnIds: ReadonlySet,
+) {
if (
message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCall &&
message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCallUpdate &&
@@ -122,10 +125,15 @@ function isRequestUserInputToolMessage(message: TranscriptMessage) {
const payload = message.payload as {
toolName?: unknown;
title?: unknown;
+ status?: unknown;
} | null;
- return (
+ const isRequestUserInput =
payload?.toolName === 'request_user_input' ||
- payload?.title === 'request_user_input'
+ payload?.title === 'request_user_input';
+ return (
+ isRequestUserInput &&
+ payload?.status !== 'failed' &&
+ requestTurnIds.has(message.turnId)
);
}
@@ -737,19 +745,26 @@ export function FastSessionTranscript({
}) ?? null
);
}, [messages, pendingInputRequest]);
- const requestUserInputById = useMemo(() => {
+ const { requestUserInputById, requestUserInputTurnIds } = useMemo(() => {
const requests = new Map<
string,
NonNullable>
>();
+ const turnIds = new Set();
for (const message of messages) {
if (message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) {
continue;
}
const request = parseAcpRequestUserInputPayload(message.payload);
- if (request) requests.set(request.requestId, request);
+ if (request) {
+ requests.set(request.requestId, request);
+ turnIds.add(request.turnId);
+ }
}
- return requests;
+ return {
+ requestUserInputById: requests,
+ requestUserInputTurnIds: turnIds,
+ };
}, [messages]);
const { persistedBeforeInput, persistedAfterInput } = useMemo(() => {
const before: AcpUiMessage[] = [];
@@ -761,7 +776,10 @@ export function FastSessionTranscript({
(message.payload as { taskNavigation?: unknown } | null)
?.taskNavigation === true) ||
message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInput ||
- isRequestUserInputToolMessage(message)
+ shouldSuppressRequestUserInputToolMessage(
+ message,
+ requestUserInputTurnIds,
+ )
) {
continue;
}
@@ -843,7 +861,13 @@ export function FastSessionTranscript({
persistedBeforeInput: before,
persistedAfterInput: after,
};
- }, [messages, owner, pendingInputRequestOrder, requestUserInputById]);
+ }, [
+ messages,
+ owner,
+ pendingInputRequestOrder,
+ requestUserInputById,
+ requestUserInputTurnIds,
+ ]);
const hasVisibleAssistantMessage = useMemo(
() =>
messages.some(
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 29defe4f78..e48c300a64 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.test.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.test.ts
@@ -395,7 +395,7 @@ describe('setup context on ordinary Fast session input', () => {
expect(mocks.after).not.toHaveBeenCalled();
});
- it('treats a duplicate saved setup category response as successful without scheduling twice', async () => {
+ it('recovers a saved response when the original process died before scheduling', async () => {
const saved = {
eventId: 'response-event',
payload: {
@@ -409,7 +409,7 @@ describe('setup context on ordinary Fast session input', () => {
};
mocks.dbSelectLimit
.mockResolvedValueOnce([request])
- .mockResolvedValueOnce([saved]);
+ .mockResolvedValueOnce([]);
mocks.resolveSetupContext.mockResolvedValue({
...setupContext,
setupSnapshot: freshSnapshot,
@@ -418,26 +418,92 @@ describe('setup context on ordinary Fast session input', () => {
setupSnapshot: freshSnapshot,
},
});
+ const scheduled: Array<() => Promise> = [];
+ mocks.after.mockImplementation((callback) => {
+ scheduled.push(callback);
+ });
+
+ // The first request persists the response, then its process dies before
+ // the registered callback gets a chance to admit or run the turn.
await submitFastSessionUserInputCommand(auth, input);
- expect(mocks.upsertMessage).not.toHaveBeenCalled();
- expect(mocks.after).not.toHaveBeenCalled();
+ expect(mocks.upsertMessage).toHaveBeenCalledOnce();
+ expect(scheduled).toHaveLength(1);
+
+ mocks.dbSelectLimit
+ .mockResolvedValueOnce([request])
+ .mockResolvedValueOnce([saved]);
+ await submitFastSessionUserInputCommand(auth, {
+ ...input,
+ answers: {
+ 'setup-tools-documents': { answers: ['Different retry value'] },
+ },
+ });
+ expect(mocks.upsertMessage).toHaveBeenCalledOnce();
+ expect(scheduled).toHaveLength(2);
+
+ mocks.dbSelectLimit.mockResolvedValueOnce([]);
+ await scheduled[1]?.();
+
+ expect(mocks.answerQuestion).toHaveBeenCalledOnce();
+ expect(mocks.answerQuestion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ question: `${JSON.stringify({ requestId: input.requestId, answers: input.answers })} `,
+ currentMessageId: `input-response:${input.requestId}`,
+ setupSnapshot: freshSnapshot,
+ }),
+ );
});
- it('does not schedule when another generic response-row claimant won', async () => {
+ it('collapses contending response claimants to one completed turn', async () => {
mocks.dbSelectLimit
.mockResolvedValueOnce([request])
- .mockResolvedValueOnce([]);
- mocks.upsertMessage.mockResolvedValueOnce({
- initialHumanTurn: false,
- inserted: false,
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([request])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([
+ {
+ payload: {
+ requestId: input.requestId,
+ sessionId: session.id,
+ turnId: request.turnId,
+ callId: input.requestId,
+ answers: input.answers,
+ resolution: 'submitted',
+ },
+ },
+ ]);
+ mocks.upsertMessage
+ .mockResolvedValueOnce({ initialHumanTurn: false, inserted: true })
+ .mockResolvedValueOnce({ initialHumanTurn: false, inserted: false });
+ const scheduled: Array<() => Promise> = [];
+ mocks.after.mockImplementation((callback) => {
+ scheduled.push(callback);
});
- await expect(
- submitFastSessionUserInputCommand(auth, input),
- ).resolves.toEqual({ success: true });
+ // Model two requests that both completed their pre-insert read before the
+ // database selected one response-row winner. Neither callback runs yet.
+ await submitFastSessionUserInputCommand(auth, input);
+ await submitFastSessionUserInputCommand(auth, {
+ ...input,
+ answers: {
+ 'setup-tools-documents': { answers: ['Losing response'] },
+ },
+ });
- expect(mocks.upsertMessage).toHaveBeenCalledOnce();
- expect(mocks.after).not.toHaveBeenCalled();
+ expect(mocks.upsertMessage).toHaveBeenCalledTimes(2);
+ expect(scheduled).toHaveLength(2);
+
+ mocks.dbSelectLimit.mockResolvedValueOnce([]);
+ await scheduled[0]?.();
+ mocks.dbSelectLimit.mockResolvedValueOnce([{ id: 'terminal-response' }]);
+ await scheduled[1]?.();
+
+ expect(mocks.answerQuestion).toHaveBeenCalledOnce();
+ expect(mocks.answerQuestion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ question: `${JSON.stringify({ requestId: input.requestId, answers: input.answers })} `,
+ }),
+ );
});
it('routes final presets through setup-specific persistence, not ordinary response writes', async () => {
diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts
index 6979fe6e76..1471de4758 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.ts
@@ -47,6 +47,7 @@ import {
isSetupIntegrationDiscoveryQuestionId,
parseAcpRequestUserInputAnswers,
parseAcpRequestUserInputPayload,
+ parseAcpRequestUserInputResponsePayload,
normalizeAcpRequestUserInputAnswers,
type AcpRequestUserInputAnswers,
type AcpRequestUserInputPayload,
@@ -870,6 +871,17 @@ export async function submitFastSessionUserInputCommand(
if (validationError) {
throw new Error(validationError);
}
+ if (requestPayload.preset && existingResponse) {
+ return { success: true };
+ }
+ const savedResponse = existingResponse
+ ? parseAcpRequestUserInputResponsePayload(existingResponse.payload)
+ : null;
+ if (existingResponse && !savedResponse) {
+ throw new Error('This input response is no longer valid.');
+ }
+ let responseAnswers = savedResponse?.answers ?? submitted;
+ let responseResolution = savedResponse?.resolution ?? resolution;
const scheduleResponseTurn = async (
answers: AcpRequestUserInputAnswers,
@@ -937,10 +949,6 @@ export async function submitFastSessionUserInputCommand(
});
};
- if (existingResponse) {
- return { success: true };
- }
-
const responseEventId = `${request.eventId}:response`;
if (requestPayload.preset) {
if (setupContext && !options.persistSetupPresetResponse) {
@@ -960,41 +968,63 @@ export async function submitFastSessionUserInputCommand(
});
return { success: true };
}
- const responseClaim = await upsertFastAgentMessage({
- sessionId: session.id,
- insertOnly: true,
- message: {
- eventId: responseEventId,
- turnId: request.turnId,
- turnSeq: 2_000_000_000,
- ts: Date.now(),
- eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
- role: 'user',
- contentBlocks: [
- {
- type: 'text' as const,
- text: formatRequestUserInputResponseText(requestPayload, {
- answers: submitted,
- resolution,
- }),
- },
- ],
- metadata: { visibleInTranscript: true },
- payload: {
- requestId: input.requestId,
- sessionId: session.id,
+ if (!existingResponse) {
+ const responseClaim = await upsertFastAgentMessage({
+ sessionId: session.id,
+ insertOnly: true,
+ message: {
+ eventId: responseEventId,
turnId: request.turnId,
- callId: input.requestId,
- answers: submitted,
- resolution,
+ turnSeq: 2_000_000_000,
+ ts: Date.now(),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
+ role: 'user',
+ contentBlocks: [
+ {
+ type: 'text' as const,
+ text: formatRequestUserInputResponseText(requestPayload, {
+ answers: responseAnswers,
+ resolution: responseResolution,
+ }),
+ },
+ ],
+ metadata: { visibleInTranscript: true },
+ payload: {
+ requestId: input.requestId,
+ sessionId: session.id,
+ turnId: request.turnId,
+ callId: input.requestId,
+ answers: responseAnswers,
+ resolution: responseResolution,
+ },
+ source: 'web',
},
- source: 'web',
- },
- });
-
- if (responseClaim?.inserted !== false) {
- await scheduleResponseTurn(submitted, resolution);
+ });
+ if (responseClaim?.inserted === false) {
+ const [winningResponse] = await db
+ .select({ payload: fastAgentMessages.payload })
+ .from(fastAgentMessages)
+ .where(
+ and(
+ eq(fastAgentMessages.conversationId, session.id),
+ eq(fastAgentMessages.eventId, responseEventId),
+ ),
+ )
+ .limit(1);
+ const winningPayload = parseAcpRequestUserInputResponsePayload(
+ winningResponse?.payload ?? null,
+ );
+ if (!winningPayload) {
+ throw new Error('This input response is no longer valid.');
+ }
+ responseAnswers = winningPayload.answers;
+ responseResolution = winningPayload.resolution;
+ }
}
+ // A retry may be the first process that survives long enough to register
+ // `after()`. Re-admit the deterministic turn on every accepted submission;
+ // the durable event key and terminal-output check collapse contenders.
+ await scheduleResponseTurn(responseAnswers, responseResolution);
return { success: true };
}
diff --git a/packages/types/src/acp-request-user-input.test.ts b/packages/types/src/acp-request-user-input.test.ts
index 44b504bd3c..a896637513 100644
--- a/packages/types/src/acp-request-user-input.test.ts
+++ b/packages/types/src/acp-request-user-input.test.ts
@@ -1,4 +1,5 @@
import {
+ formatRequestUserInputResponseText,
getAcpRequestUserInputValidationError,
normalizeAcpRequestUserInputAnswers,
parseAcpRequestUserInputAnswers,
@@ -189,3 +190,127 @@ describe('request_user_input multi-select payloads', () => {
).toBeNull();
});
});
+
+describe('request_user_input response transcript formatting', () => {
+ const request = {
+ requestId: 'r',
+ sessionId: 's',
+ turnId: 't',
+ callId: 'c',
+ status: 'pending' as const,
+ questions: [
+ {
+ ...singleQuestion,
+ isOther: true,
+ options: [
+ { id: 'fast', label: 'Fast', description: 'Run fast' },
+ {
+ id: 'thorough',
+ label: 'Thorough',
+ description: 'Run thoroughly',
+ },
+ ],
+ },
+ ],
+ };
+
+ it('renders a known option ID as its label without changing the response', () => {
+ const response = {
+ resolution: 'submitted' as const,
+ answers: { mode: { answers: ['fast'] } },
+ };
+
+ expect(formatRequestUserInputResponseText(request, response)).toBe('Fast');
+ expect(response.answers.mode.answers).toEqual(['fast']);
+ });
+
+ it('preserves unknown custom text and legacy label or index values', () => {
+ expect(
+ formatRequestUserInputResponseText(request, {
+ resolution: 'submitted',
+ answers: { mode: { answers: ['Use balanced mode'] } },
+ }),
+ ).toBe('Use balanced mode');
+ expect(
+ formatRequestUserInputResponseText(request, {
+ resolution: 'submitted',
+ answers: { mode: { answers: ['Fast'] } },
+ }),
+ ).toBe('Fast');
+ expect(
+ formatRequestUserInputResponseText(request, {
+ resolution: 'submitted',
+ answers: { mode: { answers: ['1'] } },
+ }),
+ ).toBe('1');
+ });
+
+ it('renders the setup continuation option as Continue', () => {
+ expect(
+ formatRequestUserInputResponseText(
+ {
+ ...request,
+ questions: [
+ {
+ ...singleQuestion,
+ options: [
+ {
+ id: 'continue',
+ label: 'Continue',
+ description: 'Continue setup.',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ resolution: 'submitted',
+ answers: { mode: { answers: ['continue'] } },
+ },
+ ),
+ ).toBe('Continue');
+ });
+
+ it('renders multi-select option IDs as a comma-separated label list', () => {
+ expect(
+ formatRequestUserInputResponseText(
+ {
+ ...request,
+ questions: [
+ {
+ ...singleQuestion,
+ multiple: true,
+ options: [
+ { id: 'slack', label: 'Slack', description: 'Connect Slack' },
+ {
+ id: 'notion',
+ label: 'Notion',
+ description: 'Connect Notion',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ resolution: 'submitted',
+ answers: { mode: { answers: ['slack', 'notion'] } },
+ },
+ ),
+ ).toBe('Slack, Notion');
+ });
+
+ it('continues to mask secret answers before resolving option labels', () => {
+ expect(
+ formatRequestUserInputResponseText(
+ {
+ ...request,
+ questions: [{ ...request.questions[0]!, isSecret: true }],
+ },
+ {
+ resolution: 'submitted',
+ answers: { mode: { answers: ['fast'] } },
+ },
+ ),
+ ).toBe('[hidden]');
+ });
+});
diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts
index 9b217fbdab..489da31ee5 100644
--- a/packages/types/src/acp.ts
+++ b/packages/types/src/acp.ts
@@ -2537,8 +2537,13 @@ export function getAnswerDisplayValue(
return '[hidden]';
}
+ const optionLabelsById = new Map(
+ question?.options?.flatMap((option) =>
+ option.id ? [[option.id, option.label] as const] : [],
+ ) ?? [],
+ );
const joined = answers
- .map((answer) => answer.trim())
+ .map((answer) => (optionLabelsById.get(answer) ?? answer).trim())
.filter(Boolean)
.join(', ');
From ccf3b2ad216bdb1994c8b0160614838f24ca7449 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 10 Sep 2026 16:16:39 +0000
Subject: [PATCH 05/23] fix: recover setup preset continuation
---
.../trpc/commands/fast-sessions/index.test.ts | 42 +++++++++++++++++++
.../src/trpc/commands/fast-sessions/index.ts | 8 +++-
2 files changed, 48 insertions(+), 2 deletions(-)
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 e48c300a64..d5c205313a 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,7 @@ const mocks = vi.hoisted(() => ({
dbSelect: vi.fn(),
dbInnerJoin: vi.fn(),
dbSelectLimit: vi.fn(),
+ reconcileSetupEvents: vi.fn(),
resolveSetupContext: vi.fn().mockResolvedValue(null),
submitSetupInput: vi.fn(),
upsertMessage: vi.fn(),
@@ -88,6 +89,7 @@ vi.mock('./pinned-launch', () => ({
}));
vi.mock('../setup/setup-session', () => ({
+ reconcileSetupPlatformEvents: mocks.reconcileSetupEvents,
resolveSetupSessionTurnContext: mocks.resolveSetupContext,
submitSetupSessionUserInputCommand: mocks.submitSetupInput,
}));
@@ -542,6 +544,46 @@ describe('setup context on ordinary Fast session input', () => {
expect(mocks.after).not.toHaveBeenCalled();
});
+ it('reconciles an already-persisted setup preset before returning success', 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([{ eventId: 'response-event', payload: {} }]);
+
+ await expect(
+ submitFastSessionUserInputCommand(auth, {
+ ...input,
+ answers: { 'setup-integrations': { answers: ['Continue'] } },
+ }),
+ ).resolves.toEqual({ success: true });
+
+ expect(mocks.reconcileSetupEvents).toHaveBeenCalledOnce();
+ expect(mocks.reconcileSetupEvents).toHaveBeenCalledWith(auth);
+ expect(mocks.submitSetupInput).not.toHaveBeenCalled();
+ expect(mocks.upsertMessage).not.toHaveBeenCalled();
+ });
+
it('checks setup admin ownership before an ordinary response is persisted', async () => {
mocks.resolveSetupContext.mockRejectedValue(new Error('Unauthorized'));
await expect(
diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts
index 1471de4758..9862be4d2b 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.ts
@@ -810,8 +810,11 @@ export async function submitFastSessionUserInputCommand(
if (!session) {
throw new Error('Fast session not found');
}
- const { resolveSetupSessionTurnContext, submitSetupSessionUserInputCommand } =
- await import('../setup/setup-session');
+ const {
+ reconcileSetupPlatformEvents,
+ 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);
@@ -872,6 +875,7 @@ export async function submitFastSessionUserInputCommand(
throw new Error(validationError);
}
if (requestPayload.preset && existingResponse) {
+ if (setupContext) await reconcileSetupPlatformEvents(auth);
return { success: true };
}
const savedResponse = existingResponse
From b35806b3c7a33000f001dbfd9197c51484d752e2 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 10 Sep 2026 17:14:55 +0000
Subject: [PATCH 06/23] fix: continue setup after guided responses
---
.../trpc/commands/fast-sessions/index.test.ts | 12 +++++++
.../src/trpc/commands/fast-sessions/index.ts | 10 +++++-
.../trpc/commands/setup/setup-session.test.ts | 12 +++++++
.../src/trpc/commands/setup/setup-session.ts | 34 ++++++++++++++-----
.../fast-agent/fast-agent-setup-context.ts | 4 +++
5 files changed, 62 insertions(+), 10 deletions(-)
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 d5c205313a..107d0da71a 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.test.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.test.ts
@@ -429,6 +429,18 @@ describe('setup context on ordinary Fast session input', () => {
// the registered callback gets a chance to admit or run the turn.
await submitFastSessionUserInputCommand(auth, input);
expect(mocks.upsertMessage).toHaveBeenCalledOnce();
+ expect(mocks.upsertMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.objectContaining({
+ metadata: expect.objectContaining({
+ visibleInTranscript: true,
+ userId: 'user-1',
+ userName: 'User One',
+ userEmail: 'user@example.com',
+ }),
+ }),
+ }),
+ );
expect(scheduled).toHaveLength(1);
mocks.dbSelectLimit
diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts
index 9862be4d2b..e2335cf7f9 100644
--- a/apps/web/src/trpc/commands/fast-sessions/index.ts
+++ b/apps/web/src/trpc/commands/fast-sessions/index.ts
@@ -992,7 +992,15 @@ export async function submitFastSessionUserInputCommand(
}),
},
],
- metadata: { visibleInTranscript: true },
+ metadata: {
+ visibleInTranscript: true,
+ userId: auth.userId,
+ ...(auth.name ? { userName: auth.name } : {}),
+ ...(auth.primaryEmail ? { userEmail: auth.primaryEmail } : {}),
+ ...(auth.resource?.imageUrl
+ ? { userImageUrl: auth.resource.imageUrl }
+ : {}),
+ },
payload: {
requestId: input.requestId,
sessionId: session.id,
diff --git a/apps/web/src/trpc/commands/setup/setup-session.test.ts b/apps/web/src/trpc/commands/setup/setup-session.test.ts
index 16945b6dfa..138eb077fd 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.test.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts
@@ -225,6 +225,13 @@ describe('optional setup integration discovery', () => {
await context()
).adapterExtensions.resolveUserInputPreset!('setup_integrations');
expect(questions).toEqual([]);
+ expect(mocks.schedule).toHaveBeenCalledOnce();
+ expect(mocks.schedule).toHaveBeenCalledWith(
+ expect.objectContaining({
+ platformEventKind: 'setup',
+ setupSession: true,
+ }),
+ );
expect(
(await readState()).setupSession?.integrationDiscoveryCompletedAt,
).toEqual(expect.any(String));
@@ -320,6 +327,11 @@ describe('optional setup integration discovery', () => {
expect(
(await readState()).setupSession?.integrationDiscoveryCompletedAt,
).toEqual(expect.any(String));
+ const [response] = await db
+ .select({ metadata: fastAgentMessages.metadata })
+ .from(fastAgentMessages)
+ .where(eq(fastAgentMessages.eventId, 'event:integrations:response'));
+ expect(response?.metadata).toMatchObject({ userId: auth.userId });
});
it('resumes persisted category answers and exactly matches catalog options in homepage order', async () => {
diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts
index cf86a48fcb..d001f11622 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -465,6 +465,9 @@ async function buildSetupPlatformEventTurn(
prepared?.conversation ?? (await findSetupSessionConversation(auth));
if (!conversation) return null;
+ const setupSnapshot =
+ prepared?.setupSnapshot ?? (await resolveSetupSnapshot(auth));
+ const setupContext = buildSetupTurnContext(conversation, setupSnapshot);
const currentMessageId = buildSetupEventTurnId({
sessionId: conversation.sessionId,
workflowVersion: conversation.workflowVersion,
@@ -504,10 +507,12 @@ async function buildSetupPlatformEventTurn(
turnId: currentMessageId,
},
setupSession: true,
- setupContext: buildSetupTurnContext(
- conversation,
- prepared?.setupSnapshot ?? (await resolveSetupSnapshot(auth)),
- ),
+ setupContext,
+ adapterExtensions: buildFastAgentSetupAdapter(setupContext, {
+ onIntegrationDiscoveryCompleted: async () => {
+ await reconcileSetupPlatformEvents(auth);
+ },
+ }),
durableSessionId: conversation.fastConversationId,
};
}
@@ -1037,10 +1042,17 @@ async function persistSetupPresetResponse(input: {
}),
},
],
- // The transcript client needs this control event to resolve and remove
- // the input card. FastSessionTranscript filters response event types
- // from rendered chat, so it remains visually hidden.
- metadata: { visibleInTranscript: true },
+ metadata: {
+ visibleInTranscript: true,
+ userId: input.auth.userId,
+ ...(input.auth.name ? { userName: input.auth.name } : {}),
+ ...(input.auth.primaryEmail
+ ? { userEmail: input.auth.primaryEmail }
+ : {}),
+ ...(input.auth.resource?.imageUrl
+ ? { userImageUrl: input.auth.resource.imageUrl }
+ : {}),
+ },
payload: {
requestId: input.request.payload.requestId,
sessionId: input.fastConversationId,
@@ -1123,7 +1135,11 @@ export async function resolveSetupSessionTurnContext(
const setupSnapshot = await resolveSetupSnapshot(auth);
const setupContext = buildSetupTurnContext(conversation, setupSnapshot);
return {
- adapterExtensions: buildFastAgentSetupAdapter(setupContext),
+ adapterExtensions: buildFastAgentSetupAdapter(setupContext, {
+ onIntegrationDiscoveryCompleted: async () => {
+ await reconcileSetupPlatformEvents(auth);
+ },
+ }),
setupSnapshot,
setupContext,
setupSession: true as const,
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts
index 3db4c36ed5..b57bd1779e 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts
@@ -80,6 +80,9 @@ async function completeEmptySetupIntegrationDiscovery(
/** Rebuild trusted setup-only adapter behavior from durable, serializable data. */
export function buildFastAgentSetupAdapter(
context: FastAgentSetupTurnContext,
+ lifecycle: {
+ onIntegrationDiscoveryCompleted?: () => Promise;
+ } = {},
): Pick {
return {
resolveUserInputPreset: async (preset, setupIntegrationAnswers) => {
@@ -104,6 +107,7 @@ export function buildFastAgentSetupAdapter(
}));
if (options.length === 0) {
await completeEmptySetupIntegrationDiscovery(context);
+ await lifecycle.onIntegrationDiscoveryCompleted?.();
return [];
}
return [
From 8d709ce690a1778895fcc7ee0bfff5fc43a922b1 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 10 Sep 2026 17:26:00 +0000
Subject: [PATCH 07/23] fix: resume zero-match setup discovery
---
.../lib/fast-agent-parent-event-queue.test.ts | 65 +++++++++++++++++++
.../lib/fast-agent-parent-event-queue.ts | 58 +++++++++++++++++
.../src/server/lib/fast-agent-parent-event.ts | 11 +++-
3 files changed, 133 insertions(+), 1 deletion(-)
diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event-queue.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event-queue.test.ts
index 61c8e435d3..2a07a28a01 100644
--- a/packages/sdk/src/server/lib/fast-agent-parent-event-queue.test.ts
+++ b/packages/sdk/src/server/lib/fast-agent-parent-event-queue.test.ts
@@ -789,6 +789,71 @@ describe('Fast parent event durable queue', () => {
);
});
+ it('durably enqueues setup continuation after resumed zero-match discovery', async () => {
+ const setupEvent = {
+ type: 'human_follow_up' as const,
+ eventId: 'setup-state-1',
+ currentMessageId: 'setup-state-1',
+ userId: 'user-1',
+ question:
+ '{"type":"setup_state_changed"} ',
+ turnSource: 'platform_event' as const,
+ platformEventKind: 'setup' as const,
+ platformEventVisibility: 'required' as const,
+ setupSession: true,
+ setupContext: {
+ sessionId: parent.sessionId,
+ fastConversationId: parent.sessionId,
+ setupSnapshot: JSON.stringify({
+ integrationDiscovery: { completed: false },
+ rail: { source: 'ready' },
+ }),
+ starterTaskOptions: [],
+ },
+ };
+ const row = {
+ ...pendingRow('setup-inline', setupEvent),
+ admission: 'inline' as const,
+ claimedUntil: null,
+ retryAt: null,
+ inferenceRetries: 0,
+ };
+ mocks.findPending
+ .mockResolvedValueOnce(row)
+ .mockResolvedValueOnce(row)
+ .mockResolvedValueOnce({ deliveredAt: new Date(), discardedAt: null })
+ .mockResolvedValueOnce(undefined);
+ mocks.deliver.mockImplementationOnce(async (params) => {
+ await params.onSetupIntegrationDiscoveryCompleted();
+ return 'delivered';
+ });
+
+ await drainFastAgentParentEvents({
+ conversationId: parent.sessionId,
+ eventKey: row.eventKey,
+ });
+
+ expect(mocks.insertValues).toHaveBeenCalledWith(
+ expect.objectContaining({
+ parent,
+ event: expect.objectContaining({
+ eventId: 'setup-state-1:integration-discovery-completed',
+ currentMessageId: 'setup-state-1:integration-discovery-completed',
+ setupContext: expect.objectContaining({
+ setupSnapshot: expect.stringContaining('"completed":true'),
+ }),
+ }),
+ }),
+ );
+ expect(mocks.queueAdd).toHaveBeenCalledWith(
+ 'deliver',
+ expect.objectContaining({ conversationId: parent.sessionId }),
+ expect.objectContaining({
+ jobId: expect.any(String),
+ }),
+ );
+ });
+
it('leaves scheduled retries alone until their time', async () => {
mocks.findPending.mockResolvedValueOnce(undefined);
diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event-queue.ts b/packages/sdk/src/server/lib/fast-agent-parent-event-queue.ts
index e53e885d74..eb2ec6b9d2 100644
--- a/packages/sdk/src/server/lib/fast-agent-parent-event-queue.ts
+++ b/packages/sdk/src/server/lib/fast-agent-parent-event-queue.ts
@@ -27,6 +27,7 @@ import {
RunStatus,
exitedRunStatuses,
type FastAgentParent,
+ type FastAgentHumanFollowUpEvent,
} from '@roomote/types';
import {
@@ -43,6 +44,46 @@ export type FastAgentParentEventQueueRequest = {
conversationId: string;
eventKey: string;
};
+
+function buildSetupDiscoveryCompletedEvent(
+ event: FastAgentHumanFollowUpEvent,
+): FastAgentHumanFollowUpEvent | null {
+ if (!event.setupContext) return null;
+ const snapshot = JSON.parse(event.setupContext.setupSnapshot) as Record<
+ string,
+ unknown
+ >;
+ const discovery =
+ snapshot.integrationDiscovery &&
+ typeof snapshot.integrationDiscovery === 'object' &&
+ !Array.isArray(snapshot.integrationDiscovery)
+ ? (snapshot.integrationDiscovery as Record)
+ : {};
+ const nextSnapshot = {
+ ...snapshot,
+ integrationDiscovery: { ...discovery, completed: true },
+ };
+ const eventId = `${event.eventId}:integration-discovery-completed`;
+ return {
+ type: 'human_follow_up',
+ eventId,
+ currentMessageId: eventId,
+ userId: event.userId,
+ question: `${JSON.stringify({
+ type: 'setup_state_changed',
+ snapshot: nextSnapshot,
+ changes: [{ type: 'integration_discovery_completed' }],
+ })} `,
+ turnSource: 'platform_event',
+ platformEventKind: 'setup',
+ platformEventVisibility: 'required',
+ setupSession: true,
+ setupContext: {
+ ...event.setupContext,
+ setupSnapshot: JSON.stringify(nextSnapshot),
+ },
+ };
+}
type FastAgentPullRequestOpenedEvent = Extract<
FastAgentParentEvent,
{ type: 'pull_request_opened' }
@@ -391,6 +432,10 @@ export async function drainFastAgentParentEvents(
conversationId: request.conversationId,
eventKey: row.eventKey,
};
+ const durableSetupEvent =
+ row.event.type === 'human_follow_up' && row.event.setupContext
+ ? row.event
+ : null;
if (row.admission === 'inline') {
// Bind the row to the lock the way the inline surfaces do, so a
// process shutdown that aborts this turn before it reaches its own
@@ -430,6 +475,19 @@ export async function drainFastAgentParentEvents(
wakeFastAgentParentEventAt(wakeRequest, retryAt),
}
: {}),
+ ...(durableSetupEvent
+ ? {
+ onSetupIntegrationDiscoveryCompleted: async () => {
+ const continuation =
+ buildSetupDiscoveryCompletedEvent(durableSetupEvent);
+ if (!continuation) return;
+ await enqueueFastAgentParentEvent({
+ parent: row.parent,
+ event: continuation,
+ });
+ },
+ }
+ : {}),
},
turnLock,
);
diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts
index d9b192e386..bcd7564f20 100644
--- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts
+++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts
@@ -2330,6 +2330,8 @@ type FastAgentParentEventDeliveryParams = {
* immediately after an interruption, or at a scheduled retry time. */
requestDurableResume?: () => Promise;
requestDurableRetry?: (retryAt: Date) => Promise;
+ /** Schedule the next setup state turn after a server-only preset completion. */
+ onSetupIntegrationDiscoveryCompleted?: () => Promise;
};
/** Give a structured child event to the Fast orchestrator for presentation. */
@@ -2633,7 +2635,14 @@ export async function deliverFastAgentParentEventWithLock(
...parentTurn.adapter,
launchTask: parentTurn.adapter.launchTask,
...(humanFollowUp?.setupContext
- ? buildFastAgentSetupAdapter(humanFollowUp.setupContext)
+ ? buildFastAgentSetupAdapter(humanFollowUp.setupContext, {
+ ...(params.onSetupIntegrationDiscoveryCompleted
+ ? {
+ onIntegrationDiscoveryCompleted:
+ params.onSetupIntegrationDiscoveryCompleted,
+ }
+ : {}),
+ })
: {}),
...(wakeupGuard
? {
From a65534d081a64ad552081cf95d590ee1cb5f6a29 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 10 Sep 2026 17:35:23 +0000
Subject: [PATCH 08/23] fix: preserve guided interaction ownership
---
.../trpc/commands/setup/setup-session.test.ts | 17 +++++++++-
.../src/trpc/commands/setup/setup-session.ts | 17 +++++++---
.../sdk/src/server/routers/task-runs.test.ts | 31 +++++++++++++++++++
packages/sdk/src/server/routers/task-runs.ts | 2 +-
4 files changed, 61 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/trpc/commands/setup/setup-session.test.ts b/apps/web/src/trpc/commands/setup/setup-session.test.ts
index 138eb077fd..43f042d7a8 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.test.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts
@@ -334,6 +334,21 @@ describe('optional setup integration discovery', () => {
expect(response?.metadata).toMatchObject({ userId: auth.userId });
});
+ it('rejects setup replies from a collaborator instead of dropping setup guards', async () => {
+ const collaborator = await userFactory.create({ role: 'admin' });
+ const collaboratorAuth = {
+ userId: collaborator.id,
+ isAdmin: true,
+ } as UserAuthSuccess;
+ try {
+ await expect(
+ resolveSetupSessionTurnContext(collaboratorAuth, sessionId),
+ ).rejects.toThrow('Only the setup Session owner can reply during setup.');
+ } finally {
+ await db.delete(users).where(eq(users.id, collaborator.id));
+ }
+ });
+
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']);
@@ -513,7 +528,7 @@ describe('optional setup integration discovery', () => {
{ ...auth, userId: 'other-admin' },
sessionId,
),
- ).resolves.toBeNull();
+ ).rejects.toThrow('Only the setup Session owner can reply during setup.');
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 d001f11622..e08148d6a8 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -1124,13 +1124,22 @@ export async function resolveSetupSessionTurnContext(
auth: UserAuthSuccess,
sessionId: string,
) {
- const conversation = await findSetupSessionConversation(auth);
+ const state = await readSetupNewState();
+ const setupSession = normalizeSetupNewSetupSession(state.setupSession);
+ if (!setupSession) return null;
+ const [linkedSession] = await db
+ .select({ fastConversationId: sessions.fastConversationId })
+ .from(sessions)
+ .where(eq(sessions.id, setupSession.sessionId))
+ .limit(1);
if (
- !conversation ||
- (conversation.sessionId !== sessionId &&
- conversation.fastConversationId !== sessionId)
+ setupSession.sessionId !== sessionId &&
+ linkedSession?.fastConversationId !== sessionId
)
return null;
+ const conversation = await findSetupSessionConversation(auth);
+ if (!conversation)
+ throw new Error('Only the setup Session owner can reply during setup.');
assertAdmin(auth);
const setupSnapshot = await resolveSetupSnapshot(auth);
const setupContext = buildSetupTurnContext(conversation, setupSnapshot);
diff --git a/packages/sdk/src/server/routers/task-runs.test.ts b/packages/sdk/src/server/routers/task-runs.test.ts
index b04f458147..956837c967 100644
--- a/packages/sdk/src/server/routers/task-runs.test.ts
+++ b/packages/sdk/src/server/routers/task-runs.test.ts
@@ -18,6 +18,7 @@ const {
mockRecordTaskInferenceUsage,
mockClaimShowWidgetFallbackDelivery,
mockClearPendingSlackRequestUserInput,
+ mockClearPendingCommunicationRequestUserInput,
mockReleaseShowWidgetFallbackDelivery,
mockClaimMissingChatCloseoutFallbackDelivery,
mockReleaseMissingChatCloseoutFallbackDelivery,
@@ -37,6 +38,7 @@ const {
mockRecordTaskInferenceUsage: vi.fn(),
mockClaimShowWidgetFallbackDelivery: vi.fn(),
mockClearPendingSlackRequestUserInput: vi.fn(),
+ mockClearPendingCommunicationRequestUserInput: vi.fn(),
mockReleaseShowWidgetFallbackDelivery: vi.fn(),
mockClaimMissingChatCloseoutFallbackDelivery: vi.fn(),
mockReleaseMissingChatCloseoutFallbackDelivery: vi.fn(),
@@ -56,6 +58,17 @@ vi.mock('@roomote/communication/messages', () => ({
queueCommunicationMessage: mockQueueCommunicationMessage,
}));
+vi.mock(
+ '@roomote/communication/request-user-input',
+ async (importOriginal) => ({
+ ...(await importOriginal<
+ typeof import('@roomote/communication/request-user-input')
+ >()),
+ clearPendingCommunicationRequestUserInput:
+ mockClearPendingCommunicationRequestUserInput,
+ }),
+);
+
vi.mock('@roomote/slack', () => ({
clearPendingSlackRequestUserInput: mockClearPendingSlackRequestUserInput,
getSlackThreadFooterText: mockGetSlackThreadFooterText,
@@ -345,6 +358,24 @@ describe('taskRunsRouter queue message guards', () => {
);
});
+ it('clears a communication prompt only for the matching source run', async () => {
+ mockClearPendingCommunicationRequestUserInput.mockResolvedValueOnce(true);
+
+ await expect(
+ createRunCaller().clearPendingCommunicationRequestUserInput({
+ runId: 42,
+ provider: 'discord',
+ conversationId: 'channel-1',
+ requestId: 'rui:session:turn:call',
+ }),
+ ).resolves.toBe(true);
+ expect(mockClearPendingCommunicationRequestUserInput).toHaveBeenCalledWith(
+ 'discord',
+ 'channel-1',
+ { requestId: 'rui:session:turn:call', runId: 42 },
+ );
+ });
+
it('allows queueCommunicationMessage for the matching run token', async () => {
await expect(
createRunCaller().queueCommunicationMessage({
diff --git a/packages/sdk/src/server/routers/task-runs.ts b/packages/sdk/src/server/routers/task-runs.ts
index 801974c1a5..069d906dbe 100644
--- a/packages/sdk/src/server/routers/task-runs.ts
+++ b/packages/sdk/src/server/routers/task-runs.ts
@@ -954,7 +954,7 @@ export const taskRunsRouter = router({
clearPendingCommunicationRequestUserInput(
input.provider,
input.conversationId,
- input.requestId ? { requestId: input.requestId } : undefined,
+ { requestId: input.requestId, runId: input.runId },
),
),
/**
From 58e58b97d34777b406e46bc8f9c5c354d2fa3adc Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 10 Sep 2026 17:42:22 +0000
Subject: [PATCH 09/23] fix: restore collaboration after setup
---
.../trpc/commands/setup/setup-session.test.ts | 23 +++++++++++++++++--
.../src/trpc/commands/setup/setup-session.ts | 11 ++++++++-
2 files changed, 31 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/trpc/commands/setup/setup-session.test.ts b/apps/web/src/trpc/commands/setup/setup-session.test.ts
index 43f042d7a8..f5a5f3f2f5 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.test.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts
@@ -177,10 +177,10 @@ describe('optional setup integration discovery', () => {
});
await db
.insert(deploymentSettings)
- .values({ id: 'default', setupNewState: state })
+ .values({ id: 'default', setupCompletedAt: null, setupNewState: state })
.onConflictDoUpdate({
target: deploymentSettings.id,
- set: { setupNewState: state },
+ set: { setupCompletedAt: null, setupNewState: state },
});
mocks.getStatus.mockImplementation(async () => ({
setupNewState: await readState(),
@@ -349,6 +349,25 @@ describe('optional setup integration discovery', () => {
}
});
+ it('allows normal collaborative context after setup completes', async () => {
+ const collaborator = await userFactory.create({ role: 'admin' });
+ const collaboratorAuth = {
+ userId: collaborator.id,
+ isAdmin: true,
+ } as UserAuthSuccess;
+ await db
+ .update(deploymentSettings)
+ .set({ setupCompletedAt: new Date() })
+ .where(eq(deploymentSettings.id, 'default'));
+ try {
+ await expect(
+ resolveSetupSessionTurnContext(collaboratorAuth, sessionId),
+ ).resolves.toBeNull();
+ } finally {
+ await db.delete(users).where(eq(users.id, collaborator.id));
+ }
+ });
+
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']);
diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts
index e08148d6a8..96d1c3124f 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -1124,7 +1124,16 @@ export async function resolveSetupSessionTurnContext(
auth: UserAuthSuccess,
sessionId: string,
) {
- const state = await readSetupNewState();
+ const [settings] = await db
+ .select({
+ setupCompletedAt: deploymentSettings.setupCompletedAt,
+ setupNewState: deploymentSettings.setupNewState,
+ })
+ .from(deploymentSettings)
+ .where(eq(deploymentSettings.id, 'default'))
+ .limit(1);
+ if (settings?.setupCompletedAt) return null;
+ const state = normalizeSetupNewState(settings?.setupNewState ?? {});
const setupSession = normalizeSetupNewSetupSession(state.setupSession);
if (!setupSession) return null;
const [linkedSession] = await db
From 3c93484df2d9592df40a7fc282d9496b0bc5404e Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 10 Sep 2026 17:49:23 +0000
Subject: [PATCH 10/23] fix: resolve setup cards after completion
---
.../trpc/commands/setup/setup-session.test.ts | 29 ++++++++++++++
.../src/trpc/commands/setup/setup-session.ts | 39 ++++++++++++++-----
2 files changed, 58 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/trpc/commands/setup/setup-session.test.ts b/apps/web/src/trpc/commands/setup/setup-session.test.ts
index f5a5f3f2f5..cce7a40d68 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.test.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts
@@ -368,6 +368,35 @@ describe('optional setup integration discovery', () => {
}
});
+ it('allows an admin collaborator to resolve a pending setup card after completion', async () => {
+ const collaborator = await userFactory.create({ role: 'admin' });
+ const collaboratorAuth = {
+ userId: collaborator.id,
+ isAdmin: true,
+ } as UserAuthSuccess;
+ await db
+ .update(deploymentSettings)
+ .set({ setupCompletedAt: new Date() })
+ .where(eq(deploymentSettings.id, 'default'));
+ mocks.submit.mockResolvedValueOnce({ success: true });
+ try {
+ await expect(
+ submitSetupSessionUserInputCommand(collaboratorAuth, {
+ sessionId,
+ requestId: 'pending-after-completion',
+ answers: {},
+ }),
+ ).resolves.toEqual({ success: true });
+ expect(mocks.submit).toHaveBeenCalledWith(
+ collaboratorAuth,
+ expect.objectContaining({ requestId: 'pending-after-completion' }),
+ expect.objectContaining({ setupSession: true }),
+ );
+ } finally {
+ await db.delete(users).where(eq(users.id, collaborator.id));
+ }
+ });
+
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']);
diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts
index 96d1c3124f..d43c7adfb7 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -117,9 +117,9 @@ async function readSetupNewState() {
return normalizeSetupNewState(settings?.setupNewState ?? {});
}
-async function findSetupSessionConversation(
- auth: UserAuthSuccess,
-): Promise {
+async function findSetupSessionConversationRecord(): Promise<
+ (SetupSessionConversation & { ownerUserId: string | null }) | null
+> {
const state = await readSetupNewState();
const setupSession = normalizeSetupNewSetupSession(state.setupSession);
if (!setupSession) return null;
@@ -130,22 +130,27 @@ async function findSetupSessionConversation(
sessionId: sessions.id,
conversationId: fastAgentConversations.conversationId,
workspaceId: fastAgentConversations.workspaceId,
+ ownerUserId: fastAgentConversations.userId,
})
.from(sessions)
.innerJoin(
fastAgentConversations,
eq(sessions.fastConversationId, fastAgentConversations.id),
)
- .where(
- and(
- eq(sessions.id, setupSession.sessionId),
- eq(fastAgentConversations.userId, auth.userId),
- ),
- )
+ .where(eq(sessions.id, setupSession.sessionId))
.limit(1);
return row ? { ...row, workflowVersion: setupSession.workflowVersion } : null;
}
+async function findSetupSessionConversation(
+ auth: UserAuthSuccess,
+): Promise {
+ const row = await findSetupSessionConversationRecord();
+ if (!row || row.ownerUserId !== auth.userId) return null;
+ const { ownerUserId: _, ...conversation } = row;
+ return conversation;
+}
+
async function persistSetupSessionReceipt(
auth: UserAuthSuccess,
input: {
@@ -1099,7 +1104,21 @@ export async function submitSetupSessionUserInputCommand(
},
): Promise<{ success: true }> {
assertAdmin(auth);
- const setupConversation = await findSetupSessionConversation(auth);
+ let setupConversation = await findSetupSessionConversation(auth);
+ if (!setupConversation) {
+ const [settings] = await db
+ .select({ setupCompletedAt: deploymentSettings.setupCompletedAt })
+ .from(deploymentSettings)
+ .where(eq(deploymentSettings.id, 'default'))
+ .limit(1);
+ if (settings?.setupCompletedAt) {
+ const row = await findSetupSessionConversationRecord();
+ if (row) {
+ const { ownerUserId: _, ...conversation } = row;
+ setupConversation = conversation;
+ }
+ }
+ }
if (
!setupConversation ||
(input.sessionId !== setupConversation.sessionId &&
From 40b555b611209ddd5e90ceb54703bd7acc5a15de Mon Sep 17 00:00:00 2001
From: Roomote
Date: Fri, 11 Sep 2026 09:36:41 +0000
Subject: [PATCH 11/23] fix: continue completed setup card actions
---
.../trpc/commands/setup/setup-session.test.ts | 36 +++++++++++++++++-
.../src/trpc/commands/setup/setup-session.ts | 37 +++++++++++++++----
2 files changed, 63 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/trpc/commands/setup/setup-session.test.ts b/apps/web/src/trpc/commands/setup/setup-session.test.ts
index cce7a40d68..82ed4e1ab7 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.test.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts
@@ -369,6 +369,30 @@ describe('optional setup integration discovery', () => {
});
it('allows an admin collaborator to resolve a pending setup card after completion', async () => {
+ const pendingRequest = await request({
+ requestId: 'pending-after-completion',
+ sessionId: conversationId,
+ turnId: 'pending-after-completion',
+ callId: 'pending-after-completion',
+ status: 'pending',
+ preset: 'setup_integrations',
+ questions: [
+ {
+ id: 'setup-integrations',
+ header: 'Your tools',
+ question: 'Continue setup?',
+ isOther: false,
+ isSecret: false,
+ options: [
+ {
+ id: 'continue',
+ label: 'Continue',
+ description: 'Continue without connections',
+ },
+ ],
+ },
+ ],
+ });
const collaborator = await userFactory.create({ role: 'admin' });
const collaboratorAuth = {
userId: collaborator.id,
@@ -378,13 +402,20 @@ describe('optional setup integration discovery', () => {
.update(deploymentSettings)
.set({ setupCompletedAt: new Date() })
.where(eq(deploymentSettings.id, 'default'));
- mocks.submit.mockResolvedValueOnce({ success: true });
+ mocks.submit.mockImplementationOnce(async (_auth, input, options) => {
+ await options.persistSetupPresetResponse({
+ fastConversationId: conversationId,
+ request: pendingRequest,
+ answers: input.answers,
+ });
+ return { success: true };
+ });
try {
await expect(
submitSetupSessionUserInputCommand(collaboratorAuth, {
sessionId,
requestId: 'pending-after-completion',
- answers: {},
+ answers: { 'setup-integrations': { answers: ['continue'] } },
}),
).resolves.toEqual({ success: true });
expect(mocks.submit).toHaveBeenCalledWith(
@@ -392,6 +423,7 @@ describe('optional setup integration discovery', () => {
expect.objectContaining({ requestId: 'pending-after-completion' }),
expect.objectContaining({ setupSession: true }),
);
+ expect(mocks.schedule).toHaveBeenCalled();
} finally {
await db.delete(users).where(eq(users.id, collaborator.id));
}
diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts
index d43c7adfb7..b3920bf041 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -245,18 +245,26 @@ function buildSetupSnapshot(input: {
});
}
-async function resolveSetupSnapshot(auth: UserAuthSuccess): Promise {
+async function resolveSetupSnapshot(
+ auth: UserAuthSuccess,
+ conversation?: SetupSessionConversation,
+): Promise {
const status = await getSetupNewStatusCommand(auth);
const setupSession = normalizeSetupNewSetupSession(
status.setupNewState.setupSession,
);
return buildSetupSnapshot({
status,
- integrationDiscovery: await readSetupIntegrationDiscovery(auth),
+ integrationDiscovery: await readSetupIntegrationDiscovery(
+ auth,
+ {},
+ conversation,
+ ),
hasSuccessfulStarterLaunch: setupSession?.starterTaskSelection
? await hasSuccessfulSetupSessionTaskLaunch(
auth,
setupSession.starterTaskSelection.selectedAt,
+ conversation,
)
: false,
});
@@ -281,10 +289,12 @@ function buildSetupTurnContext(
async function readSetupIntegrationDiscovery(
auth: UserAuthSuccess,
suppliedAnswers: AcpRequestUserInputAnswers = {},
+ conversationOverride?: SetupSessionConversation,
) {
const state = await readSetupNewState();
const setupSession = normalizeSetupNewSetupSession(state.setupSession);
- const conversation = await findSetupSessionConversation(auth);
+ const conversation =
+ conversationOverride ?? (await findSetupSessionConversation(auth));
const messages = conversation
? await db
.select({
@@ -384,8 +394,10 @@ async function readSetupIntegrationDiscovery(
async function hasSuccessfulSetupSessionTaskLaunch(
auth: UserAuthSuccess,
selectedAt: string,
+ conversationOverride?: SetupSessionConversation,
): Promise {
- const conversation = await findSetupSessionConversation(auth);
+ const conversation =
+ conversationOverride ?? (await findSetupSessionConversation(auth));
if (!conversation) return false;
const [run] = await db
.select({ id: taskRuns.id })
@@ -528,13 +540,15 @@ async function buildSetupPlatformEventTurn(
*/
export async function reconcileSetupPlatformEvents(
auth: UserAuthSuccess,
+ options: { conversation?: SetupSessionConversation } = {},
): Promise {
assertAdmin(auth);
const status = await getSetupNewStatusCommand(auth);
const state = normalizeSetupNewState(status.setupNewState);
const setupSession = normalizeSetupNewSetupSession(state.setupSession);
if (!setupSession) return status.setupCompletedAt != null;
- const conversation = await findSetupSessionConversation(auth);
+ const conversation =
+ options.conversation ?? (await findSetupSessionConversation(auth));
if (!conversation) return status.setupCompletedAt != null;
const setupCompleted =
status.setupCompletedAt != null ||
@@ -543,9 +557,14 @@ export async function reconcileSetupPlatformEvents(
? await hasSuccessfulSetupSessionTaskLaunch(
auth,
setupSession.starterTaskSelection.selectedAt,
+ conversation,
)
: false;
- const integrationDiscovery = await readSetupIntegrationDiscovery(auth);
+ const integrationDiscovery = await readSetupIntegrationDiscovery(
+ auth,
+ {},
+ conversation,
+ );
const setupSnapshot = buildSetupSnapshot({
status,
hasSuccessfulStarterLaunch,
@@ -1126,13 +1145,15 @@ export async function submitSetupSessionUserInputCommand(
) {
throw new Error('This input request does not belong to the setup Session.');
}
- const setupSnapshot = await resolveSetupSnapshot(auth);
+ const setupSnapshot = await resolveSetupSnapshot(auth, setupConversation);
return submitFastSessionUserInputCommand(auth, input, {
setupContext: buildSetupTurnContext(setupConversation, setupSnapshot),
setupSession: true,
persistSetupPresetResponse: async (details) => {
const result = await persistSetupPresetResponse({ auth, ...details });
- await reconcileSetupPlatformEvents(auth);
+ await reconcileSetupPlatformEvents(auth, {
+ conversation: setupConversation,
+ });
return result;
},
});
From b61c7705d8508333edf49abb76b77058bbbdb13f Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Fri, 11 Sep 2026 14:22:53 +0100
Subject: [PATCH 12/23] fix: tolerate placeholder setup input fields
---
.../__tests__/fast-agent-service.test.ts | 15 +--
.../server/fast-agent/fast-agent-service.ts | 97 ++++++++++++-------
2 files changed, 68 insertions(+), 44 deletions(-)
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 273acabcf5..9fbe2c36c0 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
@@ -1356,18 +1356,13 @@ describe('answerFastAgentQuestion native OpenCode tools', () => {
async (_params, _session, options) => {
await options.onSessionReady('opencode-session-1');
options.onPromptStarted?.();
- // Some models fill every optional parameter; the placeholder
- // questions must be discarded, never rendered or used to reject.
+ // Some models fill every optional parameter, including malformed
+ // placeholders and nulls. Trusted preset calls must discard them
+ // before validation, never render them or fail.
toolResult = await invokeTool(nativeToolNames.requestUserInput, {
preset: 'setup_starter_tasks',
- questions: [
- {
- id: 'placeholder',
- header: 'placeholder',
- question: 'placeholder',
- options: [{ label: 'placeholder', description: 'placeholder' }],
- },
- ],
+ questions: [],
+ setupIntegrationAnswers: null,
});
return '';
},
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 ad62023d5d..645d6deffc 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
@@ -571,41 +571,70 @@ const setupIntegrationAnswersSchema = z.record(
// 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.
-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;
- setupIntegrationAnswers?: z.output<
- typeof setupIntegrationAnswersSchema
- >;
- }
- | { questions: z.output[] }
- | null =>
- args.preset
- ? {
- preset: args.preset,
- ...(args.setupIntegrationAnswers !== undefined
- ? { setupIntegrationAnswers: args.setupIntegrationAnswers }
- : {}),
+const requestUserInputArgsSchema = z.preprocess(
+ (raw) => {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
+
+ const input = raw as Record;
+ if (input.preset === 'setup_starter_tasks') {
+ // A trusted preset owns its questions. Models sometimes serialize
+ // optional fields as placeholders or null; discard them before schema
+ // validation so those fields cannot make the preset call fail.
+ return { preset: input.preset };
+ }
+ if (input.preset === 'setup_integrations') {
+ // Integration answers are meaningful for this preset, but questions
+ // are still server-owned. Treat a model-emitted null as omitted.
+ return {
+ preset: input.preset,
+ ...(input.setupIntegrationAnswers !== undefined &&
+ input.setupIntegrationAnswers !== null
+ ? { setupIntegrationAnswers: input.setupIntegrationAnswers }
+ : {}),
+ };
+ }
+ return raw;
+ },
+ 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;
+ setupIntegrationAnswers?: z.output<
+ typeof setupIntegrationAnswersSchema
+ >;
}
- : args.questions
- ? { questions: args.questions }
- : null,
- );
+ | { questions: z.output[] }
+ | null =>
+ args.preset
+ ? {
+ preset: args.preset,
+ ...(args.setupIntegrationAnswers !== undefined
+ ? { setupIntegrationAnswers: args.setupIntegrationAnswers }
+ : {}),
+ }
+ : args.questions
+ ? { questions: args.questions }
+ : null,
+ ),
+);
function normalizeThreadText(text: string): string {
return text.replace(/\s+/g, ' ').trim();
From 5335daec5cf6963efed3328ae4f27ff1cc58fe3c Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Fri, 11 Sep 2026 14:33:16 +0100
Subject: [PATCH 13/23] fix: avoid duplicate starter selection confirmation
---
.../FastSessionTranscript.client.test.tsx | 29 +++++++++++++++++--
.../[sessionId]/FastSessionTranscript.tsx | 19 ++++++++++++
2 files changed, 46 insertions(+), 2 deletions(-)
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 0d04ac461d..00ba9e299f 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
@@ -611,6 +611,22 @@ describe('FastSessionTranscript', () => {
resolution: 'submitted',
},
};
+ const starterReceipt = {
+ ...textMessage({
+ id: 'starter-receipt',
+ role: 'user',
+ text: 'Selected Speed up CI.',
+ ts: 2,
+ inputKind: SETUP_RECEIPT_INPUT_KIND,
+ userId: 'user-1',
+ }),
+ metadata: {
+ visibleInTranscript: true,
+ inputKind: SETUP_RECEIPT_INPUT_KIND,
+ setupReceiptKind: 'starter_selection',
+ userId: 'user-1',
+ },
+ };
const { unmount } = render(
{
render(
{
);
expect(screen.queryByText('Structured input request')).toBeNull();
- expect(screen.getByText('Structured response')).toBeInTheDocument();
+ if (preset === 'setup_starter_tasks') {
+ expect(screen.queryByText('Structured response')).toBeNull();
+ expect(screen.getByText('Selected Speed up CI.')).toBeInTheDocument();
+ } else {
+ expect(screen.getByText('Structured response')).toBeInTheDocument();
+ }
expect(screen.getByLabelText('Test User')).toBeInTheDocument();
expect(screen.queryByText(cardLabel)).toBeNull();
},
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index 7783722855..45c8d16998 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -766,6 +766,15 @@ export function FastSessionTranscript({
requestUserInputTurnIds: turnIds,
};
}, [messages]);
+ const hasStarterSelectionReceipt = useMemo(
+ () =>
+ messages.some(
+ (message) =>
+ message.metadata?.inputKind === SETUP_RECEIPT_INPUT_KIND &&
+ message.metadata.setupReceiptKind === 'starter_selection',
+ ),
+ [messages],
+ );
const { persistedBeforeInput, persistedAfterInput } = useMemo(() => {
const before: AcpUiMessage[] = [];
const after: AcpUiMessage[] = [];
@@ -819,6 +828,15 @@ export function FastSessionTranscript({
const request = requestId
? (requestUserInputById.get(requestId) ?? null)
: null;
+ // Setup submission also writes a canonical selection receipt. Keep
+ // that deterministic confirmation as the single visible record rather
+ // than rendering the same selection again as a generic response.
+ if (
+ request?.preset === 'setup_starter_tasks' &&
+ hasStarterSelectionReceipt
+ ) {
+ continue;
+ }
uiMessage = {
...uiMessage,
role: 'user',
@@ -864,6 +882,7 @@ export function FastSessionTranscript({
}, [
messages,
owner,
+ hasStarterSelectionReceipt,
pendingInputRequestOrder,
requestUserInputById,
requestUserInputTurnIds,
From db65820fa5a4dd9ac14d635f6f0635ec9421ab36 Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Fri, 11 Sep 2026 14:34:55 +0100
Subject: [PATCH 14/23] Fix setup timeline child key warning
---
apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx
index 01ea62b871..c1c834c220 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx
@@ -144,7 +144,7 @@ export default async function SessionDetailPage({
authorizedUser.isAdmin &&
unifiedSession.id === (await findDeploymentSetupSessionId());
const setupTimelineExtras = isSetupSession ? (
-
+
From 357a87540ab8825278762d89e144fbee278ab55a Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Fri, 11 Sep 2026 14:55:19 +0100
Subject: [PATCH 15/23] fix transcript interaction receipt deduplication
---
.../FastSessionTranscript.client.test.tsx | 104 +++++++++++++++++-
.../[sessionId]/FastSessionTranscript.tsx | 21 +---
apps/web/src/lib/setup-receipt-transcript.ts | 37 +++++++
.../commands/setup/setup-receipts.test.ts | 7 +-
.../src/trpc/commands/setup/setup-receipts.ts | 2 +
.../src/trpc/commands/setup/setup-session.ts | 1 +
packages/types/src/acp.ts | 8 ++
7 files changed, 162 insertions(+), 18 deletions(-)
create mode 100644 apps/web/src/lib/setup-receipt-transcript.ts
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 00ba9e299f..3164f3afac 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
@@ -14,6 +14,7 @@ import {
FastSessionTranscript,
pendingResponseReducer,
} from './FastSessionTranscript';
+import { isRequestUserInputResponseRepresentedByCanonicalReceipt } from '@/lib/setup-receipt-transcript';
import { SessionRunningTaskCountContext } from './session-task-panel-context';
import {
clearPendingFastSessionLaunch,
@@ -607,6 +608,9 @@ describe('FastSessionTranscript', () => {
eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
payload: {
requestId,
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
answers: { starters: { answers: ['Speed up CI'] } },
resolution: 'submitted',
},
@@ -626,6 +630,12 @@ describe('FastSessionTranscript', () => {
setupReceiptKind: 'starter_selection',
userId: 'user-1',
},
+ payload: {
+ setupReceipt: {
+ requestId,
+ kind: 'starter_selection',
+ },
+ },
};
const { unmount } = render(
@@ -660,15 +670,105 @@ describe('FastSessionTranscript', () => {
expect(screen.queryByText('Structured input request')).toBeNull();
if (preset === 'setup_starter_tasks') {
expect(screen.queryByText('Structured response')).toBeNull();
- expect(screen.getByText('Selected Speed up CI.')).toBeInTheDocument();
+ expect(screen.getAllByText('Selected Speed up CI.')).toHaveLength(1);
} else {
- expect(screen.getByText('Structured response')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('request-user-input-response'),
+ ).toBeInTheDocument();
}
expect(screen.getByLabelText('Test User')).toBeInTheDocument();
expect(screen.queryByText(cardLabel)).toBeNull();
},
);
+ it('keeps an unmatched setup response visible even when a setup receipt exists', () => {
+ const request = {
+ ...textMessage({
+ id: 'request',
+ role: 'assistant',
+ text: 'Choose',
+ ts: 1,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId: 'request-1',
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ preset: 'setup_starter_tasks' as const,
+ questions: [
+ { id: 'choice', question: 'Choose', options: [{ label: 'One' }] },
+ ],
+ },
+ };
+ const response = {
+ ...textMessage({ id: 'response', role: 'user', text: 'Response', ts: 2 }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
+ payload: {
+ requestId: 'request-1',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ answers: { choice: { answers: ['One'] } },
+ resolution: 'submitted' as const,
+ },
+ };
+ const receipt = textMessage({
+ id: 'receipt',
+ role: 'user',
+ text: 'A different setup result.',
+ ts: 2,
+ inputKind: SETUP_RECEIPT_INPUT_KIND,
+ });
+ receipt.metadata = {
+ visibleInTranscript: true,
+ inputKind: SETUP_RECEIPT_INPUT_KIND,
+ setupReceiptKind: 'compute_readiness',
+ } as {
+ visibleInTranscript: boolean;
+ inputKind?: string;
+ setupReceiptKind?: string;
+ };
+ receipt.payload = {
+ setupReceipt: { kind: 'compute_readiness', requestId: 'request-2' },
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('One')).toBeInTheDocument();
+ expect(screen.getByText('A different setup result.')).toBeInTheDocument();
+ });
+
+ it('does not suppress responses for historical receipts without request linkage', () => {
+ expect(
+ isRequestUserInputResponseRepresentedByCanonicalReceipt(
+ {
+ requestId: 'request-1',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ answers: {},
+ resolution: 'submitted',
+ },
+ [
+ {
+ metadata: {
+ inputKind: SETUP_RECEIPT_INPUT_KIND,
+ setupReceiptKind: 'starter_selection',
+ },
+ payload: { setupReceipt: { kind: 'starter_selection' } },
+ },
+ ],
+ ),
+ ).toBe(false);
+ });
+
it('renders a structured response once in chronology as human-authored text', () => {
const requestId = 'rui:chronology';
const question = 'Which direction should I take?';
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index 45c8d16998..de51ff2d2d 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -73,6 +73,7 @@ import {
import { SetupStarterTasksCard } from './setup/SetupStarterTasksCard';
import { SetupIntegrationsCard } from './setup/SetupIntegrationsCard';
import { SESSION_HEADER_CONTENT_CLASS_NAME } from './session-header-layout';
+import { isRequestUserInputResponseRepresentedByCanonicalReceipt } from '@/lib/setup-receipt-transcript';
import {
AcpTranscriptBlockList,
@@ -766,15 +767,6 @@ export function FastSessionTranscript({
requestUserInputTurnIds: turnIds,
};
}, [messages]);
- const hasStarterSelectionReceipt = useMemo(
- () =>
- messages.some(
- (message) =>
- message.metadata?.inputKind === SETUP_RECEIPT_INPUT_KIND &&
- message.metadata.setupReceiptKind === 'starter_selection',
- ),
- [messages],
- );
const { persistedBeforeInput, persistedAfterInput } = useMemo(() => {
const before: AcpUiMessage[] = [];
const after: AcpUiMessage[] = [];
@@ -828,12 +820,12 @@ export function FastSessionTranscript({
const request = requestId
? (requestUserInputById.get(requestId) ?? null)
: null;
- // Setup submission also writes a canonical selection receipt. Keep
- // that deterministic confirmation as the single visible record rather
- // than rendering the same selection again as a generic response.
if (
- request?.preset === 'setup_starter_tasks' &&
- hasStarterSelectionReceipt
+ response &&
+ isRequestUserInputResponseRepresentedByCanonicalReceipt(
+ response,
+ messages,
+ )
) {
continue;
}
@@ -882,7 +874,6 @@ export function FastSessionTranscript({
}, [
messages,
owner,
- hasStarterSelectionReceipt,
pendingInputRequestOrder,
requestUserInputById,
requestUserInputTurnIds,
diff --git a/apps/web/src/lib/setup-receipt-transcript.ts b/apps/web/src/lib/setup-receipt-transcript.ts
new file mode 100644
index 0000000000..a7a0865b00
--- /dev/null
+++ b/apps/web/src/lib/setup-receipt-transcript.ts
@@ -0,0 +1,37 @@
+import {
+ SETUP_RECEIPT_INPUT_KIND,
+ type SetupReceiptPayload,
+ type AcpRequestUserInputResponsePayload,
+} from '@roomote/types';
+
+type SetupReceiptMessage = {
+ metadata: Record | null;
+ payload: Record | null;
+};
+
+function getCanonicalSetupReceipt(
+ message: SetupReceiptMessage,
+): SetupReceiptPayload | null {
+ if (message.metadata?.inputKind !== SETUP_RECEIPT_INPUT_KIND) return null;
+ const receipt = message.payload?.setupReceipt;
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) {
+ return null;
+ }
+ const kind = (receipt as Record).kind;
+ return typeof kind === 'string' ? (receipt as SetupReceiptPayload) : null;
+}
+
+/**
+ * Returns true only when a canonical receipt explicitly represents the same
+ * request-user-input action. Presets are presentation hints, not an
+ * association: responses without a linked receipt must remain visible.
+ */
+export function isRequestUserInputResponseRepresentedByCanonicalReceipt(
+ response: AcpRequestUserInputResponsePayload,
+ messages: readonly SetupReceiptMessage[],
+): boolean {
+ return messages.some(
+ (message) =>
+ getCanonicalSetupReceipt(message)?.requestId === response.requestId,
+ );
+}
diff --git a/apps/web/src/trpc/commands/setup/setup-receipts.test.ts b/apps/web/src/trpc/commands/setup/setup-receipts.test.ts
index 40a6af9a6e..672652bb19 100644
--- a/apps/web/src/trpc/commands/setup/setup-receipts.test.ts
+++ b/apps/web/src/trpc/commands/setup/setup-receipts.test.ts
@@ -16,6 +16,7 @@ describe('setup transcript receipts', () => {
userId: 'user-1',
kind: 'compute_readiness' as const,
fingerprint: 'modal',
+ requestId: 'request-1',
text: 'Sandbox configured with Modal.',
payload: { provider: 'modal' },
ts: 123,
@@ -36,7 +37,11 @@ describe('setup transcript receipts', () => {
setupReceiptKind: 'compute_readiness',
},
payload: {
- setupReceipt: { kind: 'compute_readiness', provider: 'modal' },
+ setupReceipt: {
+ kind: 'compute_readiness',
+ requestId: 'request-1',
+ provider: 'modal',
+ },
},
});
});
diff --git a/apps/web/src/trpc/commands/setup/setup-receipts.ts b/apps/web/src/trpc/commands/setup/setup-receipts.ts
index 0a95fbdd53..ced856fbb1 100644
--- a/apps/web/src/trpc/commands/setup/setup-receipts.ts
+++ b/apps/web/src/trpc/commands/setup/setup-receipts.ts
@@ -55,6 +55,7 @@ export function buildSetupReceiptMessage(input: {
kind: SetupReceiptKind;
fingerprint: string;
text: string;
+ requestId?: string;
payload?: Record;
ts?: number;
}) {
@@ -84,6 +85,7 @@ export function buildSetupReceiptMessage(input: {
payload: {
setupReceipt: {
kind: input.kind,
+ ...(input.requestId ? { requestId: input.requestId } : {}),
...(input.payload ?? {}),
},
},
diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts
index b3920bf041..a81ef098d8 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -1098,6 +1098,7 @@ async function persistSetupPresetResponse(input: {
userId: input.auth.userId,
kind: 'starter_selection',
fingerprint: input.request.payload.requestId,
+ requestId: input.request.payload.requestId,
text: formatStarterSelectionReceipt(
taskIds.map(
(taskId) =>
diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts
index 489da31ee5..de262ee5ab 100644
--- a/packages/types/src/acp.ts
+++ b/packages/types/src/acp.ts
@@ -83,6 +83,14 @@ export const ACP_LOGICAL_EVENT_ID_KEY = 'logicalEventId' as const;
*/
export const SETUP_RECEIPT_INPUT_KIND = 'setup_receipt' as const;
+/** Payload shared by canonical setup receipts persisted in transcript history. */
+export interface SetupReceiptPayload {
+ kind: string;
+ /** Request-user-input event represented by this receipt, when applicable. */
+ requestId?: string;
+ [key: string]: unknown;
+}
+
export interface AcpLogicalEventIdParts {
sessionId: string | null | undefined;
turnId?: string | null | undefined;
From ef35b08ded1b3e4ad9cd758aac99e2d2b15d1904 Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Fri, 11 Sep 2026 16:19:22 +0100
Subject: [PATCH 16/23] feat setup receipts as transcript activities
---
.../FastSessionTranscript.client.test.tsx | 24 ++++++---
.../hooks/services/acp-protocol-service.ts | 11 ++++
.../[taskId]/messages/acp/AcpMessageItem.tsx | 3 ++
.../messages/acp/AcpSetupReceiptMessage.tsx | 52 +++++++++++++++++++
.../task/[taskId]/messages/acp/types.ts | 12 ++++-
.../commands/setup/setup-receipts.test.ts | 8 +++
.../src/trpc/commands/setup/setup-receipts.ts | 8 ++-
.../src/trpc/commands/setup/setup-session.ts | 44 ++++++++++++++++
packages/types/src/acp.ts | 5 ++
9 files changed, 158 insertions(+), 9 deletions(-)
create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpSetupReceiptMessage.tsx
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 3164f3afac..380e0e5a4b 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
@@ -676,7 +676,9 @@ describe('FastSessionTranscript', () => {
screen.getByTestId('request-user-input-response'),
).toBeInTheDocument();
}
- expect(screen.getByLabelText('Test User')).toBeInTheDocument();
+ if (preset === 'setup_integrations') {
+ expect(screen.getByLabelText('Test User')).toBeInTheDocument();
+ }
expect(screen.queryByText(cardLabel)).toBeNull();
},
);
@@ -1161,7 +1163,7 @@ describe('FastSessionTranscript', () => {
);
});
- it('resolves a setup receipt avatar from the session owner', () => {
+ it('renders a setup receipt as a completed action with its card icon', () => {
const receipt = textMessage({
id: 'setup-receipt',
role: 'user',
@@ -1170,6 +1172,15 @@ describe('FastSessionTranscript', () => {
inputKind: SETUP_RECEIPT_INPUT_KIND,
userId: 'user-1',
});
+ receipt.payload = {
+ setupReceipt: {
+ kind: 'source_connection',
+ presentation: {
+ label: 'Asked to connect source control',
+ iconKey: 'git-branch',
+ },
+ },
+ };
render(
{
/>,
);
- const avatar = screen.getByLabelText('Test User');
- expect(avatar.querySelector('img')).toHaveAttribute(
- 'src',
- 'https://example.com/avatar.png',
- );
+ expect(
+ screen.getByText('Asked to connect source control'),
+ ).toBeInTheDocument();
+ expect(document.querySelector('.lucide-git-branch')).toBeInTheDocument();
});
it('removes the running task indicator when the count returns to zero', () => {
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts
index 3e2577b9db..4439333813 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts
@@ -6,6 +6,7 @@ import {
type AcpRequestUserInputPayload,
type AcpToolCallPayload,
type AcpToolResultPayload,
+ type SetupReceiptPayload,
type TaskMessageContentBlock,
type TaskMessageRole,
asBoolean,
@@ -30,6 +31,7 @@ import {
resolveAcpTranscriptVisibility,
textFromContentArray,
ACP_ENVELOPE_EVENT_TYPES,
+ SETUP_RECEIPT_INPUT_KIND,
ACP_LIVE_EVENT_TYPES,
} from '@roomote/types';
@@ -335,6 +337,15 @@ export function toAcpUiMessage(
null,
};
+ if (metadataRecord.inputKind === SETUP_RECEIPT_INPUT_KIND) {
+ return {
+ ...base,
+ role: 'user',
+ kind: 'setup_receipt',
+ data: (payloadRecord.setupReceipt ?? {}) as SetupReceiptPayload,
+ } as AcpUiMessage;
+ }
+
switch (normalized.kind) {
case 'text':
return {
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx
index 7f422d496a..cbd2bb12bd 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx
@@ -8,6 +8,7 @@ import { AcpVoiceCallMessage } from './AcpVoiceCallMessage';
import { AcpTodoSectionMessage } from './AcpTodoSectionMessage';
import { AcpTextMessage } from './AcpTextMessage';
import { AcpToolMessage } from './AcpToolMessage';
+import { AcpSetupReceiptMessage } from './AcpSetupReceiptMessage';
import { AcpUnknownMessage } from './AcpUnknownMessage';
import { DelegatedTaskCard } from './DelegatedTaskCard';
import { getDelegatedTaskDetails } from './delegated-task';
@@ -29,6 +30,8 @@ function AcpMessageItemBase({
children,
}: AcpMessageItemProps) {
switch (msg.kind) {
+ case 'setup_receipt':
+ return ;
case 'text':
return ;
case 'reasoning':
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpSetupReceiptMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpSetupReceiptMessage.tsx
new file mode 100644
index 0000000000..7785d3a27f
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpSetupReceiptMessage.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import {
+ CheckCircle2,
+ Container,
+ GitBranch,
+ ListChecks,
+ Plug,
+ Zap,
+ type LucideIcon,
+} from '@/components/system';
+import {
+ Message,
+ MessageContent,
+ Tool,
+ ToolHeader,
+} from '@/components/ai-elements';
+
+import type { AcpSetupReceiptUiMessage } from './types';
+
+const ICONS: Record = {
+ container: Container,
+ 'git-branch': GitBranch,
+ 'list-checks': ListChecks,
+ plug: Plug,
+ zap: Zap,
+};
+
+export function AcpSetupReceiptMessage({
+ msg,
+}: {
+ msg: AcpSetupReceiptUiMessage;
+}) {
+ const presentation = msg.data.presentation;
+ const Icon = ICONS[presentation?.iconKey ?? ''] ?? CheckCircle2;
+ const label = presentation?.label ?? msg.text ?? 'Setup action completed';
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts
index 8b2caeecaa..3a8ab431d0 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts
@@ -5,6 +5,7 @@ import type {
AcpToolResultPayload,
AcpEventType,
DataVisualizationBlock,
+ SetupReceiptPayload,
TaskMessageRole,
} from '@roomote/types';
@@ -50,6 +51,11 @@ export interface AcpToolResultUiMessage extends AcpUiMessageBase {
data: AcpToolResultPayload;
}
+export interface AcpSetupReceiptUiMessage extends AcpUiMessageBase {
+ kind: 'setup_receipt';
+ data: SetupReceiptPayload;
+}
+
export interface AcpPlanUiMessage extends AcpUiMessageBase {
kind: 'plan';
data: AcpPlanPayload;
@@ -64,7 +70,10 @@ export interface AcpTodoSectionUiMessage extends AcpUiMessageBase {
}
export interface AcpOtherUiMessage extends AcpUiMessageBase {
- kind: Exclude;
+ kind: Exclude<
+ AcpMessageKind,
+ 'tool_call' | 'tool_result' | 'plan' | 'setup_receipt'
+ >;
data: Record;
/** Source chunks before reasoning-only display normalization. */
rawText?: string;
@@ -73,6 +82,7 @@ export interface AcpOtherUiMessage extends AcpUiMessageBase {
export type AcpUiMessage =
| AcpToolCallUiMessage
| AcpToolResultUiMessage
+ | AcpSetupReceiptUiMessage
| AcpPlanUiMessage
| AcpTodoSectionUiMessage
| AcpOtherUiMessage;
diff --git a/apps/web/src/trpc/commands/setup/setup-receipts.test.ts b/apps/web/src/trpc/commands/setup/setup-receipts.test.ts
index 672652bb19..72346dbee1 100644
--- a/apps/web/src/trpc/commands/setup/setup-receipts.test.ts
+++ b/apps/web/src/trpc/commands/setup/setup-receipts.test.ts
@@ -17,6 +17,10 @@ describe('setup transcript receipts', () => {
kind: 'compute_readiness' as const,
fingerprint: 'modal',
requestId: 'request-1',
+ presentation: {
+ label: 'Asked to set up a sandbox',
+ iconKey: 'container',
+ },
text: 'Sandbox configured with Modal.',
payload: { provider: 'modal' },
ts: 123,
@@ -40,6 +44,10 @@ describe('setup transcript receipts', () => {
setupReceipt: {
kind: 'compute_readiness',
requestId: 'request-1',
+ presentation: {
+ label: 'Asked to set up a sandbox',
+ iconKey: 'container',
+ },
provider: 'modal',
},
},
diff --git a/apps/web/src/trpc/commands/setup/setup-receipts.ts b/apps/web/src/trpc/commands/setup/setup-receipts.ts
index ced856fbb1..fbb937dbb7 100644
--- a/apps/web/src/trpc/commands/setup/setup-receipts.ts
+++ b/apps/web/src/trpc/commands/setup/setup-receipts.ts
@@ -9,7 +9,8 @@ export type SetupReceiptKind =
| 'source_connection'
| 'compute_readiness'
| 'starter_selection'
- | 'recommendation_application';
+ | 'recommendation_application'
+ | 'integration_discovery';
function formatList(items: string[]): string {
if (items.length === 0) return '';
@@ -56,6 +57,10 @@ export function buildSetupReceiptMessage(input: {
fingerprint: string;
text: string;
requestId?: string;
+ presentation: {
+ label: string;
+ iconKey: string;
+ };
payload?: Record;
ts?: number;
}) {
@@ -86,6 +91,7 @@ export function buildSetupReceiptMessage(input: {
setupReceipt: {
kind: input.kind,
...(input.requestId ? { requestId: input.requestId } : {}),
+ presentation: input.presentation,
...(input.payload ?? {}),
},
},
diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts
index a81ef098d8..f9fe20283f 100644
--- a/apps/web/src/trpc/commands/setup/setup-session.ts
+++ b/apps/web/src/trpc/commands/setup/setup-session.ts
@@ -157,6 +157,10 @@ async function persistSetupSessionReceipt(
kind: SetupReceiptKind;
fingerprint: string;
text: string;
+ presentation: {
+ label: string;
+ iconKey: string;
+ };
payload?: Record;
ts?: number;
},
@@ -591,6 +595,10 @@ export async function reconcileSetupPlatformEvents(
{
kind: 'source_connection',
fingerprint,
+ presentation: {
+ label: 'Asked to connect source control',
+ iconKey: 'git-branch',
+ },
text: formatSourceConnectionReceipt({
providerLabels: synchronized.map((provider) => provider.label),
repositoryCount,
@@ -615,6 +623,10 @@ export async function reconcileSetupPlatformEvents(
{
kind: 'compute_readiness',
fingerprint: state.computeProvider,
+ presentation: {
+ label: 'Asked to set up a sandbox',
+ iconKey: 'container',
+ },
text: formatComputeReadinessReceipt(providerLabel),
payload: { provider: state.computeProvider },
},
@@ -811,6 +823,10 @@ export async function persistSetupRecommendationApplicationReceipt(
await persistSetupSessionReceipt(auth, {
kind: 'recommendation_application',
fingerprint: `${batch.inputFingerprint}:${action}`,
+ presentation: {
+ label: 'Suggested things to automate',
+ iconKey: 'zap',
+ },
text: formatRecommendationApplicationReceipt({ action, enabledTitles }),
payload: {
action,
@@ -1099,6 +1115,10 @@ async function persistSetupPresetResponse(input: {
kind: 'starter_selection',
fingerprint: input.request.payload.requestId,
requestId: input.request.payload.requestId,
+ presentation: {
+ label: 'Suggested initial tasks',
+ iconKey: 'list-checks',
+ },
text: formatStarterSelectionReceipt(
taskIds.map(
(taskId) =>
@@ -1112,6 +1132,30 @@ async function persistSetupPresetResponse(input: {
.onConflictDoNothing({
target: [fastAgentMessages.conversationId, fastAgentMessages.eventId],
});
+ else
+ await tx
+ .insert(fastAgentMessages)
+ .values({
+ conversationId: input.fastConversationId,
+ ...buildSetupReceiptMessage({
+ sessionId: setupSession.sessionId,
+ workflowVersion: setupSession.workflowVersion,
+ userId: input.auth.userId,
+ kind: 'integration_discovery',
+ fingerprint: input.request.payload.requestId,
+ requestId: input.request.payload.requestId,
+ presentation: {
+ label: 'Suggested integrations',
+ iconKey: 'plug',
+ },
+ text: 'Suggested integrations.',
+ payload: {},
+ ts: now.getTime(),
+ }),
+ })
+ .onConflictDoNothing({
+ target: [fastAgentMessages.conversationId, fastAgentMessages.eventId],
+ });
});
}
diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts
index de262ee5ab..bb47247244 100644
--- a/packages/types/src/acp.ts
+++ b/packages/types/src/acp.ts
@@ -88,6 +88,10 @@ export interface SetupReceiptPayload {
kind: string;
/** Request-user-input event represented by this receipt, when applicable. */
requestId?: string;
+ presentation?: {
+ label: string;
+ iconKey: string;
+ };
[key: string]: unknown;
}
@@ -835,6 +839,7 @@ export function parseAcpRequestUserInputAnswerReply(
export type AcpMessageKind =
| 'text'
+ | 'setup_receipt'
| 'reasoning'
| 'tool_call'
| 'tool_result'
From a775302d0fd4306022b55039f77cddf19da81c66 Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Fri, 11 Sep 2026 16:42:46 +0100
Subject: [PATCH 17/23] chore: resolve rebase lint issues
---
.../[sessionId]/FastSessionTranscript.tsx | 87 -------------------
.../src/components/settings/Integrations.tsx | 2 -
2 files changed, 89 deletions(-)
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index de51ff2d2d..d69f654a38 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -640,93 +640,6 @@ export function FastSessionTranscript({
return { messageCount, assistantCount };
}, [serverMessages]);
- const persistedUiMessages = useMemo(
- () =>
- messages
- .filter(
- (message) =>
- !(
- message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage &&
- (message.payload as { taskNavigation?: unknown } | null)
- ?.taskNavigation === true
- ) &&
- message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput &&
- message.eventType !==
- ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
- )
- .map((message) => {
- // Keep the persisted source in the UI pipeline so it reconciles the
- // streamed reply in place, but hide this internal voice delivery.
- if (
- message.role === 'assistant' &&
- message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage &&
- (message.metadata as { voiceCommentary?: unknown } | null)
- ?.voiceCommentary === true
- ) {
- const text = getTranscriptMessageText(message) ?? '';
- return toAcpUiMessage({
- id: `assistant:${message.eventId}`,
- ts: message.ts,
- eventType: ACP_ENVELOPE_EVENT_TYPES.ToolResult as AcpEventType,
- role: 'tool',
- kind: 'tool_result',
- contentBlocks: [{ type: 'text', text }],
- metadata: {
- visibleInTranscript: false,
- toolCallId: message.eventId,
- },
- payload: {
- toolName: 'report_to_voice',
- toolCallId: message.eventId,
- status: 'completed',
- rawInput: {},
- output: text,
- },
- text,
- userName: null,
- userEmail: null,
- userImageUrl: null,
- });
- }
- const uiMessage = toAcpUiMessage({
- // A reply keeps the id its streamed chunks rendered under, so the
- // persisted row reconciles in place instead of remounting.
- id:
- message.role === 'assistant' &&
- message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage
- ? `assistant:${message.eventId}`
- : message.id,
- ts: message.ts,
- eventType: message.eventType as AcpEventType,
- role: message.role,
- kind: inferAcpMessageKind(message.eventType),
- contentBlocks: message.contentBlocks,
- metadata: message.metadata,
- payload: message.payload,
- text: getTranscriptMessageText(message),
- userName: message.userName,
- userEmail: message.userEmail,
- userImageUrl: message.userImageUrl,
- });
-
- if (
- uiMessage.role !== 'user' ||
- !owner ||
- uiMessage.userId !== owner.userId
- ) {
- return uiMessage;
- }
-
- return {
- ...uiMessage,
- userName: uiMessage.userName ?? owner.name,
- userEmail: uiMessage.userEmail ?? owner.email,
- userImageUrl: uiMessage.userImageUrl ?? owner.imageUrl,
- };
- }),
- [messages, owner],
- );
-
const pendingInputRequest = useMemo(
() => findPendingSessionInputRequest(messages),
[messages],
diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx
index 6e15efeb4d..beb5c43d06 100644
--- a/apps/web/src/components/settings/Integrations.tsx
+++ b/apps/web/src/components/settings/Integrations.tsx
@@ -29,8 +29,6 @@ import {
useGranolaConnection,
useElevenLabsConnection,
useVoiceConnection,
- useDeploymentMcpEnablements,
- useMcpOauthReadiness,
useEffectiveMcpIntegrations,
useNotionConnection,
useRipplingConnection,
From 64ea28720abe6ae1f1e8cdce3579a581244e8bc4 Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Fri, 11 Sep 2026 16:44:15 +0100
Subject: [PATCH 18/23] fix: use effective integrations for voice status
---
apps/web/src/components/settings/Integrations.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx
index beb5c43d06..bcd8ceb390 100644
--- a/apps/web/src/components/settings/Integrations.tsx
+++ b/apps/web/src/components/settings/Integrations.tsx
@@ -1749,8 +1749,8 @@ export function Integrations({
);
const voiceConnectionSummary = useMemo(
() =>
- (userMcpConnections.data ?? []).find((entry) => entry.mcpId === 'voice'),
- [userMcpConnections.data],
+ (effectiveIntegrations.data ?? []).find((entry) => entry.id === 'voice'),
+ [effectiveIntegrations.data],
);
const isVoiceConnected =
voiceConnectionSummary?.authStatus === 'authenticated';
From e73e244e2ee2888e8827777c161a11ec8e888ba7 Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Fri, 11 Sep 2026 16:44:31 +0100
Subject: [PATCH 19/23] feat: personalize guided setup opening
---
.../src/server/fast-agent/fast-agent-prompt.ts | 6 +++++-
.../src/server/fast-agent/fast-agent-setup-tools.test.ts | 9 +++++++--
2 files changed, 12 insertions(+), 3 deletions(-)
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 4bef732acc..621e7e40f7 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
@@ -336,7 +336,11 @@ ${
setupSession
? `
## First Roomote Interaction
-This is often the user's first interaction with Roomote. Make the experience welcoming and orienting: introduce myself, briefly explain what I can help with, and state what I need from the user next. For example: "Hi, I'm Roomote. I can answer questions about your code, fix issues, review pull requests, automate recurring work, and more. To get started, I need access to your source code." Err on the side of human context, not implementation detail. Setup snapshots, platform events, trusted presets, lifecycle, durable intent, \`launch_task\`, and other internal state labels are instructions for you, not language to expose to the user.
+This is often the user's first interaction with Roomote. Make the opening feel like a relationship, not a configuration checklist. On the first setup interaction, start with "Hi, I'm Roomote." Then briefly explain that I can answer questions about their code, fix issues, review pull requests, automate recurring work, and more. Then ask exactly: "What should I call you?" Do not ask about GitHub, source control, tools, or setup capabilities in that first message.
+
+After the user answers the name question, immediately use \`update_personalization\` with confidence \`explicit\` to save one concise preference in the form "Call me ." Never expose the tool or private personalization in the reply. Then ask exactly: "What have you been working on?"
+
+After the user answers what they have been working on, immediately use \`update_personalization\` with confidence \`explicit\` to save one concise preference in the form "Currently working on ." Then continue with the existing setup agenda, starting with the capability needed next (usually source control). Do not re-ask either question when the conversation history already contains the answer and the corresponding personalization update has been completed. These two questions are ordinary free-text conversation, not \`request_user_input\` cards. Err on the side of human context, not implementation detail. Setup snapshots, platform events, trusted presets, lifecycle, durable intent, \`launch_task\`, and other internal state labels are instructions for you, not language to expose to the user.
## Conversational Setup
You are guiding this deployment's first administrator from runtime readiness to optional starter work.
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 c7bfbc0f08..4254b418a9 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
@@ -52,9 +52,14 @@ describe('setup prompt guidance and snapshot injection', () => {
expect(prompt).toContain(
"This is often the user's first interaction with Roomote",
);
- expect(prompt).toContain("Hi, I'm Roomote");
+ expect(prompt).toContain("Hi, I'm Roomote.");
+ expect(prompt).toContain('What should I call you?');
+ expect(prompt).toContain('What have you been working on?');
+ expect(prompt).toContain('update_personalization');
+ expect(prompt).toContain('Call me .');
+ expect(prompt).toContain('Currently working on .');
expect(prompt).toContain(
- 'To get started, I need access to your source code.',
+ 'Do not ask about GitHub, source control, tools, or setup capabilities in that first message.',
);
expect(prompt).toContain(
'always refer to Roomote in the first person: use "I", "me", and "my"',
From 37a50d3e466b55a356ae44dca3ca28c0c58a5115 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Fri, 11 Sep 2026 16:18:06 +0000
Subject: [PATCH 20/23] fix: preserve hidden voice delivery rows
---
.../[sessionId]/FastSessionTranscript.tsx | 34 +++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index d69f654a38..269961ac84 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -719,6 +719,40 @@ export function FastSessionTranscript({
userImageUrl: message.userImageUrl,
});
+ // Keep the persisted source in the UI pipeline so it reconciles the
+ // streamed reply in place, but hide this internal voice delivery.
+ if (
+ message.role === 'assistant' &&
+ message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage &&
+ (message.metadata as { voiceCommentary?: unknown } | null)
+ ?.voiceCommentary === true
+ ) {
+ const text = getTranscriptMessageText(message) ?? '';
+ uiMessage = toAcpUiMessage({
+ id: `assistant:${message.eventId}`,
+ ts: message.ts,
+ eventType: ACP_ENVELOPE_EVENT_TYPES.ToolResult as AcpEventType,
+ role: 'tool',
+ kind: 'tool_result',
+ contentBlocks: [{ type: 'text', text }],
+ metadata: {
+ visibleInTranscript: false,
+ toolCallId: message.eventId,
+ },
+ payload: {
+ toolName: 'report_to_voice',
+ toolCallId: message.eventId,
+ status: 'completed',
+ rawInput: {},
+ output: text,
+ },
+ text,
+ userName: null,
+ userEmail: null,
+ userImageUrl: null,
+ });
+ }
+
if (
message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse
) {
From 93a6ab5d50d358bcfd633a5a92a0bc26ab890f4a Mon Sep 17 00:00:00 2001
From: Roomote
Date: Fri, 11 Sep 2026 16:29:29 +0000
Subject: [PATCH 21/23] fix: remove setup profile questions
---
.../src/server/fast-agent/fast-agent-prompt.ts | 6 +-----
.../src/server/fast-agent/fast-agent-setup-tools.test.ts | 9 ++-------
2 files changed, 3 insertions(+), 12 deletions(-)
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 621e7e40f7..4bef732acc 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
@@ -336,11 +336,7 @@ ${
setupSession
? `
## First Roomote Interaction
-This is often the user's first interaction with Roomote. Make the opening feel like a relationship, not a configuration checklist. On the first setup interaction, start with "Hi, I'm Roomote." Then briefly explain that I can answer questions about their code, fix issues, review pull requests, automate recurring work, and more. Then ask exactly: "What should I call you?" Do not ask about GitHub, source control, tools, or setup capabilities in that first message.
-
-After the user answers the name question, immediately use \`update_personalization\` with confidence \`explicit\` to save one concise preference in the form "Call me ." Never expose the tool or private personalization in the reply. Then ask exactly: "What have you been working on?"
-
-After the user answers what they have been working on, immediately use \`update_personalization\` with confidence \`explicit\` to save one concise preference in the form "Currently working on ." Then continue with the existing setup agenda, starting with the capability needed next (usually source control). Do not re-ask either question when the conversation history already contains the answer and the corresponding personalization update has been completed. These two questions are ordinary free-text conversation, not \`request_user_input\` cards. Err on the side of human context, not implementation detail. Setup snapshots, platform events, trusted presets, lifecycle, durable intent, \`launch_task\`, and other internal state labels are instructions for you, not language to expose to the user.
+This is often the user's first interaction with Roomote. Make the experience welcoming and orienting: introduce myself, briefly explain what I can help with, and state what I need from the user next. For example: "Hi, I'm Roomote. I can answer questions about your code, fix issues, review pull requests, automate recurring work, and more. To get started, I need access to your source code." Err on the side of human context, not implementation detail. Setup snapshots, platform events, trusted presets, lifecycle, durable intent, \`launch_task\`, and other internal state labels are instructions for you, not language to expose to the user.
## Conversational Setup
You are guiding this deployment's first administrator from runtime readiness to optional starter work.
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 4254b418a9..c7bfbc0f08 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
@@ -52,14 +52,9 @@ describe('setup prompt guidance and snapshot injection', () => {
expect(prompt).toContain(
"This is often the user's first interaction with Roomote",
);
- expect(prompt).toContain("Hi, I'm Roomote.");
- expect(prompt).toContain('What should I call you?');
- expect(prompt).toContain('What have you been working on?');
- expect(prompt).toContain('update_personalization');
- expect(prompt).toContain('Call me .');
- expect(prompt).toContain('Currently working on .');
+ expect(prompt).toContain("Hi, I'm Roomote");
expect(prompt).toContain(
- 'Do not ask about GitHub, source control, tools, or setup capabilities in that first message.',
+ 'To get started, I need access to your source code.',
);
expect(prompt).toContain(
'always refer to Roomote in the first person: use "I", "me", and "my"',
From 70e2da5c366e6ef2419d0fedf3e73eb50cce0dd2 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Fri, 11 Sep 2026 17:24:01 +0000
Subject: [PATCH 22/23] test: keep declarative environment fixture invalid
---
packages/db/src/lib/__tests__/declarative-environments.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/db/src/lib/__tests__/declarative-environments.test.ts b/packages/db/src/lib/__tests__/declarative-environments.test.ts
index 846798a43e..2d40ad1531 100644
--- a/packages/db/src/lib/__tests__/declarative-environments.test.ts
+++ b/packages/db/src/lib/__tests__/declarative-environments.test.ts
@@ -299,7 +299,7 @@ describe('declarative environments', () => {
await writeFile(
path.join(definitionsDir, 'a-invalid.yaml'),
- YAML.stringify({ name: 'missing repositories' }),
+ YAML.stringify({ description: 'missing name' }),
);
await writeFile(
path.join(definitionsDir, 'b-broken.yaml'),
From 5340f39c8525665fc4ae2cd4b947fba1e97eecd8 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Fri, 11 Sep 2026 17:27:06 +0000
Subject: [PATCH 23/23] test: isolate provider usage responses
---
packages/db/src/lib/__tests__/provider-usage-limits.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/db/src/lib/__tests__/provider-usage-limits.test.ts b/packages/db/src/lib/__tests__/provider-usage-limits.test.ts
index eea750767b..d294c3ac77 100644
--- a/packages/db/src/lib/__tests__/provider-usage-limits.test.ts
+++ b/packages/db/src/lib/__tests__/provider-usage-limits.test.ts
@@ -192,7 +192,7 @@ describe('getProviderUsageLimitSnapshots', () => {
access: 'chatgpt-access',
accountId: 'acct-1',
});
- const fetchImpl = vi.fn().mockResolvedValue(
+ const fetchImpl = vi.fn().mockImplementation(async () =>
jsonResponse({
plan_type: 'pro',
rate_limit: {