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/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx index 8809652286..1d93032ae6 100644 --- a/apps/docs/self-hosting.mdx +++ b/apps/docs/self-hosting.mdx @@ -202,6 +202,19 @@ 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 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 +**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. +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 fd0aafce29..ca7505e714 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 @@ -179,6 +179,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[] = []; @@ -482,14 +485,93 @@ describe('FastSessionTranscript', () => { }); }); - it('removes a structured-input card when its response control event arrives', () => { - const requestId = 'rui:setup-starters'; + 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.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: 'starter-request', + id: 'input-request', role: 'assistant', - text: 'Choose starter tasks', - ts: 1, + text: question, + ts: 2, }), eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, payload: { @@ -498,30 +580,31 @@ describe('FastSessionTranscript', () => { 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, + id: 'direction', + header: 'Direction', + question, + isOther: true, isSecret: false, - options: [{ label: 'Speed up CI', description: 'Improve CI.' }], }, ], }, }; const response = { ...textMessage({ - id: 'starter-response', + id: 'input-response', role: 'user', - text: 'Structured response', - ts: 2, + text: 'Legacy persisted answer', + ts: 3, }), eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse, payload: { requestId, - answers: { starters: { answers: ['Speed up CI'] } }, + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + answers: { direction: { answers: ['Use the narrow path'] } }, resolution: 'submitted', }, }; @@ -529,12 +612,313 @@ describe('FastSessionTranscript', () => { 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.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({ + 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( + , ); - expect(screen.queryByText('Structured input request')).toBeNull(); - expect(screen.queryByText('Structured response')).toBeNull(); + 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([ @@ -1942,7 +2326,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 72ed37fe06..ea9581a36f 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, @@ -63,6 +66,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 { @@ -101,6 +105,33 @@ function getTranscriptMessageText(message: TranscriptMessage) { : text; } +function shouldSuppressRequestUserInputToolMessage( + message: TranscriptMessage, + requestTurnIds: ReadonlySet, +) { + 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; + status?: unknown; + } | null; + const isRequestUserInput = + payload?.toolName === 'request_user_input' || + payload?.title === 'request_user_input'; + return ( + isRequestUserInput && + payload?.status !== 'failed' && + requestTurnIds.has(message.turnId) + ); +} + type PendingResponseState = { pendingAfter: TranscriptOrder | null; latestVisibleResponse: TranscriptOrder | null; @@ -589,59 +620,148 @@ 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, + 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, 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); + turnIds.add(request.turnId); + } + } + return { + requestUserInputById: requests, + requestUserInputTurnIds: turnIds, + }; + }, [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 || + shouldSuppressRequestUserInputToolMessage( + message, + requestUserInputTurnIds, ) - .map((message) => { - 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, - }); + ) { + continue; + } - if ( - uiMessage.role !== 'user' || - !owner || - uiMessage.userId !== owner.userId - ) { - return uiMessage; - } + 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, + }); - return { - ...uiMessage, - userName: uiMessage.userName ?? owner.name, - userEmail: uiMessage.userEmail ?? owner.email, - userImageUrl: uiMessage.userImageUrl ?? owner.imageUrl, - }; - }), - [messages, owner], - ); + 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, + requestUserInputTurnIds, + ]); const hasVisibleAssistantMessage = useMemo( () => messages.some( @@ -652,10 +772,6 @@ export function FastSessionTranscript({ ), [messages], ); - const pendingInputRequest = useMemo( - () => findPendingSessionInputRequest(messages), - [messages], - ); const reviewOffers = useMemo( () => messages.flatMap((message) => { @@ -664,15 +780,37 @@ export function FastSessionTranscript({ }), [messages], ); - const uiMessages = useMemo( - () => - streamMessages.length === 0 - ? persistedUiMessages - : [...persistedUiMessages, ...streamMessages], - [persistedUiMessages, streamMessages], - ); - const { renderBlocks, suppressMessage } = useAcpTranscriptBlocks({ - messages: uiMessages, + const { uiMessagesBeforeInput, uiMessagesAfterInput } = useMemo(() => { + if (!pendingInputRequestOrder) { + return { + uiMessagesBeforeInput: [ + ...persistedBeforeInput, + ...persistedAfterInput, + ...streamMessages, + ], + 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, + ]); + const { + renderBlocks: renderBlocksBeforeInput, + suppressMessage: suppressMessageBeforeInput, + } = useAcpTranscriptBlocks({ + messages: uiMessagesBeforeInput, artifacts: [], displayMode, initialPrompt: null, @@ -680,7 +818,21 @@ export function FastSessionTranscript({ showInternalMessages: false, hasLeadingTextBoundary: false, keepDelegatedTasksVisible: true, - resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, + resetKey: `before:${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, + }); + const { + renderBlocks: renderBlocksAfterInput, + suppressMessage: suppressMessageAfterInput, + } = useAcpTranscriptBlocks({ + messages: uiMessagesAfterInput, + artifacts: [], + displayMode, + initialPrompt: null, + shouldHideFirstMessage: false, + showInternalMessages: false, + hasLeadingTextBoundary: false, + keepDelegatedTasksVisible: true, + resetKey: `after:${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, }); const sendReply = useCallback( @@ -815,9 +967,36 @@ export function FastSessionTranscript({

) : null} + {pendingInputRequest ? ( +
+ {pendingInputRequest.preset === 'setup_starter_tasks' ? ( + + ) : pendingInputRequest.preset === 'setup_integrations' ? ( + + ) : ( + + )} +
+ ) : null} + {hasVisibleAssistantMessage ? timelineExtras : null} @@ -848,25 +1027,10 @@ export function FastSessionTranscript({ } /> ))} - {pendingInputRequest ? ( -
- {pendingInputRequest.preset === 'setup_starter_tasks' ? ( - - ) : ( - - )} -
- ) : null} - {canReply && !pendingInputRequest ? ( + {canReply && !pendingInputRequest?.preset ? (
{ 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} + ); + 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." + > + {authFailed ? ( +

+ Authorization didn't finish. You can try connecting again or + continue without it. +

+ ) : null} + {statusError ? ( +

+ I couldn't load connection status. You can still connect a tool + or keep going. +

+ ) : null} + {effectiveIntegrations.data?.some( + (integration) => integration.status === 'unavailable', + ) ? ( +

+ 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 effective = effectiveById.get(integration.id); + const unavailable = effective?.status === 'unavailable'; + return ( +
  • + {definition ? ( + + ) : null} +
    + {integration.name} + {statusPending ? ( + + ) : unavailable || status || statusError ? ( +

    + {unavailable + ? 'Unavailable on this instance' + : (status ?? 'Status unavailable')} +

    + ) : null} +
    + +
  • + ); + })} +
+ {keepGoing} + {continuationError} + { + 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) && + effectiveById.get(active.id)?.status === 'needs_connection' ? ( + + ) : null} + + + + + +
+ ); +} diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 4ad0f023ba..c256994e44 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'; @@ -94,6 +95,7 @@ const state = vi.hoisted(() => ({ }, }, linearRedirectPath: '', + pathname: '/settings/integrations', searchParams: '', })); @@ -150,7 +152,7 @@ function cloneMcpToolsData() { } vi.mock('next/navigation', () => ({ - usePathname: () => '/settings/integrations', + usePathname: () => state.pathname, useSearchParams: () => new URLSearchParams(state.searchParams), })); @@ -227,6 +229,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, @@ -515,6 +545,7 @@ describe('Integrations settings', () => { linearOrganizationName: 'Roomote', }; state.linearRedirectPath = ''; + state.pathname = '/settings/integrations'; state.asanaConnection = null; state.notionConnection = null; state.ripplingConnection = null; @@ -552,6 +583,60 @@ describe('Integrations settings', () => { ); }); + it('renders only requested integrations in passed order without custom servers or groups', () => { + render(); + expect( + screen.getAllByRole('heading').map((heading) => heading.textContent), + ).toEqual(['Integrations', 'Notion', 'Sentry', 'Linear']); + expect(screen.queryByText('Add custom server')).not.toBeInTheDocument(); + }); + + it('does not leak custom servers when filtered integrations are disabled', () => { + state.integrationsEnabled = false; + render(); + expect( + screen.getByText('Integrations disabled by deployment operator'), + ).toBeInTheDocument(); + expect(screen.queryByText('Add custom server')).not.toBeInTheDocument(); + }); + + it('preserves the embedded pathname for Linear and MCP OAuth', () => { + state.pathname = '/sessions/setup-session'; + state.linearInstallation = null; + render(); + expect(state.linearRedirectPath).toBe('/sessions/setup-session'); + fireEvent.click( + screen.getByRole('button', { name: 'Connect and enable Pylon' }), + ); + expect(mutations.connectMcp).toHaveBeenCalledWith( + { mcpId: 'pylon', redirectTo: '/sessions/setup-session' }, + expect.any(Object), + ); + }); + + it('keeps filtered deployment configuration read-only for non-admins', () => { + state.isAdmin = false; + render(); + expect(screen.getByRole('heading', { name: 'Notion' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Configure Notion' }), + ).not.toBeInTheDocument(); + }); + + it('clears an embedded secret on cancellation without saving', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' })); + fireEvent.change(screen.getByLabelText('Internal integration secret'), { + target: { value: 'test-secret' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(mutations.saveNotionConnection).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' })); + expect(screen.getByLabelText('Internal integration secret')).toHaveValue( + '', + ); + }); + it('uses the settings action for missing Linear OAuth setup', () => { state.linearInstallation = null; state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index 21332eae1b..b598b1d788 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -21,13 +21,11 @@ import { import { useAsanaConnection, useConnectMcp, - useCuratedIntegrationsAvailability, useDisconnectMcp, useGrafanaConnection, useGranolaConnection, useElevenLabsConnection, - useDeploymentMcpEnablements, - useMcpOauthReadiness, + useEffectiveMcpIntegrations, useNotionConnection, useRipplingConnection, useSaveAsanaConnection, @@ -41,7 +39,6 @@ import { useSaveXConnection, useSetDeploymentMcpEnabled, useSnowflakeConnection, - useUserMcpConnections, useVercelConnection, useXConnection, } from '@/hooks/mcp-connections'; @@ -1379,7 +1376,11 @@ function VercelConnectionFields({ ); } -export function Integrations() { +export function Integrations({ + integrationIds, +}: { + integrationIds?: readonly string[]; +} = {}) { const pathname = usePathname(); const searchParams = useSearchParams(); const { isAdmin } = useAuthorizedUser(); @@ -1479,22 +1480,21 @@ 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(); - 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(); @@ -1507,12 +1507,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( @@ -1520,8 +1520,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 && @@ -1533,10 +1533,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 && @@ -1547,72 +1547,72 @@ 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( isAdmin && (isGranolaConnected || isGranolaDialogOpen), ); 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), @@ -1780,13 +1780,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; @@ -1845,7 +1845,7 @@ export function Integrations() { isMcpBased: false, isPending: linearInstallation.isPending || - (!linearInstallation.data && oauthReadiness.isPending) || + (!linearInstallation.data && effectiveIntegrations.isPending) || connectLinear.isPending || disconnectLinear.isPending, status: linearOauthUnavailable @@ -2228,7 +2228,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, @@ -2242,7 +2248,7 @@ export function Integrations() { linearOauthSetup.isPending, linearOauthStatus, linearOauthUnavailable, - oauthReadiness.isPending, + effectiveIntegrations.isPending, isAdmin, isGrafanaDialogOpen, isGranolaDialogOpen, @@ -2255,8 +2261,9 @@ export function Integrations() { saveGranolaConnection.isPending, saveElevenLabsConnection.isPending, saveVercelConnection.isPending, - deploymentEnablements.data, + effectiveIntegrations.data, pathname, + integrationIds, setDeploymentEnabled, saveSnowflakeConnection.isPending, asanaConnection.isPending, @@ -2275,7 +2282,6 @@ export function Integrations() { xConnection.isPending, isXDialogOpen, highlightedIntegrationId, - userMcpConnections.data, ]); const { @@ -2896,7 +2902,11 @@ export function Integrations() { }); }; - if (integrationsAvailability.data?.enabled === false) { + if ( + effectiveIntegrations.data?.some( + (integration) => integration.status === 'unavailable', + ) + ) { return (
@@ -2906,7 +2916,7 @@ export function Integrations() { instance. - {customMcpEnabled ? ( + {integrationIds === undefined && customMcpEnabled ? ( <> {customMcpDialogs} @@ -3170,32 +3180,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/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 20b082f510..9e05b9e407 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 ab959e372b..2bda8b99f8 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,10 @@ 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(), sql: vi.fn(), })); @@ -31,10 +35,12 @@ 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, resolveApiBaseUrl: vi.fn(), + upsertFastAgentMessage: mocks.upsertMessage, })); vi.mock('@roomote/sdk/server', () => ({ @@ -82,6 +88,12 @@ vi.mock('./pinned-launch', () => ({ startPinnedFastSessionLaunch: mocks.startPinnedLaunch, })); +vi.mock('../setup/setup-session', () => ({ + reconcileSetupPlatformEvents: mocks.reconcileSetupEvents, + resolveSetupSessionTurnContext: mocks.resolveSetupContext, + submitSetupSessionUserInputCommand: mocks.submitSetupInput, +})); + import { getFastSessionTasksCommand, handleFastSessionPrReviewActionCommand, @@ -90,6 +102,7 @@ import { startFastSessionCommand, startSetupFastSessionCommand, updateFastSessionModelSelectionCommand, + submitFastSessionUserInputCommand, } from './index'; describe('getFastSessionTasksCommand', () => { @@ -140,6 +153,459 @@ 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, + setupContext: { + sessionId: 'session-1', + fastConversationId: 'session-1', + setupSnapshot: initialSnapshot, + starterTaskOptions: [], + }, + }; + 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.after.mockReset(); + 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); + 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 () => { + 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, + setupContext: { + ...setupContext.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', + }), + }), + }), + ); + 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'])( + '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, + setupContext: { + ...setupContext.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('recovers a saved response when the original process died before scheduling', 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([]); + mocks.resolveSetupContext.mockResolvedValue({ + ...setupContext, + setupSnapshot: freshSnapshot, + setupContext: { + ...setupContext.setupContext, + 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).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 + .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('collapses contending response claimants to one completed turn', async () => { + mocks.dbSelectLimit + .mockResolvedValueOnce([request]) + .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); + }); + + // 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).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 () => { + 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('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( + submitFastSessionUserInputCommand(auth, input), + ).rejects.toThrow('Unauthorized'); + expect(mocks.upsertMessage).not.toHaveBeenCalled(); + expect(mocks.after).not.toHaveBeenCalled(); + }); +}); + const auth = { userId: 'user-1', isAdmin: false, diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts index 0db5c6f25d..5f1d815f9f 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, @@ -43,11 +44,14 @@ import { formatErrorForLog, getAcpRequestUserInputValidationError, getUserDisplayName, + isSetupIntegrationDiscoveryQuestionId, parseAcpRequestUserInputAnswers, parseAcpRequestUserInputPayload, parseAcpRequestUserInputResponsePayload, + normalizeAcpRequestUserInputAnswers, type AcpRequestUserInputAnswers, type AcpRequestUserInputPayload, + type FastAgentSetupTurnContext, type ReasoningEffort, } from '@roomote/types'; import type { FastAgentTurnAdapter } from '@roomote/cloud-agents/server'; @@ -158,6 +162,7 @@ type WebFastAgentTurnInput = { skipIfTurnCompleted?: { conversationId: string; turnId: string }; setupSnapshot?: string; setupSession?: boolean; + setupContext?: FastAgentSetupTurnContext; adapterExtensions?: Partial; }; @@ -208,6 +213,7 @@ async function runWebFastAgentTurn({ platformEventVisibility, setupSnapshot, setupSession, + setupContext, adapterExtensions, durableSessionId, }: WebFastAgentTurnInput): Promise { @@ -256,11 +262,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: { @@ -281,6 +286,7 @@ async function runWebFastAgentTurn({ } : {}), ...(setupSession ? { setupSession: true } : {}), + ...(setupContext ? { setupContext } : {}), }, }).catch((error) => { console.error( @@ -321,7 +327,9 @@ async function runWebFastAgentTurn({ ...(platformEventVisibility ? { platformEventVisibility } : {}), } : {}), - ...(setupSnapshot ? { setupSnapshot } : {}), + ...(setupContext?.setupSnapshot || setupSnapshot + ? { setupSnapshot: setupContext?.setupSnapshot ?? setupSnapshot } + : {}), setupSession, adapter: { resolveMcpServerConfigs: () => @@ -348,6 +356,7 @@ async function runWebFastAgentTurn({ } : {}), ...delivery.adapter, + ...(setupContext ? buildFastAgentSetupAdapter(setupContext) : {}), ...adapterExtensions, }, }); @@ -666,6 +675,9 @@ export async function replyToFastSessionCommand( if (!session) { throw new Error('Fast session not found'); } + const { resolveSetupSessionTurnContext } = + await import('../setup/setup-session'); + const setupContext = await resolveSetupSessionTurnContext(auth, session.id); const senderDisplayName = getUserDisplayName({ name: auth.name, email: auth.primaryEmail }) ?? null; @@ -708,6 +720,7 @@ export async function replyToFastSessionCommand( reasoningEffort: settings.reasoningEffort, ...(senderDisplayName ? { senderDisplayName } : {}), durableSessionId: session.id, + ...setupContext, }); return { success: true }; @@ -763,6 +776,7 @@ export async function submitFastSessionUserInputCommand( adapterExtensions?: Partial; setupSnapshot?: string; setupSession?: boolean; + setupContext?: FastAgentSetupTurnContext; persistSetupPresetResponse?: (input: { fastConversationId: string; request: { @@ -778,6 +792,13 @@ export async function submitFastSessionUserInputCommand( if (!session) { throw new Error('Fast session not found'); } + 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); const [request] = await db .select({ @@ -818,7 +839,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.'); @@ -831,8 +856,32 @@ export async function submitFastSessionUserInputCommand( if (validationError) { throw new Error(validationError); } + if (requestPayload.preset && existingResponse) { + if (setupContext) await reconcileSetupPlatformEvents(auth); + 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 = (answers: AcpRequestUserInputAnswers) => { + const scheduleResponseTurn = async ( + answers: AcpRequestUserInputAnswers, + responseResolution: 'submitted' | 'cancelled', + ) => { + const freshSetupContext = setupContext + ? await resolveSetupSessionTurnContext(auth, session.id) + : null; + const skippedDiscovery = + freshSetupContext && + requestPayload.questions.some((question) => + isSetupIntegrationDiscoveryQuestionId(question.id), + ); + if (responseResolution === 'cancelled' && !skippedDiscovery) return; const responseTurnId = `input-response:${input.requestId}`; const conversation = session.surface === 'automation' @@ -861,6 +910,9 @@ export async function submitFastSessionUserInputCommand( question: `${JSON.stringify({ requestId: input.requestId, answers, + ...(responseResolution === 'cancelled' + ? { resolution: responseResolution } + : {}), })}`, turnSource: 'platform_event', platformEventKind: 'input_response', @@ -877,25 +929,17 @@ 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?.resolution === 'submitted' - ) { - scheduleResponseTurn(persistedResponse.answers); - } - 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.'); } @@ -910,39 +954,71 @@ export async function submitFastSessionUserInputCommand( }); return { success: true }; } - await upsertFastAgentMessage({ - sessionId: session.id, - 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, + 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, + turnId: request.turnId, + callId: input.requestId, + answers: responseAnswers, + resolution: responseResolution, + }, + source: 'web', }, - source: 'web', - }, - }); - - if (resolution === 'cancelled') return { success: true }; - scheduleResponseTurn(submitted); + }); + 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/apps/web/src/trpc/commands/mcp-connections/index.test.ts b/apps/web/src/trpc/commands/mcp-connections/index.test.ts index e424d10891..edccd6fe01 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.test.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.test.ts @@ -25,6 +25,7 @@ import type { UserAuthSuccess } from '@/types'; import { connectMcpCommand, + getEffectiveMcpIntegrationsCommand, saveAsanaConnectionCommand, setDeploymentMcpEnabledCommand, } from './index'; @@ -35,6 +36,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); @@ -44,6 +50,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 () => { @@ -144,4 +151,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 f0d53b6349..30704ad475 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -13,6 +13,7 @@ import { getDefaultMcpConnectionRole, getAllowedIntegrationMcpToolNames, getMcpIntegration, + getMcpIntegrationConnectionMode, getMcpIntegrationConnectionScope, getMcpIntegrationDefaultDisabledTools, type McpConnectionRole, @@ -31,6 +32,7 @@ import { MCP_INTEGRATIONS, normalizeGrafanaBaseUrl, type McpIntegration, + type EffectiveMcpIntegration, type McpToolsListJsonRpcPayload, parseMcpJsonRpcPayload, } from '@roomote/types'; @@ -571,6 +573,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 new file mode 100644 index 0000000000..82ed4e1ab7 --- /dev/null +++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts @@ -0,0 +1,614 @@ +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', async (importOriginal) => ({ + ...(await importOriginal()), + 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(answer = 'continue') { + 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: [answer] } }, + }); + } + + 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', setupCompletedAt: null, setupNewState: state }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { setupCompletedAt: null, 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('completes zero-match discovery server-side without a browser response', 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).toEqual([]); + expect(mocks.schedule).toHaveBeenCalledOnce(); + expect(mocks.schedule).toHaveBeenCalledWith( + expect.objectContaining({ + platformEventKind: 'setup', + setupSession: 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(0); + await expect( + (await context()).adapterExtensions.resolveUserInputPreset!( + 'setup_integrations', + ), + ).rejects.toThrow('already complete'); + }); + + 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, + ).integrationDiscovery; + expect(snapshot).toMatchObject({ + skipped: true, + completed: false, + matchedIntegrationIds: [], + }); + await reconcileSetupPlatformEvents(auth); + expect(mocks.schedule).toHaveBeenCalledOnce(); + const questions = await ( + await context() + ).adapterExtensions.resolveUserInputPreset!('setup_integrations'); + expect(questions).toEqual([]); + 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('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)); + 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('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('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('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, + isAdmin: true, + } as UserAuthSuccess; + await db + .update(deploymentSettings) + .set({ setupCompletedAt: new Date() }) + .where(eq(deploymentSettings.id, 'default')); + 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: { 'setup-integrations': { answers: ['continue'] } }, + }), + ).resolves.toEqual({ success: true }); + expect(mocks.submit).toHaveBeenCalledWith( + collaboratorAuth, + 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)); + } + }); + + 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('coalesces setup changes into one deterministic turn without discovery-first dropping', 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(['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); + 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: true }); + } + 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]) => + 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) => ({ + id: task.id, + 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, + ), + ).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 a25ab4064e..b3920bf041 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, @@ -22,9 +22,19 @@ 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, + type FastAgentSetupTurnContext, } from '@roomote/types'; import { captureEvent } from '@roomote/telemetry/server'; @@ -51,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' @@ -106,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; @@ -119,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: { @@ -191,6 +207,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 +219,7 @@ function buildSetupSnapshot(input: { ); return JSON.stringify({ + integrationDiscovery: input.integrationDiscovery, rail: deriveSetupRailMilestones(input.status), sourceControl: { selectedProvider: state.sourceControlProvider, @@ -225,27 +245,159 @@ 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, + {}, + conversation, + ), hasSuccessfulStarterLaunch: setupSession?.starterTaskSelection ? await hasSuccessfulSetupSessionTaskLaunch( auth, setupSession.starterTaskSelection.selectedAt, + conversation, ) : false, }); } +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 = {}, + conversationOverride?: SetupSessionConversation, +) { + const state = await readSetupNewState(); + const setupSession = normalizeSetupNewSetupSession(state.setupSession); + const conversation = + conversationOverride ?? (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, + 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 }) @@ -299,38 +451,6 @@ function deriveSetupRailMilestones( }; } -async function buildSetupSessionAdapterExtensions( - auth: UserAuthSuccess, -): Promise> { - return { - resolveUserInputPreset: async (preset) => { - 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: { @@ -362,6 +482,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, @@ -400,10 +523,14 @@ async function buildSetupPlatformEventTurn( conversationId: conversation.fastConversationId, turnId: currentMessageId, }, - adapterExtensions: await buildSetupSessionAdapterExtensions(auth), setupSession: true, - setupSnapshot: - prepared?.setupSnapshot ?? (await resolveSetupSnapshot(auth)), + setupContext, + adapterExtensions: buildFastAgentSetupAdapter(setupContext, { + onIntegrationDiscoveryCompleted: async () => { + await reconcileSetupPlatformEvents(auth); + }, + }), + durableSessionId: conversation.fastConversationId, }; } @@ -413,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 || @@ -428,11 +557,18 @@ export async function reconcileSetupPlatformEvents( ? await hasSuccessfulSetupSessionTaskLaunch( auth, setupSession.starterTaskSelection.selectedAt, + conversation, ) : false; + const integrationDiscovery = await readSetupIntegrationDiscovery( + auth, + {}, + conversation, + ); const setupSnapshot = buildSetupSnapshot({ status, hasSuccessfulStarterLaunch, + integrationDiscovery, }); const connected = status.sourceControlSetup.providers.filter( @@ -616,13 +752,27 @@ export async function reconcileSetupPlatformEvents( { allowAfterSetupCompletion: true }, ); - for (const event of events) { - const turn = await buildSetupPlatformEventTurn(auth, event, { - conversation, - setupSnapshot, - }); - 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; } @@ -802,10 +952,22 @@ 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 || + !( + [ + 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.'); + } await db.transaction(async (tx) => { await tx.execute( @@ -860,7 +1022,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 +1030,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(); @@ -900,10 +1066,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, @@ -914,29 +1087,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], + }); }); } @@ -949,7 +1123,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 && @@ -957,14 +1145,61 @@ export async function submitSetupSessionUserInputCommand( ) { throw new Error('This input request does not belong to the setup Session.'); } + const setupSnapshot = await resolveSetupSnapshot(auth, setupConversation); 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 }); - await reconcileSetupPlatformEvents(auth); + await reconcileSetupPlatformEvents(auth, { + conversation: setupConversation, + }); return result; }, }); } + +/** Attach setup capabilities to ordinary replies as well as structured input turns. */ +export async function resolveSetupSessionTurnContext( + auth: UserAuthSuccess, + sessionId: string, +) { + 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 + .select({ fastConversationId: sessions.fastConversationId }) + .from(sessions) + .where(eq(sessions.id, setupSession.sessionId)) + .limit(1); + if ( + 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); + return { + adapterExtensions: buildFastAgentSetupAdapter(setupContext, { + onIntegrationDiscoveryCompleted: async () => { + await reconcileSetupPlatformEvents(auth); + }, + }), + 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 451bdd9683..6b3516bfe7 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -242,6 +242,7 @@ import { } from '../commands/sandbox-session'; import { getDeploymentMcpEnablementsCommand, + getEffectiveMcpIntegrationsCommand, getCuratedIntegrationsAvailabilityCommand, getMcpOauthReadinessCommand, setDeploymentMcpEnabledCommand, @@ -1870,6 +1871,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-native-tool-schemas.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts index c214fa51dd..e69ae2fe29 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 @@ -478,6 +478,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 905eaf8d08..992a595549 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 @@ -1100,6 +1100,152 @@ 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('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) => { + 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/__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 c2e46fd888..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. */ @@ -174,12 +176,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 +212,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-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-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index 04eaa8cf49..83730ab245 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 @@ -609,7 +609,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), @@ -623,7 +623,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 a8f55ffade..6266871aa4 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 @@ -273,14 +273,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 and communication-provider configuration are out of scope. Never ask for them and never block activation on them. -- 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. -- 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. ` : '' } @@ -322,7 +324,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} - 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. @@ -427,7 +429,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." : '' } ${ @@ -473,10 +475,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-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 6433a635cf..5a82898a09 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 @@ -616,7 +616,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. @@ -624,16 +631,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, @@ -4502,9 +4526,19 @@ 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); + // 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..b57bd1779e --- /dev/null +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts @@ -0,0 +1,161 @@ +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, + lifecycle: { + onIntegrationDiscoveryCompleted?: () => Promise; + } = {}, +): 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); + await lifecycle.onIntegrationDiscoveryCompleted?.(); + 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 f61942bc36..357f353508 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 @@ -78,48 +78,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', ); @@ -132,6 +108,25 @@ describe('setup prompt guidance and snapshot injection', () => { expect(prompt).not.toContain('update_plan'); }); + it('keeps discovery optional, resumable, reorderable, and server-resolved', () => { + const prompt = buildFastAgentSystemPrompt({ + ...baseInput, + setupSession: true, + }); + for (const rule of [ + 'Optional integration discovery never gates setup completion', + 'ordered categories as suggestions, not a questionnaire', + 'reorder the agenda', + 'setup-tools-', + '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'); + }); + it('omits setup sections for ordinary sessions', () => { const prompt = buildFastAgentSystemPrompt(baseInput); @@ -147,12 +142,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-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.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 565793ed00..c8fa3eb53d 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 @@ -9,6 +9,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(), @@ -93,6 +94,7 @@ vi.mock('@roomote/communication', async (importOriginal) => ({ vi.mock('@roomote/cloud-agents/server', () => ({ acquireFastAgentTurnLock: mocks.acquireTurnLock, answerFastAgentQuestion: mocks.answerQuestion, + buildFastAgentSetupAdapter: mocks.buildSetupAdapter, resolveApiBaseUrl: () => 'https://roomote.example.com', fastAgentConversationRepository: { findById: mocks.findSession, @@ -640,6 +642,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' }, @@ -665,6 +673,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 71e700eef6..5ccd848bed 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, @@ -2224,6 +2225,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. */ @@ -2462,6 +2465,9 @@ export async function deliverFastAgentParentEventWithLock( (humanFollowUp ? 'human' : 'platform_event'), ...(humanFollowUp?.input ? { input: humanFollowUp.input } : {}), ...(humanFollowUp?.setupSession ? { setupSession: true } : {}), + ...(humanFollowUp?.setupContext + ? { setupSnapshot: humanFollowUp.setupContext.setupSnapshot } + : {}), ...(humanFollowUp ? { currentDurableHumanFollowUpEventId: humanFollowUp.eventId } : {}), @@ -2522,6 +2528,16 @@ export async function deliverFastAgentParentEventWithLock( createArtifact: buildFastAgentArtifactCreator(params.parent.sessionId), ...parentTurn.adapter, launchTask: parentTurn.adapter.launchTask, + ...(humanFollowUp?.setupContext + ? buildFastAgentSetupAdapter(humanFollowUp.setupContext, { + ...(params.onSetupIntegrationDiscoveryCompleted + ? { + onIntegrationDiscoveryCompleted: + params.onSetupIntegrationDiscoveryCompleted, + } + : {}), + }) + : {}), ...(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/sdk/src/server/routers/task-runs.test.ts b/packages/sdk/src/server/routers/task-runs.test.ts index f086ab5287..8181c0fc18 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 4c6da2392f..467e6b644b 100644 --- a/packages/sdk/src/server/routers/task-runs.ts +++ b/packages/sdk/src/server/routers/task-runs.ts @@ -982,7 +982,7 @@ export const taskRunsRouter = router({ clearPendingCommunicationRequestUserInput( input.provider, input.conversationId, - input.requestId ? { requestId: input.requestId } : undefined, + { requestId: input.requestId, runId: input.runId }, ), ), /** diff --git a/packages/types/src/acp-request-user-input.test.ts b/packages/types/src/acp-request-user-input.test.ts index 397fd8e3e1..a896637513 100644 --- a/packages/types/src/acp-request-user-input.test.ts +++ b/packages/types/src/acp-request-user-input.test.ts @@ -1,10 +1,13 @@ import { + formatRequestUserInputResponseText, getAcpRequestUserInputValidationError, + normalizeAcpRequestUserInputAnswers, parseAcpRequestUserInputAnswers, parseAcpRequestUserInputPayload, parseAcpRequestUserInputQuestion, parseAcpRequestUserInputRequestParams, parseAcpRequestUserInputResponsePayload, + resolveAcpRequestUserInputAnswer, } from './acp'; const singleQuestion = { @@ -102,12 +105,64 @@ 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, ).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'] }, @@ -135,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 e8b801e414..45ec26f47f 100644 --- a/packages/types/src/acp.ts +++ b/packages/types/src/acp.ts @@ -164,6 +164,8 @@ export const ACP_REQUEST_USER_INPUT_METHOD = export const ACP_REQUEST_USER_INPUT_REQUEST_ID_PREFIX = 'rui' as const; export interface AcpRequestUserInputQuestionOption { + /** Canonical option identity supplied by trusted server presets. */ + id?: string; label: string; description: string; } @@ -209,11 +211,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.'; @@ -223,6 +227,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; @@ -233,7 +265,7 @@ export interface AcpRequestUserInputRequestParams { export interface AcpRequestUserInputPayload extends AcpRequestUserInputRequestParams { requestId: string; status: 'pending'; - preset?: 'setup_starter_tasks'; + preset?: 'setup_starter_tasks' | 'setup_integrations'; } export interface AcpRequestUserInputResponsePayload { @@ -307,7 +339,8 @@ function parseAcpRequestUserInputQuestionOption( return null; } - return { label, description }; + const id = asStringOrNull(record?.id); + return { label, description, ...(id ? { id } : {}) }; } export function parseAcpRequestUserInputQuestion( @@ -405,7 +438,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; @@ -487,7 +523,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, }; } @@ -496,12 +534,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) => @@ -511,7 +554,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) { @@ -2460,8 +2506,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(', '); diff --git a/packages/types/src/fast-agent.ts b/packages/types/src/fast-agent.ts index fc2418c886..b22d777cac 100644 --- a/packages/types/src/fast-agent.ts +++ b/packages/types/src/fast-agent.ts @@ -231,6 +231,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), @@ -288,6 +305,9 @@ export const fastAgentHumanFollowUpEventSchema = z.object({ platformEventKind: fastAgentPlatformEventKindSchema.optional(), platformEventVisibility: fastAgentPlatformEventVisibilitySchema.optional(), setupSession: 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/index.ts b/packages/types/src/index.ts index 938e6b6cae..7cff7ab186 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -80,6 +80,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/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 215ec6bc27..0d68ce74e0 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -343,6 +343,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 = { 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, + ), + } + : {}), }; }