From 076d9685df2cfb9bb1bb7531fa223de982615366 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:21:28 -0500 Subject: [PATCH 01/30] [Fix] Render continue action when revisiting a saved Slack setup (#299) SlackSetupExperience's intro screen owns the step action buttons, and it is skipped when the Slack config is already saved (savedSatisfied). But StepAuthEnvVars's providerOwnsActions check was missing the matching !savedSatisfied clause, so revisiting the auth step with saved Slack credentials rendered the value form with no action button at all, stranding the user on the page. Align providerOwnsActions with the intro guard and add a regression test for the saved-Slack revisit path. Co-authored-by: Claude Opus 4.8 --- .../setup/StepAuthEnvVars.client.test.tsx | 45 +++++++++++++++++++ .../(onboarding)/setup/StepAuthEnvVars.tsx | 8 +++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx index a889886603..aa1d2290da 100644 --- a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx @@ -447,6 +447,51 @@ describe('StepAuthEnvVars', () => { ).toHaveAttribute('href', '/api/setup/roomote-logo'); }); + it('offers a continue action when a saved Slack config is revisited', () => { + const authSetup = buildAuthSetup('slack'); + const savedSlackAuthSetup: SetupAuthStatus = { + ...authSetup, + providers: authSetup.providers.map((provider) => + provider.id === 'slack' + ? { + ...provider, + savedSatisfied: true, + setupSatisfied: true, + fields: provider.fields.map((field) => ({ + ...field, + savedSatisfied: true, + savedValue: + field.envVarName === 'R_SLACK_CLIENT_ID' + ? '11040692082085.11578538885334' + : field.savedValue, + })), + } + : provider, + ), + }; + + render( + , + ); + + // Saved Slack skips the "Create Slack app" intro and shows the value form. + expect(screen.getByText('Enter the values below:')).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /create slack app/i }), + ).not.toBeInTheDocument(); + + // The intro is what "owns" the Slack action buttons, so once it is hidden + // the step itself must still render an action button — otherwise the user + // is stranded on a page with no way to continue. + expect( + screen.getByRole('button', { name: /continue/i }), + ).toBeInTheDocument(); + }); + it('keeps Microsoft setup focused on the single app values', () => { render( !field.runtimeSatisfied) ?? false; + // Slack "owns" the step actions only while its intro screen is shown, which + // is the same condition SlackSetupExperience uses to render that intro. If a + // saved (or runtime) config is being edited instead, the intro is hidden and + // this step must render its own action button — otherwise there is no way to + // continue. Keep this in sync with SlackSetupExperience's intro guard. const providerOwnsActions = selectedProvider?.id === 'slack' && !showManualSlackValues && - !selectedProvider.runtimeSatisfied; + !selectedProvider.runtimeSatisfied && + !selectedProvider.savedSatisfied; if (bootstrapMode && selectedProviderRuntimeConfigured) { return ( From 09c42fdfd4fb99773bd4993a6fef54679c208311 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 13 Jul 2026 22:33:37 +0000 Subject: [PATCH 02/30] Release Roomote 0.4.2 Hotfix-only cut from main with the #299 Slack setup Continue fix. --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e9f9625f..8d262284d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.4.2 (2026-07-13) + +### Patch changes + +- Onboarding Slack setup no longer strands revisits with saved credentials on a form with no Continue action: the step button is shown again when the intro screen is skipped. + ## 0.4.1 (2026-07-13) ### Patch changes diff --git a/package.json b/package.json index 9926816b6d..623eb1d437 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "0.4.1", + "version": "0.4.2", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { From b632074ad2b61f726772a06436a767643c554d47 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:17:42 +0000 Subject: [PATCH 03/30] [Chore] Refresh 0.5.0 notes after develop catch-up Drop the reverted analytics Model group-by line and document notification destination and homepage dump-flash fixes now included on release/v0.5.0. --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3290c8956..428b23642e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,17 @@ This file tracks product releases for Roomote (single monorepo version). Automat - Source-control setup expands provider OAuth and connection flows, simplifies Azure DevOps to organization and PAT by default while preserving full repository identifiers, and prefills the GitHub App description in the manifest setup flow. - Slack agent narrative replies prefer modern markdown blocks, the Working on footer posts out of band with notifications when linked PRs close, MCP integration setup becomes a non-blocking suggestion instead of blocking task start (with Zero detection limited to product surfaces), and agents can post to Teams or Telegram channels through a surface-generic channel-post tool. - When only one environment exists the homepage starts there instead of Auto, subagent rows expand to show the launch prompt, the router supplies task-relevant kickoff strings (with freer punctuation and no forced opening reply after free-form kickoffs), freeform kickoffs always show when tasks start, CI failure triage runs as one environment-backed fix task, the coding agent consults the advisor on hard failures and user challenges, and Microsoft Teams onboarding setup copy and flow are refreshed. -- Daily anonymous product stats include a 7-day PR funnel so deployments can evaluate how effectively agent work turns into shipped pull requests, and analytics can group by Model. +- Daily anonymous product stats include a 7-day PR funnel so deployments can evaluate how effectively agent work turns into shipped pull requests. - Visual proof images now render inline in the task transcript instead of only as detached artifact links. ### Patch changes - Hosted Docker runtime provisioning is more reliable across E2B, Blaxel, and related setup paths, with retryable rebuilds that preserve the prior artifact; failed local standby resumes clean up nested Docker-project daemons rather than leaving them running. - Setup can back out of earlier choices without wiping later steps when a user revisits a picker, finishing setup into an onboarding task no longer flashes the home page first, and source-control settings no longer discard in-progress configuration edits when provider-status refetches. -- GitLab OAuth listing and install paths work for OAuth-backed tokens and public callback hosts: MR list/sync uses the bearer-aware token header, and OAuth authorize/callback redirect URIs use the request callback host (matching Gitea). Gitea comment intake ignores the configured deployment bot identity, not only roomote*-prefixed logins. Host-aware keys keep PR funnel and merge-duration counts correct across multi-host source-control instances, and Slack markdown link conversion no longer uses a ReDoS-prone regex. +- GitLab OAuth listing and install paths work for OAuth-backed tokens and public callback hosts: MR list/sync uses the bearer-aware token header, and OAuth authorize/callback redirect URIs use the request callback host (matching Gitea). Gitea comment intake ignores the configured deployment bot identity, not only roomote*-prefixed logins. Host-aware keys keep PR funnel and merge-duration counts correct across multi-host source-control instances, and Slack/markdown path handling avoids ReDoS-prone polynomial patterns flagged by CodeQL. - Local development artifact uploads from hosted workers succeed through the Caddy edge, and presigned upload responses without an S3 ETag are no longer treated as successful. +- Slack notifications no longer target the wrong task thread or post to destinations whose Slack connection was disconnected. +- The homepage empty-environments warning no longer flashes orange while environments are still loading. ## 0.4.2 (2026-07-13) From 691e036e4314f7467375d837d90212d47c29f8f9 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:45:26 +0000 Subject: [PATCH 04/30] [Chore] Bring release/v0.5.0 current with develop again Merge latest develop (#370, #371), document the Blaxel Compose --wait fix on the existing 0.5.0 notes, remove the shipping pending changeset, and quiet residual oxlint/ESLint friction from that merge. --- .changeset/blaxel-compose-wait-provider-env.md | 5 ----- CHANGELOG.md | 1 + apps/worker/src/commands/setup/node-pty.ts | 9 ++++----- .../worker/src/workspace/__tests__/tool-versions.test.ts | 5 ++--- 4 files changed, 7 insertions(+), 13 deletions(-) delete mode 100644 .changeset/blaxel-compose-wait-provider-env.md diff --git a/.changeset/blaxel-compose-wait-provider-env.md b/.changeset/blaxel-compose-wait-provider-env.md deleted file mode 100644 index c1d0326aa9..0000000000 --- a/.changeset/blaxel-compose-wait-provider-env.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@roomote/web': patch ---- - -Blaxel Docker projects no longer pass the unsupported Compose `--wait` flag: the provider check now reads the worker's process environment, where the compute provider is actually set. diff --git a/CHANGELOG.md b/CHANGELOG.md index 428b23642e..f53fc6f7b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This file tracks product releases for Roomote (single monorepo version). Automat - Local development artifact uploads from hosted workers succeed through the Caddy edge, and presigned upload responses without an S3 ETag are no longer treated as successful. - Slack notifications no longer target the wrong task thread or post to destinations whose Slack connection was disconnected. - The homepage empty-environments warning no longer flashes orange while environments are still loading. +- Blaxel Docker projects no longer pass the unsupported Compose `--wait` flag: the provider check now reads the worker's process environment, where the compute provider is actually set. ## 0.4.2 (2026-07-13) diff --git a/apps/worker/src/commands/setup/node-pty.ts b/apps/worker/src/commands/setup/node-pty.ts index 446dbd8900..1110448ef1 100644 --- a/apps/worker/src/commands/setup/node-pty.ts +++ b/apps/worker/src/commands/setup/node-pty.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -27,11 +28,9 @@ import { export async function installNodePty(logger: StartupLogger): Promise { try { // node-pty is a native module that must be compiled at install time. - // We use require() here (not import()) because we only need to check - // whether the module is resolvable — not actually load it. A failed - // require() throws synchronously, which is cheap to catch. - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('node-pty'); + // Use createRequire (not dynamic import) so a missing module throws + // synchronously and we cut the resolve check short. + createRequire(import.meta.url)('node-pty'); return; } catch { // Not found, proceed with install. diff --git a/apps/worker/src/workspace/__tests__/tool-versions.test.ts b/apps/worker/src/workspace/__tests__/tool-versions.test.ts index 70acaa2b0b..788139bb48 100644 --- a/apps/worker/src/workspace/__tests__/tool-versions.test.ts +++ b/apps/worker/src/workspace/__tests__/tool-versions.test.ts @@ -117,9 +117,8 @@ function buildRepositorySyncCommands( /** * Access private methods for focused unit testing. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getPrivateMethod(instance: WorkspaceManager, method: string): any { - return (instance as unknown as Record)[method]; +function getPrivateMethod(instance: WorkspaceManager, method: string): T { + return (instance as unknown as Record)[method] as T; } describe('WorkspaceManager tool versions', () => { From 5cb2fc05c5ef2477538edc6e8401d1b9280e75c8 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:49:45 +0000 Subject: [PATCH 05/30] fix: address v0.7.0 release feedback --- .../settings/DiscordSetupStatus.test.tsx | 42 +++++++++++++++++++ .../settings/DiscordSetupStatus.tsx | 6 +-- .../commands/automations/settings-update.ts | 3 +- apps/web/src/trpc/routers/_app.ts | 13 ++++-- .../src/server/__tests__/enqueue-task.test.ts | 23 +++++++--- .../__tests__/harness-model-overrides.test.ts | 4 +- .../src/server/harness-model-overrides.ts | 38 ++++++++++++----- .../cloud-agents/src/server/task-run-queue.ts | 34 ++++++++++++++- .../src/server/automations/codeql-triage.ts | 4 +- .../automations/github-deployment-scope.ts | 20 +++++++++ 10 files changed, 159 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/settings/DiscordSetupStatus.test.tsx b/apps/web/src/components/settings/DiscordSetupStatus.test.tsx index 2d1a5ca54d..ce674161e0 100644 --- a/apps/web/src/components/settings/DiscordSetupStatus.test.tsx +++ b/apps/web/src/components/settings/DiscordSetupStatus.test.tsx @@ -257,4 +257,46 @@ describe('DiscordSetupStatus', () => { expect(screen.queryByText(/Gateway service/i)).not.toBeInTheDocument(); expect(screen.getByText('Discord account linking')).toBeInTheDocument(); }); + + it('shows quarantined events as delivery history instead of a current failure', () => { + render( + , + ); + + expect(screen.getByText(/Event delivery history:/)).toBeInTheDocument(); + expect( + screen.getByText('1 undeliverable event was quarantined.'), + ).toBeInTheDocument(); + expect(screen.queryByText(/recent messages/i)).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/settings/DiscordSetupStatus.tsx b/apps/web/src/components/settings/DiscordSetupStatus.tsx index 312035a3c7..e84d7928a7 100644 --- a/apps/web/src/components/settings/DiscordSetupStatus.tsx +++ b/apps/web/src/components/settings/DiscordSetupStatus.tsx @@ -85,9 +85,9 @@ export function DiscordSetupStatus({ status }: { status: DiscordCommsStatus }) { /> {(status.gateway?.deadLetterDepth ?? 0) > 0 ? ( ) : null} {status.gateway?.capacityWarning ? ( diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts index 08e16a5ee9..f7014ebb55 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -880,7 +880,8 @@ export async function updateBackgroundAgentSettingsCommand( }); const sentryTriageFrequency = input.sentryTriageFrequency ?? 'off'; const dependabotTriageFrequency = input.dependabotTriageFrequency ?? 'off'; - const codeqlTriageFrequency = input.codeqlTriageFrequency ?? 'off'; + const codeqlTriageFrequency = + input.codeqlTriageFrequency ?? existingSettings.codeqlTriageFrequency; const securityAuditorFrequency = input.securityAuditorFrequency ?? 'off'; const codeQualityAuditorFrequency = input.codeQualityAuditorFrequency ?? 'off'; diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index f4dbb9bc45..4f30005a3d 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -464,14 +464,21 @@ const automationsRouter = createRouter({ .min(1) .max(160) .nullable(), - codeqlTriageFrequency: z.enum(['off', 'daily', 'weekly']), - codeqlTriageSlackChannel: z.string().trim().min(1).max(160).nullable(), + codeqlTriageFrequency: z.enum(['off', 'daily', 'weekly']).optional(), + codeqlTriageSlackChannel: z + .string() + .trim() + .min(1) + .max(160) + .nullable() + .optional(), codeqlTriageDiscordChannel: z .string() .trim() .min(1) .max(160) - .nullable(), + .nullable() + .optional(), ...SCHEDULE_ONLY_FREQUENCY_FIELD_SHAPE, suggesterFrequency: z.enum(['off', 'daily', 'weekly']), suggesterSlackChannel: z.string().trim().min(1).max(160).nullable(), diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index 9d97a4e5d3..fa97f3d70d 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -62,15 +62,26 @@ const explicitWorkKind = { describe('resolveFreshTaskComputeProvider', () => { it('forces fresh launches onto Roomote in cloud-managed deployments', () => { - expect(resolveFreshTaskComputeProvider('modal', 'docker', true)).toBe( - 'roomote', - ); + expect( + resolveFreshTaskComputeProvider('modal', 'docker', undefined, true), + ).toBe('roomote'); }); it('keeps the requested provider outside cloud-managed deployments', () => { - expect(resolveFreshTaskComputeProvider('modal', 'docker', false)).toBe( - 'modal', - ); + expect( + resolveFreshTaskComputeProvider('modal', 'docker', undefined, false), + ).toBe('modal'); + }); + + it('keeps environment snapshots on their configured provider in cloud-managed deployments', () => { + expect( + resolveFreshTaskComputeProvider( + 'modal', + 'docker', + TaskPayloadKind.SnapshotEnvironment, + true, + ), + ).toBe('modal'); }); }); diff --git a/packages/cloud-agents/src/server/__tests__/harness-model-overrides.test.ts b/packages/cloud-agents/src/server/__tests__/harness-model-overrides.test.ts index 8109d53b71..7e52438c4e 100644 --- a/packages/cloud-agents/src/server/__tests__/harness-model-overrides.test.ts +++ b/packages/cloud-agents/src/server/__tests__/harness-model-overrides.test.ts @@ -190,9 +190,11 @@ describe('resolveEffectiveHarnessModelState', () => { sourceRunHarnessModelOverrides: { 'opencode-server': 'openrouter/z-ai/glm-5.2', }, + sourceTaskType: TaskPayloadKind.GithubPrReview, deploymentCodingReasoningEffort: 'high', + deploymentCodeReviewReasoningEffort: 'xhigh', }); - expect(task.payload.reasoningEffort).toBe('high'); + expect(task.payload.reasoningEffort).toBe('xhigh'); }); }); diff --git a/packages/cloud-agents/src/server/harness-model-overrides.ts b/packages/cloud-agents/src/server/harness-model-overrides.ts index 6ab0e12983..a764ce7a7b 100644 --- a/packages/cloud-agents/src/server/harness-model-overrides.ts +++ b/packages/cloud-agents/src/server/harness-model-overrides.ts @@ -21,10 +21,10 @@ function isConfiguredModelId( return typeof value === 'string' && value.trim().length > 0; } -function isCodeReviewTaskType(task: TaskSpec): boolean { +function isCodeReviewTaskType(taskType: TaskPayloadKind): boolean { return ( - task.type === TaskPayloadKind.GithubPrReview || - task.type === TaskPayloadKind.GithubPrReviewSync + taskType === TaskPayloadKind.GithubPrReview || + taskType === TaskPayloadKind.GithubPrReviewSync ); } @@ -80,7 +80,9 @@ function applyHarnessModelOverrides( function resolveOverrideTaskReasoningEffort(options: { modelId: string; deploymentTaskModelSettings?: TaskModelSettings | null; + deploymentCodeReviewReasoningEffort?: ReasoningEffort | null; deploymentCodingReasoningEffort?: ReasoningEffort | null; + isCodeReviewTask: boolean; }): ReasoningEffort | null { const catalogModel = getTaskModelCatalog( options.deploymentTaskModelSettings, @@ -90,10 +92,11 @@ function resolveOverrideTaskReasoningEffort(options: { return null; } - return ( - options.deploymentCodingReasoningEffort ?? - DEFAULT_MODEL_ROLE_REASONING_EFFORTS.coding - ); + return options.isCodeReviewTask + ? (options.deploymentCodeReviewReasoningEffort ?? + DEFAULT_MODEL_ROLE_REASONING_EFFORTS.codeReview) + : (options.deploymentCodingReasoningEffort ?? + DEFAULT_MODEL_ROLE_REASONING_EFFORTS.coding); } /** @@ -107,7 +110,9 @@ function applyOverrideTaskReasoningEffort( options: { targetHarness: CodingHarness; deploymentTaskModelSettings?: TaskModelSettings | null; + deploymentCodeReviewReasoningEffort?: ReasoningEffort | null; deploymentCodingReasoningEffort?: ReasoningEffort | null; + isCodeReviewTask: boolean; }, ): T { if (options.targetHarness !== 'opencode-server') { @@ -130,7 +135,10 @@ function applyOverrideTaskReasoningEffort( const reasoningEffort = resolveOverrideTaskReasoningEffort({ modelId: overrideModelId, deploymentTaskModelSettings: options.deploymentTaskModelSettings, + deploymentCodeReviewReasoningEffort: + options.deploymentCodeReviewReasoningEffort, deploymentCodingReasoningEffort: options.deploymentCodingReasoningEffort, + isCodeReviewTask: options.isCodeReviewTask, }); if (!reasoningEffort) { @@ -151,9 +159,11 @@ export function resolveEffectiveHarnessModelState(options: { targetHarness: CodingHarness; isSnapshotResume: boolean; sourceRunHarnessModelOverrides?: HarnessModelOverrides; + sourceTaskType?: TaskPayloadKind; deploymentMetadata?: MetadataRecord | null; deploymentTaskModelSettings?: TaskModelSettings | null; deploymentCodeReviewModelId?: string | null; + deploymentCodeReviewReasoningEffort?: ReasoningEffort | null; deploymentCodingReasoningEffort?: ReasoningEffort | null; }): { task: T; model: string } { const shouldReuseSourceHarnessModelOverrides = @@ -165,7 +175,12 @@ export function resolveEffectiveHarnessModelState(options: { options.task, options.sourceRunHarnessModelOverrides, ), - options, + { + ...options, + isCodeReviewTask: isCodeReviewTaskType( + options.sourceTaskType ?? options.task.type, + ), + }, ); return { @@ -196,7 +211,10 @@ export function resolveEffectiveHarnessModelState(options: { } return { - task: applyOverrideTaskReasoningEffort(options.task, options), + task: applyOverrideTaskReasoningEffort(options.task, { + ...options, + isCodeReviewTask: isCodeReviewTaskType(options.task.type), + }), model: resolveTaskModelForHarness( options.targetHarness, options.task.payload.harnessModelOverrides, @@ -213,7 +231,7 @@ export function resolveEffectiveHarnessModelState(options: { ? options.deploymentCodeReviewModelId : null; const taskModelId = - isCodeReviewTaskType(options.task) && resolvedCodeReviewModelId + isCodeReviewTaskType(options.task.type) && resolvedCodeReviewModelId ? resolvedCodeReviewModelId : defaultTaskModelId; const nextTask = applyHarnessModelOverrides(options.task, { diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 0aee644aa6..45fe2146a4 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -94,11 +94,12 @@ enum TaskRunQueueKeys { export function resolveFreshTaskComputeProvider( provider: string | null | undefined, fallback: ComputeProvider, + taskType?: TaskPayloadKind, cloudEnabled = isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), ): ComputeProvider { // Managed deployments own the sandbox lifecycle, so fresh work must not // escape to a previously configured bring-your-own provider. - return cloudEnabled + return cloudEnabled && taskType !== TaskPayloadKind.SnapshotEnvironment ? 'roomote' : resolveComputeProviderTarget(provider, fallback); } @@ -273,6 +274,9 @@ type ResolvedHarnessSelection = { | import('@roomote/types').TaskModelSettings | null; deploymentCodeReviewModelId?: string | null; + deploymentCodeReviewReasoningEffort?: + | import('@roomote/types').ReasoningEffort + | null; deploymentCodingReasoningEffort?: | import('@roomote/types').ReasoningEffort | null; @@ -315,6 +319,16 @@ function resolveCodingReasoningEffort( : persistedConfig.roomoteModelReasoningEffort; } +function resolveCodeReviewReasoningEffort( + persistedConfig: import('@roomote/types').DeploymentModelConfig, +): import('@roomote/types').ReasoningEffort | null { + const envEffort = process.env.R_CODE_REVIEW_MODEL_REASONING_EFFORT?.trim(); + + return isReasoningEffort(envEffort) + ? envEffort + : persistedConfig.roomoteCodeReviewModelReasoningEffort; +} + async function resolveRequestedHarness( task: TaskSpec, ): Promise { @@ -338,6 +352,9 @@ async function resolveRequestedHarness( deploymentCodeReviewModelId: resolveCodeReviewModelId( deploymentModelConfig, ), + deploymentCodeReviewReasoningEffort: resolveCodeReviewReasoningEffort( + deploymentModelConfig, + ), deploymentCodingReasoningEffort: resolveCodingReasoningEffort( deploymentModelConfig, ), @@ -1293,6 +1310,8 @@ async function enqueueFreshLaunch( deploymentTaskModelSettings: resolvedHarness.deploymentTaskModelSettings, deploymentCodeReviewModelId: resolvedHarness.deploymentCodeReviewModelId ?? null, + deploymentCodeReviewReasoningEffort: + resolvedHarness.deploymentCodeReviewReasoningEffort ?? null, deploymentCodingReasoningEffort: resolvedHarness.deploymentCodingReasoningEffort ?? null, }); @@ -1328,6 +1347,7 @@ async function enqueueFreshLaunch( const targetComputeProvider = resolveFreshTaskComputeProvider( task.computeProvider, await resolveDefaultComputeProvider(), + task.type, ); const requestedWorkKindDecision = @@ -1354,9 +1374,12 @@ async function enqueueFreshLaunch( // This is the only place where fresh tasks and their first runs are created. const taskRun = await db.transaction(async (tx) => { + const chatgptConnected = effectiveTaskModel.startsWith('openai/') + ? await isChatGptSubscriptionConnected(tx) + : false; const modelProvider = getDisplayModelProviderId(effectiveTaskModel, { - chatgptConnected: await isChatGptSubscriptionConnected(tx), + chatgptConnected, }) ?? DEFAULT_STANDARD_TASK_MODEL_PROVIDER; // Commit-author evaluation is unconditional at fresh enqueue. @@ -1692,6 +1715,8 @@ export async function enqueueTaskRelaunch( deploymentTaskModelSettings: resolvedHarness.deploymentTaskModelSettings, deploymentCodeReviewModelId: resolvedHarness.deploymentCodeReviewModelId ?? null, + deploymentCodeReviewReasoningEffort: + resolvedHarness.deploymentCodeReviewReasoningEffort ?? null, deploymentCodingReasoningEffort: resolvedHarness.deploymentCodingReasoningEffort ?? null, }); @@ -1708,6 +1733,7 @@ export async function enqueueTaskRelaunch( const targetComputeProvider = resolveFreshTaskComputeProvider( task.computeProvider, await resolveDefaultComputeProvider(), + task.type, ); const taskRun = await db.transaction(async (tx) => { @@ -1827,6 +1853,7 @@ async function enqueueSnapshotResume( taskId: true, harness: true, vendor: true, + payloadKind: true, payload: true, }, }); @@ -1883,10 +1910,13 @@ async function enqueueSnapshotResume( targetHarness, isSnapshotResume: true, sourceRunHarnessModelOverrides, + sourceTaskType: sourceRun.payloadKind, deploymentMetadata: resolvedHarness.deploymentMetadata, deploymentTaskModelSettings: resolvedHarness.deploymentTaskModelSettings, deploymentCodeReviewModelId: resolvedHarness.deploymentCodeReviewModelId ?? null, + deploymentCodeReviewReasoningEffort: + resolvedHarness.deploymentCodeReviewReasoningEffort ?? null, deploymentCodingReasoningEffort: resolvedHarness.deploymentCodingReasoningEffort ?? null, }); diff --git a/packages/sdk/src/server/automations/codeql-triage.ts b/packages/sdk/src/server/automations/codeql-triage.ts index 694346627e..b5f6322d54 100644 --- a/packages/sdk/src/server/automations/codeql-triage.ts +++ b/packages/sdk/src/server/automations/codeql-triage.ts @@ -12,7 +12,7 @@ import { type ResolvedAutomationDestination, } from './destination'; import { - getActiveRepositoryFullNames, + getActiveGitHubRepositoryFullNames, hasActiveGitHubInstallation, } from './github-deployment-scope'; import { createScheduledTriageJob } from './scheduled-triage-runner'; @@ -84,7 +84,7 @@ export const codeqlTriageJob = createScheduledTriageJob({ return { kind: 'skip', reason: 'GitHub is not configured' }; } - const selectedRepositories = await getActiveRepositoryFullNames(); + const selectedRepositories = await getActiveGitHubRepositoryFullNames(); const repositoryCoverage = await buildRepositoryCoverage(selectedRepositories); // CodeQL follow-ups must run validation before opening PRs, so the scan diff --git a/packages/sdk/src/server/automations/github-deployment-scope.ts b/packages/sdk/src/server/automations/github-deployment-scope.ts index c49e56d95b..5f81fc1544 100644 --- a/packages/sdk/src/server/automations/github-deployment-scope.ts +++ b/packages/sdk/src/server/automations/github-deployment-scope.ts @@ -1,4 +1,5 @@ import { + and, db, eq, githubInstallations, @@ -44,3 +45,22 @@ export async function getActiveRepositoryFullNames(): Promise { (left, right) => left.localeCompare(right), ); } + +export async function getActiveGitHubRepositoryFullNames(): Promise { + const rows = await db + .select({ + fullName: repositories.fullName, + }) + .from(repositories) + .where( + and( + eq(repositories.isActive, true), + eq(repositories.sourceControlProvider, 'github'), + ), + ) + .orderBy(repositories.fullName); + + return [...new Set(rows.map((row) => row.fullName).filter(Boolean))].sort( + (left, right) => left.localeCompare(right), + ); +} From 434d309894428f547bbb716aecf07d274abcf2e6 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:59:15 +0000 Subject: [PATCH 06/30] fix: keep CodeQL defaults simple --- apps/web/src/trpc/commands/automations/settings-update.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts index f7014ebb55..08e16a5ee9 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -880,8 +880,7 @@ export async function updateBackgroundAgentSettingsCommand( }); const sentryTriageFrequency = input.sentryTriageFrequency ?? 'off'; const dependabotTriageFrequency = input.dependabotTriageFrequency ?? 'off'; - const codeqlTriageFrequency = - input.codeqlTriageFrequency ?? existingSettings.codeqlTriageFrequency; + const codeqlTriageFrequency = input.codeqlTriageFrequency ?? 'off'; const securityAuditorFrequency = input.securityAuditorFrequency ?? 'off'; const codeQualityAuditorFrequency = input.codeQualityAuditorFrequency ?? 'off'; From f00dcc86c3f3d7a5c3c20d5e34b555a92f8824bb Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:02:33 +0000 Subject: [PATCH 07/30] fix: address remaining release feedback --- .../cloud-agents/src/server/task-run-queue.ts | 24 ++++++++++++++++++- .../src/server/automations/codeql-triage.ts | 12 +++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 45fe2146a4..400848c0a9 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -1854,6 +1854,7 @@ async function enqueueSnapshotResume( harness: true, vendor: true, payloadKind: true, + sourceRunId: true, payload: true, }, }); @@ -1886,6 +1887,27 @@ async function enqueueSnapshotResume( harnessModelOverrides?: import('@roomote/types').HarnessModelOverrides; } )?.harnessModelOverrides; + let sourceTaskType = sourceRun.payloadKind; + let parentRunId = sourceRun.sourceRunId; + + // Snapshot resumes point to the immediately preceding resume, so follow the + // chain until reaching the original run whose role selected the model. + while ( + sourceTaskType === TaskPayloadKind.SnapshotResume && + parentRunId !== null + ) { + const parentRun = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, parentRunId), + columns: { payloadKind: true, sourceRunId: true }, + }); + + if (!parentRun) { + break; + } + + sourceTaskType = parentRun.payloadKind; + parentRunId = parentRun.sourceRunId; + } if (task.harness && sourceJobHarness !== task.harness) { console.warn( @@ -1910,7 +1932,7 @@ async function enqueueSnapshotResume( targetHarness, isSnapshotResume: true, sourceRunHarnessModelOverrides, - sourceTaskType: sourceRun.payloadKind, + sourceTaskType, deploymentMetadata: resolvedHarness.deploymentMetadata, deploymentTaskModelSettings: resolvedHarness.deploymentTaskModelSettings, deploymentCodeReviewModelId: diff --git a/packages/sdk/src/server/automations/codeql-triage.ts b/packages/sdk/src/server/automations/codeql-triage.ts index b5f6322d54..7df1d94316 100644 --- a/packages/sdk/src/server/automations/codeql-triage.ts +++ b/packages/sdk/src/server/automations/codeql-triage.ts @@ -92,6 +92,14 @@ export const codeqlTriageJob = createScheduledTriageJob({ const environmentBackedRepositories = getEnvironmentBackedCoverage( repositoryCoverage, ).map((coverage) => coverage.repositoryFullName); + + if (environmentBackedRepositories.length === 0) { + return { + kind: 'skip', + reason: 'No active GitHub repositories have configured environments', + }; + } + const recentThreadFeedback = await loadAutomationThreadFeedbackContext({ automationKey: 'codeql_triage', slackChannelId: channelId, @@ -102,9 +110,7 @@ export const codeqlTriageJob = createScheduledTriageJob({ kind: 'scan', payload: { repo: ALL_REPOSITORIES, - ...(environmentBackedRepositories.length > 0 - ? { selectedRepositories: environmentBackedRepositories } - : {}), + selectedRepositories: environmentBackedRepositories, description: buildCodeqlTriagePrompt({ channelId, destination, From b6787d139b1771d14b03b05be64e6e52936be179 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:53:27 +0000 Subject: [PATCH 08/30] Release Roomote 0.14.1 Include post-cut develop fixes in the unshipped 0.14.1 notes and freeze point. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7c6adfa3..7a19cbfe2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ This file tracks product releases for Roomote (single monorepo version). Automat - Quote web UI follow-ups into Discord-linked task threads (name + text blockquote) before the agent's next reply, matching Slack behavior and preserving quotes across web snapshot resume. - Stop stacking a second empty Discord question shell when request_user_input enriches options; edit the existing prompt so users see one question with real choices. - Support structured request_user_input on Discord end-to-end: post option buttons, accept button or text answers, and resume the paused agent so answering no longer leaves the run waiting. +- Back off transient provider retry attempts with exponential delay (1s, 2s, 4s) instead of retrying immediately after capacity failures. - Fix sandbox WebGL by making the home directory traversable for Chromium's GPU process - Show self-review and PR review feedback summaries in the task web view for web-only tasks by always writing the summary into task message history, not only when a chat route exists. +- Fix controller recovery scans for persisted worker-bootstrap restarts so the query no longer references invalid table aliases and bootstrap recovery can continue. ## 0.14.0 (2026-07-19) From 1be9a42abb722819a634c266999f3dbe1a612fb3 Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:43:36 +0000 Subject: [PATCH 09/30] fix: prevent stale PR review actions --- .../src/server/__tests__/enqueue-task.test.ts | 12 ++- .../cloud-agents/src/server/task-run-queue.ts | 6 +- .../__tests__/pr-review-action.test.ts | 52 +++++++++++++ .../server/lib/task-runs/pr-review-action.ts | 77 +++++++++++-------- 4 files changed, 110 insertions(+), 37 deletions(-) create mode 100644 packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index e74fb1870d..ccaee1de73 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -841,6 +841,7 @@ describe('enqueueTask snapshot resume', () => { repo: 'acme/widgets', description: 'Do the thing', sourceControlProvider: 'ado', + sourceControlHost: 'dev.azure.com', }, }), initiator: { kind: 'user', userId }, @@ -865,9 +866,16 @@ describe('enqueueTask snapshot resume', () => { ); expect( - (resumeRun.payload as { sourceControlProvider?: string }) - .sourceControlProvider, + ( + resumeRun.payload as { + sourceControlProvider?: string; + sourceControlHost?: string; + } + ).sourceControlProvider, ).toBe('gitea'); + expect( + (resumeRun.payload as { sourceControlHost?: string }).sourceControlHost, + ).toBeUndefined(); }); it('rejects a resume without a source run id', async () => { diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 52613f4fd9..cb750a0209 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -1917,7 +1917,9 @@ function inheritSnapshotResumeSourceControlStamps( sourceControlHost?: unknown; }; - if (payload.sourceControlProvider === undefined) { + const inheritsProvider = payload.sourceControlProvider === undefined; + + if (inheritsProvider) { const provider = sourceControlProviderSchema.safeParse( source.sourceControlProvider, ); @@ -1927,7 +1929,7 @@ function inheritSnapshotResumeSourceControlStamps( } } - if (payload.sourceControlHost === undefined) { + if (inheritsProvider && payload.sourceControlHost === undefined) { const host = typeof source.sourceControlHost === 'string' ? source.sourceControlHost.trim() diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts new file mode 100644 index 0000000000..db0e813ffa --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts @@ -0,0 +1,52 @@ +const mockEval = vi.fn(); + +vi.mock('@roomote/redis', () => ({ + getRedis: () => ({ eval: mockEval }), +})); + +import { + attachPendingPrReviewActionMessage, + claimPendingPrReviewActionsForThread, +} from '../pr-review-action'; + +describe('PR review action state', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('attaches notification ids with an atomic compare-and-update script', async () => { + mockEval.mockResolvedValue(1); + + await attachPendingPrReviewActionMessage('nonce-1', 'message-1'); + + expect(mockEval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('get', KEYS[1])"), + 1, + 'pr-review-action:nonce-1', + 'message-1', + ); + expect(mockEval.mock.calls[0]?.[0]).toContain("'KEEPTTL'"); + }); + + it('claims every indexed offer through one atomic script', async () => { + mockEval.mockResolvedValue([ + JSON.stringify({ nonce: 'nonce-1', messageId: 'message-1' }), + ]); + + await expect( + claimPendingPrReviewActionsForThread({ + provider: 'discord', + channelId: 'channel-1', + threadId: 'thread-1', + }), + ).resolves.toEqual([{ nonce: 'nonce-1', messageId: 'message-1' }]); + + expect(mockEval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('smembers', KEYS[1])"), + 1, + 'pr-review-action:thread:discord:channel-1:thread-1', + 'pr-review-action:', + ); + expect(mockEval.mock.calls[0]?.[0]).toContain("redis.call('del', KEYS[1])"); + }); +}); diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts index 4c33377452..e0304be6ad 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts @@ -51,6 +51,35 @@ redis.call('del', KEYS[1]) return val `; +// Attaching a message must not revive an offer that a typed reply or button +// click claimed after the notification was posted. +const ATTACH_PR_REVIEW_ACTION_MESSAGE_LUA = ` +local val = redis.call('get', KEYS[1]) +if not val then return 0 end +local pending = cjson.decode(val) +pending.messageId = ARGV[1] +redis.call('set', KEYS[1], cjson.encode(pending), 'KEEPTTL') +return 1 +`; + +// Read, clear, and claim the complete conversation index in one operation so +// offers added concurrently remain indexed for a later typed reply. +const CLAIM_PR_REVIEW_ACTIONS_FOR_THREAD_LUA = ` +local nonces = redis.call('smembers', KEYS[1]) +if #nonces == 0 then return {} end +redis.call('del', KEYS[1]) +local claimed = {} +for _, nonce in ipairs(nonces) do + local actionKey = ARGV[1] .. nonce + local val = redis.call('get', actionKey) + if val then + redis.call('del', actionKey) + table.insert(claimed, val) + end +end +return claimed +`; + function getPrReviewActionKey(nonce: string): string { return `${PR_REVIEW_ACTION_PREFIX}${nonce}`; } @@ -94,25 +123,14 @@ export async function attachPendingPrReviewActionMessage( messageId: string, ): Promise { const redis = getRedis(); - const key = getPrReviewActionKey(nonce); - const raw = await redis.get(key); - - if (typeof raw !== 'string') { - return; - } - - try { - const pending = JSON.parse(raw) as PendingPrReviewAction; - - await redis.set( - key, - JSON.stringify({ ...pending, messageId }), - 'EX', - PR_REVIEW_ACTION_TTL_SECONDS, - ); - } catch { - // Malformed record; leave it to expire. - } + await redis + .eval( + ATTACH_PR_REVIEW_ACTION_MESSAGE_LUA, + 1, + getPrReviewActionKey(nonce), + messageId, + ) + .catch(() => undefined); } export async function claimPendingPrReviewAction( @@ -155,23 +173,16 @@ export async function claimPendingPrReviewActionsForThread(input: { }): Promise { const redis = getRedis(); const threadKey = getPrReviewActionThreadKey(input); - const nonces = await redis.smembers(threadKey); - - if (nonces.length === 0) { - return []; - } - - await redis.del(threadKey).catch(() => undefined); + const rawClaims = await redis.eval( + CLAIM_PR_REVIEW_ACTIONS_FOR_THREAD_LUA, + 1, + threadKey, + PR_REVIEW_ACTION_PREFIX, + ); const claimed: PendingPrReviewAction[] = []; - for (const nonce of nonces) { - const raw = await redis.eval( - CLAIM_PR_REVIEW_ACTION_LUA, - 1, - getPrReviewActionKey(nonce), - ); - + for (const raw of Array.isArray(rawClaims) ? rawClaims : []) { if (typeof raw !== 'string') { continue; } From d5cfa42b600b1d3ab394947a3332cfc44c992259 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:33:32 +0000 Subject: [PATCH 10/30] test: use enabled GLM override fixture --- .../server/__tests__/harness-model-overrides.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cloud-agents/src/server/__tests__/harness-model-overrides.test.ts b/packages/cloud-agents/src/server/__tests__/harness-model-overrides.test.ts index 1304972429..f63b8e4e22 100644 --- a/packages/cloud-agents/src/server/__tests__/harness-model-overrides.test.ts +++ b/packages/cloud-agents/src/server/__tests__/harness-model-overrides.test.ts @@ -37,13 +37,13 @@ describe('resolveEffectiveHarnessModelState', () => { it('resolves the persisted model from an OpenCode harness override', () => { const { model } = resolveEffectiveHarnessModelState({ task: makeTask({ - 'opencode-server': 'openrouter/z-ai/glm-5.2', + 'opencode-server': 'openrouter/z-ai/glm-5.3', }), targetHarness: 'opencode-server', isSnapshotResume: false, }); - expect(model).toBe('openrouter/z-ai/glm-5.2'); + expect(model).toBe('openrouter/z-ai/glm-5.3'); }); it('uses the deployment code review model for PR review tasks when no override is present', () => { @@ -108,7 +108,7 @@ describe('resolveEffectiveHarnessModelState', () => { it('stamps the default coding reasoning effort for a model override', () => { const { task } = resolveEffectiveHarnessModelState({ - task: makeTask({ 'opencode-server': 'openrouter/z-ai/glm-5.2' }), + task: makeTask({ 'opencode-server': 'openrouter/z-ai/glm-5.3' }), targetHarness: 'opencode-server', isSnapshotResume: false, }); @@ -118,7 +118,7 @@ describe('resolveEffectiveHarnessModelState', () => { it('inherits the deployment coding reasoning effort for a model override', () => { const { task } = resolveEffectiveHarnessModelState({ - task: makeTask({ 'opencode-server': 'openrouter/z-ai/glm-5.2' }), + task: makeTask({ 'opencode-server': 'openrouter/z-ai/glm-5.3' }), targetHarness: 'opencode-server', isSnapshotResume: false, deploymentCodingReasoningEffort: 'xhigh', @@ -128,7 +128,7 @@ describe('resolveEffectiveHarnessModelState', () => { }); it('keeps an explicit per-task reasoning effort over the deployment level', () => { - const task = makeTask({ 'opencode-server': 'openrouter/z-ai/glm-5.2' }); + const task = makeTask({ 'opencode-server': 'openrouter/z-ai/glm-5.3' }); task.payload.reasoningEffort = 'low'; const { task: nextTask } = resolveEffectiveHarnessModelState({ From b02a084e09e0384f1254367ecabc122244f4e109 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 28 Aug 2026 22:51:38 -0400 Subject: [PATCH 11/30] [Fix] Discover pages inside shared Notion databases (#1825) --- apps/api/src/handlers/mcp/notion/tools.ts | 15 ++- .../__tests__/brain-notion.test.ts | 96 ++++++++++++++++++- .../brain-collectors/notion-pages.ts | 84 ++++++++++++++-- apps/docs/integrations/notion.mdx | 5 +- 4 files changed, 187 insertions(+), 13 deletions(-) diff --git a/apps/api/src/handlers/mcp/notion/tools.ts b/apps/api/src/handlers/mcp/notion/tools.ts index 00a5cf1882..2faf41b552 100644 --- a/apps/api/src/handlers/mcp/notion/tools.ts +++ b/apps/api/src/handlers/mcp/notion/tools.ts @@ -79,7 +79,7 @@ function registerSearchTool( { title: 'Search Notion', description: - 'Search pages and data sources explicitly shared with the deployment Notion integration.', + 'Search pages and data sources explicitly shared with the deployment Notion integration. Pages that live inside databases are often missing from search results: to find them, locate the data source (object_type "data_source") and list its rows with notion-query-data-sources.', inputSchema: { query: z.string().optional(), object_type: z.enum(['page', 'data_source']).optional(), @@ -118,10 +118,12 @@ function registerFetchTool( { title: 'Fetch Notion Content', description: - 'Fetch a page, data source, or block explicitly shared with the deployment Notion integration. Pages include enhanced Markdown content; blocks include one page of child blocks.', + 'Fetch a page, database, data source, or block explicitly shared with the deployment Notion integration. Pages include enhanced Markdown content; databases list their data sources (query rows with notion-query-data-sources); blocks include one page of child blocks.', inputSchema: { id: nonEmptyStringSchema, - object_type: z.enum(['page', 'data_source', 'block']).default('page'), + object_type: z + .enum(['page', 'database', 'data_source', 'block']) + .default('page'), include_transcript: z.boolean().optional(), ...paginationSchema, }, @@ -136,6 +138,13 @@ function registerFetchTool( page_size, }) => { const encodedId = encodeURIComponent(id); + if (objectType === 'database') { + const database = await notionApiRequestJson>({ + config, + path: `databases/${encodedId}`, + }); + return toMcpToolResult({ database }); + } if (objectType === 'data_source') { const dataSource = await notionApiRequestJson>({ config, diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts index 91ad9a3210..822ce94d0f 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts @@ -360,7 +360,9 @@ describe('Notion traversal discovery', () => { mode: 'traverse' as const, lastSweepAt: '2026-08-27T00:00:00.000Z', scanStartedAt: '2026-08-27T00:00:00.000Z', - traverse: { afterItemId: '', pending: [] }, + // Most tests focus on the block/inventory walk; the shared data-source + // enumeration that runs first has its own tests below. + traverse: { afterItemId: '', pending: [], dataSourcesDone: true }, }; const pageObject = (id: string, title: string) => ({ object: 'page', @@ -521,6 +523,98 @@ describe('Notion traversal discovery', () => { expect(result.pages.map((page) => page.title)).toEqual(['Row page']); }); + it('discovers rows of directly-shared databases search never surfaced', async () => { + const dataSource = '99991111-0000-0000-0000-0000000000d5'; + const row = '99991111-0000-0000-0000-000000000101'; + const searchBodies: unknown[] = []; + mockNotionApiRequestJson.mockImplementation(async ({ path, body }) => { + if (path === 'search') { + searchBodies.push(body); + return { + results: [{ object: 'data_source', id: dataSource }], + has_more: false, + }; + } + if (path === `data_sources/${encodeURIComponent(dataSource)}/query`) { + return { + results: [pageObject(row, 'Shared database row')], + has_more: false, + }; + } + if (path === `pages/${encodeURIComponent(row)}`) { + return pageObject(row, 'Shared database row'); + } + if (path === `pages/${encodeURIComponent(row)}/markdown`) { + return { markdown: 'Row body' }; + } + if (path.startsWith('blocks/')) { + return { results: [], has_more: false }; + } + throw new Error(`unexpected path ${path}`); + }); + + const result = await collectNotionTraversal({ + config, + saved: { + ...savedTraverse, + traverse: { afterItemId: '', pending: [] }, + }, + limit: 10, + }); + + expect(searchBodies).toEqual([ + expect.objectContaining({ + filter: { property: 'object', value: 'data_source' }, + }), + ]); + expect(result.pages.map((page) => page.title)).toEqual([ + 'Shared database row', + ]); + expect(JSON.parse(result.stateUpdates![0]!.cursor as string)).toMatchObject( + { mode: 'idle' }, + ); + }); + + it('restarts data-source enumeration when its search cursor expires', async () => { + const searchCursors: unknown[] = []; + mockNotionApiRequestJson.mockImplementation(async ({ path, body }) => { + if (path === 'search') { + const cursor = (body as { start_cursor?: string }).start_cursor; + searchCursors.push(cursor ?? null); + if (cursor) { + throw new NotionApiError( + 'cursor expired', + 400, + 'validation_error', + null, + ); + } + return { results: [], has_more: false }; + } + throw new Error(`unexpected path ${path}`); + }); + + const result = await collectNotionTraversal({ + config, + saved: { + ...savedTraverse, + traverse: { + afterItemId: '', + pending: [], + dataSourceCursor: 'stale-cursor', + }, + }, + limit: 10, + }); + + // The stale cursor restarts the enumeration from the top instead of + // wedging the pass, and the cycle still completes. + expect(searchCursors).toEqual(['stale-cursor', null]); + expect(JSON.parse(result.stateUpdates![0]!.cursor as string)).toMatchObject( + { mode: 'idle' }, + ); + }); + it('descends into any block with children, not just a container allowlist', async () => { const parent = 'ffff6666-0000-0000-0000-000000000001'; const paragraph = 'ffff6666-0000-0000-0000-0000000000b1'; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts index 61af282289..496e4df419 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts @@ -79,9 +79,10 @@ const NOTION_MAX_SEARCH_REQUESTS_PER_PASS = 10; * directly-shared content is guaranteed; children reachable through a shared * parent may never appear (see * https://developers.notion.com/reference/search-optimizations-and-limitations). - * After each sweep+reconcile cycle the collector therefore walks the block - * tree and data sources of every inventoried page, discovering - * inheritance-shared pages the search index missed. + * After each sweep+reconcile cycle the collector therefore enumerates every + * data source the integration can see and walks the block tree of every + * inventoried page, discovering inheritance-shared pages — database rows + * above all — that the search index missed. */ const NOTION_MAX_TRAVERSAL_REQUESTS_PER_PASS = 24; const NOTION_TRAVERSAL_SEED_BATCH = 25; @@ -761,6 +762,23 @@ export function buildNotionSearchBody( }; } +function buildNotionDataSourceSearchBody( + cursor: string | null, +): Record { + return { + filter: { property: 'object', value: 'data_source' }, + page_size: NOTION_SEARCH_PAGE_SIZE, + ...(cursor ? { start_cursor: cursor } : {}), + }; +} + +function isNotionSearchDataSource( + value: unknown, +): value is { object: 'data_source'; id: string } { + const record = asObject(value); + return !!record && record.object === 'data_source' && !!asString(record.id); +} + async function fetchNotionPage( config: McpConnectionNotionConfig, page: NotionSearchPage, @@ -803,6 +821,10 @@ type NotionTraverseState = { afterItemId: string; /** Discovered containers awaiting expansion, bounded. */ pending: NotionTraverseNode[]; + /** Search cursor for the shared data-source enumeration, when paused. */ + dataSourceCursor?: string; + /** True once every directly-shared data source is enqueued this cycle. */ + dataSourcesDone?: boolean; }; type NotionScanCursor = { @@ -918,11 +940,13 @@ function isNotionBlock(value: unknown): value is NotionBlock { /** * Walk the inventory's block trees and data sources, emitting pages the - * search-based sweep never surfaced. The seed side iterates - * brain_collector_items by durable id cursor (no queue growth); only - * discovered containers carry over between passes, bounded by - * NOTION_TRAVERSAL_MAX_PENDING. A pass ends when its request budget or page - * limit is spent; completion flips the scan back to idle. + * search-based sweep never surfaced. Directly-shared data sources are + * enumerated first via search (their rows inherit access but are the classic + * search-index gap), then the seed side iterates brain_collector_items by + * durable id cursor (no queue growth); only discovered containers carry over + * between passes, bounded by NOTION_TRAVERSAL_MAX_PENDING. A pass ends when + * its request budget or page limit is spent; completion flips the scan back + * to idle. */ export async function collectNotionTraversal(input: { config: McpConnectionNotionConfig; @@ -936,6 +960,8 @@ export async function collectNotionTraversal(input: { pending: [], }; let afterItemId = state.afterItemId; + let dataSourceCursor = state.dataSourceCursor ?? null; + let dataSourcesDone = state.dataSourcesDone === true; const pending: NotionTraverseNode[] = [...state.pending]; const pages: CollectorPage[] = []; const itemUpdates: CollectorItemUpdate[] = []; @@ -1083,6 +1109,46 @@ export async function collectNotionTraversal(input: { const node = pending.shift(); if (!node) { + if (!dataSourcesDone) { + // Rows of a database shared directly with the integration inherit + // access but rarely reach the search index, and no inventoried page + // holds them as child_database blocks — search is the only way to + // find those data sources at all. + requests++; + let found: NotionSearchResponse; + try { + found = await notionCollectorRequest({ + config: input.config, + path: 'search', + method: 'POST', + body: buildNotionDataSourceSearchBody(dataSourceCursor), + }); + } catch (error) { + if ( + dataSourceCursor && + error instanceof NotionApiError && + error.status === 400 + ) { + // Notion pagination cursors expire; restart the enumeration. + dataSourceCursor = null; + continue; + } + throw error; + } + for (const raw of found.results ?? []) { + if (isNotionSearchDataSource(raw)) { + pushPending({ kind: 'data_source', id: raw.id }); + } + } + const nextCursor = + found.has_more && asString(found.next_cursor) + ? found.next_cursor!.trim() + : null; + dataSourceCursor = nextCursor; + dataSourcesDone = nextCursor === null; + continue; + } + const batch = await listBrainCollectorItemsAfter( db, NOTION_PAGES_COLLECTOR_ID, @@ -1259,6 +1325,8 @@ export async function collectNotionTraversal(input: { traverse: { afterItemId, pending: pending.slice(0, NOTION_TRAVERSAL_MAX_PENDING), + ...(dataSourceCursor ? { dataSourceCursor } : {}), + ...(dataSourcesDone ? { dataSourcesDone: true } : {}), }, }, ), diff --git a/apps/docs/integrations/notion.mdx b/apps/docs/integrations/notion.mdx index 1c7824081c..09baeb90f8 100644 --- a/apps/docs/integrations/notion.mdx +++ b/apps/docs/integrations/notion.mdx @@ -40,7 +40,10 @@ When Memory is enabled, Roomote also backfills the pages shared with this integration and keeps their Markdown snapshots current. Notion pages are stored under the `notion/` namespace. New and edited pages are picked up on regular Memory collector ticks, and a daily full sweep discovers older pages -that were newly shared without being edited. The same sweep replaces pages +that were newly shared without being edited. Because Notion's search index +does not reliably surface pages that live inside databases, the sweep also +enumerates every shared data source and walks page trees to capture database +rows and other inheritance-shared pages. The same sweep replaces pages that are no longer shared with unavailable tombstones, so their former content is no longer retained in Memory search results. From a0c968b6bece57c1d66b14efd42fd840d9b584df Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:04:44 +0000 Subject: [PATCH 12/30] chore: add Notion hotfix changeset --- .changeset/notion-shared-database-pages.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/notion-shared-database-pages.md diff --git a/.changeset/notion-shared-database-pages.md b/.changeset/notion-shared-database-pages.md new file mode 100644 index 0000000000..303d042213 --- /dev/null +++ b/.changeset/notion-shared-database-pages.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': patch +--- + +Discover pages inside directly shared Notion databases in Memory and let agents resolve the database through the Notion MCP even when Notion search omits its rows. From 2128ad2f847249eabad5a3d1501424398b238d1d Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:05:10 +0000 Subject: [PATCH 13/30] chore: release Roomote 0.45.1 --- .changeset/notion-shared-database-pages.md | 5 ----- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) delete mode 100644 .changeset/notion-shared-database-pages.md diff --git a/.changeset/notion-shared-database-pages.md b/.changeset/notion-shared-database-pages.md deleted file mode 100644 index 303d042213..0000000000 --- a/.changeset/notion-shared-database-pages.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@roomote/web': patch ---- - -Discover pages inside directly shared Notion databases in Memory and let agents resolve the database through the Notion MCP even when Notion search omits its rows. diff --git a/CHANGELOG.md b/CHANGELOG.md index 95bc6f3042..04f2289e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.45.1 (2026-08-29) + +This patch restores complete Notion database discovery across Memory and the built-in Notion MCP. + +### Highlights + +- Find and ingest pages inside directly shared Notion databases even when Notion search omits them. + +### Patch changes + +- Discover pages inside directly shared Notion databases in Memory and let agents resolve the database through the Notion MCP even when Notion search omits its rows. + ## 0.45.0 (2026-08-27) This release adds secure hosted trial inference and self-run Brain model options, expands GLM 5.3 support, and improves reliability across Fast sessions, pull-request reviews, Memory, and chat. diff --git a/package.json b/package.json index f3b9f26ee7..66c0d40766 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "0.45.0", + "version": "0.45.1", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { From d0444e2ffeb61383839b7f3ac68eedfe5a2a262f Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:22:44 -0400 Subject: [PATCH 14/30] fix: use catalog default for Fast inference (#2164) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .changeset/fast-default-model-runtime.md | 5 ++ .../__tests__/non-task-provider-usage.test.ts | 45 ++++++++++++++ .../db/src/lib/model-runtime-config.test.ts | 61 +++++++++++++++++++ packages/db/src/lib/model-runtime-config.ts | 7 ++- 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 .changeset/fast-default-model-runtime.md diff --git a/.changeset/fast-default-model-runtime.md b/.changeset/fast-default-model-runtime.md new file mode 100644 index 0000000000..54c9dfb2a2 --- /dev/null +++ b/.changeset/fast-default-model-runtime.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': patch +--- + +Use the task model catalog default for Fast sessions when no explicit orchestration or coding model override is configured. diff --git a/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts b/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts index af3d59b007..2649f1e826 100644 --- a/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts +++ b/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts @@ -1537,6 +1537,51 @@ describe('resolveOpenCodeSmallModel', () => { }); }); + it('uses the deployment coding model for Fast inference when no orchestration override is configured', async () => { + process.env = { + ...originalEnv, + }; + mockResolveEffectiveModelRuntimeEnv.mockResolvedValue({ + R_MODEL: 'openrouter/z-ai/glm-5.2', + OPENROUTER_API_KEY: 'test-key', + }); + sessionPromptMock.mockResolvedValue({ + data: { + info: {}, + parts: [{ type: 'text', text: 'ok' }], + }, + error: undefined, + }); + + const { + generateTrackedNonTaskTextInOpenCodeSession, + NON_TASK_INFERENCE_SURFACES, + } = await import('../non-task-provider-usage.js'); + + await generateTrackedNonTaskTextInOpenCodeSession( + { + surface: NON_TASK_INFERENCE_SURFACES.fastAgentQuestionAnswering, + modelRole: 'orchestration', + prompt: 'Answer.', + }, + { id: 'default-fast-session' }, + { + directory: '/tmp/roomote-fast-default-test', + tools: { '*': false, send_chat_reply: true }, + }, + ); + + expect(sessionPromptMock).toHaveBeenCalledWith( + expect.objectContaining({ + model: { + providerID: 'openrouter', + modelID: 'z-ai/glm-5.2', + }, + }), + expect.any(Object), + ); + }); + it('does not apply coding reasoning to a non-reasoning orchestration model', async () => { process.env = { ...originalEnv, diff --git a/packages/db/src/lib/model-runtime-config.test.ts b/packages/db/src/lib/model-runtime-config.test.ts index d287ff8571..d6b2263551 100644 --- a/packages/db/src/lib/model-runtime-config.test.ts +++ b/packages/db/src/lib/model-runtime-config.test.ts @@ -104,6 +104,11 @@ describe('resolveEffectiveModelRuntimeEnv', () => { roomoteModel: 'anthropic/claude-sonnet-4', roomoteVisionModel: 'anthropic/claude-opus-4.7', }, + taskModelSettings: { + models: [], + allowedModelIds: [], + defaultModelId: 'openrouter/z-ai/glm-5.2', + }, }); const env = await resolveEffectiveModelRuntimeEnv({ @@ -132,6 +137,62 @@ describe('resolveEffectiveModelRuntimeEnv', () => { }); }); + it('uses the task model catalog default when no runtime model override is configured', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ + runtimeModelConfig: {}, + taskModelSettings: { + models: [ + { + id: 'openrouter/z-ai/glm-5.2', + displayName: 'GLM 5.2', + family: 'GLM', + }, + ], + allowedModelIds: ['openrouter/z-ai/glm-5.2'], + defaultModelId: 'openrouter/z-ai/glm-5.2', + }, + }); + + const env = await resolveEffectiveModelRuntimeEnv({ + runtimeEnv: {}, + deploymentEnvVars: { OPENROUTER_API_KEY: 'sk-openrouter' }, + }); + + expect(env).toMatchObject({ + R_MODEL: 'openrouter/z-ai/glm-5.2', + R_MODEL_REASONING_EFFORT: 'medium', + R_MODEL_ENV_KEYS: 'OPENROUTER_API_KEY', + OPENROUTER_API_KEY: 'sk-openrouter', + }); + }); + + it('keeps explicit orchestration and coding models ahead of the catalog default', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ + runtimeModelConfig: {}, + taskModelSettings: { + models: [], + allowedModelIds: [], + defaultModelId: 'openrouter/z-ai/glm-5.2', + }, + }); + + const env = await resolveEffectiveModelRuntimeEnv({ + runtimeEnv: { + R_MODEL: 'openrouter/openai/gpt-5.4', + R_ORCHESTRATION_MODEL: 'anthropic/claude-sonnet-4', + }, + deploymentEnvVars: { + OPENROUTER_API_KEY: 'sk-openrouter', + ANTHROPIC_API_KEY: 'sk-anthropic', + }, + }); + + expect(env).toMatchObject({ + R_MODEL: 'openrouter/openai/gpt-5.4', + R_ORCHESTRATION_MODEL: 'anthropic/claude-sonnet-4', + }); + }); + it('rejects the dev-login placeholder before control-plane inference can use it', async () => { mockDeploymentSettingsFindFirst.mockResolvedValue({ runtimeModelConfig: { diff --git a/packages/db/src/lib/model-runtime-config.ts b/packages/db/src/lib/model-runtime-config.ts index d59d455be6..1a5177cc10 100644 --- a/packages/db/src/lib/model-runtime-config.ts +++ b/packages/db/src/lib/model-runtime-config.ts @@ -408,7 +408,7 @@ async function resolveModelRuntimeEnv( const executor = options.executor ?? db; const [ persistedEnvVars, - { runtimeModelConfig, catalogModels, enabledCatalogModels }, + { runtimeModelConfig, catalogModels, enabledCatalogModels, defaultModelId }, ] = await Promise.all([ resolveEffectiveDeploymentEnvVars({ deploymentEnvVars: options.deploymentEnvVars, @@ -445,7 +445,10 @@ async function resolveModelRuntimeEnv( runtimeOverrideModelConfig[descriptor.modelConfigKey] ?? normalizeConfiguredValue( persistedRuntimeModelConfig[descriptor.modelConfigKey], - ), + ) ?? + (descriptor.modelFallback === 'deployment-default' + ? defaultModelId + : undefined), ), ]; }), From 01904a0df8ddd1e51b04e0429ab353d8e12c1f1d Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:35:54 +0000 Subject: [PATCH 15/30] Release Roomote 1.2.3 --- .changeset/fast-default-model-runtime.md | 5 ----- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) delete mode 100644 .changeset/fast-default-model-runtime.md diff --git a/.changeset/fast-default-model-runtime.md b/.changeset/fast-default-model-runtime.md deleted file mode 100644 index 54c9dfb2a2..0000000000 --- a/.changeset/fast-default-model-runtime.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@roomote/web': patch ---- - -Use the task model catalog default for Fast sessions when no explicit orchestration or coding model override is configured. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1807e3f9b9..6bc83f9dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 1.2.3 (2026-09-03) + +Roomote 1.2.3 restores Fast Sessions for deployments that rely on the default task model. + +### Highlights + +- Start Fast Sessions with the default task model when no explicit model override is configured. + +### Patch changes + +- Use the task model catalog default for Fast sessions when no explicit orchestration or coding model override is configured. + ## 1.2.2 (2026-09-03) Roomote 1.2.2 strengthens deployment and setup reliability while making Slack-driven Fast conversations clearer and more consistent. diff --git a/package.json b/package.json index 387d1bec94..299c68c9a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "1.2.2", + "version": "1.2.3", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { From 313b0d5bfd50c2f59af29546ed3a950f9b1d1788 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:03:56 +0100 Subject: [PATCH 16/30] [Improve] Complete setup before optional starter work (#2127) * fix: complete setup before optional starter work * fix: show sandbox setup before starter selection --------- Co-authored-by: Roomote --- apps/docs/self-hosting.mdx | 14 +-- .../AuthenticatedLayoutClient.client.test.tsx | 49 ++++++++++ .../AuthenticatedLayoutClient.tsx | 20 ++-- apps/web/src/app/(sandbox)/SandboxShell.tsx | 6 +- .../SessionUserInputCard.client.test.tsx | 2 +- .../setup/SetupSandboxCard.client.test.tsx | 93 +++++++++++++++++++ .../[sessionId]/setup/SetupSandboxCard.tsx | 11 +-- .../setup/SetupStarterTasksCard.tsx | 2 +- .../layout/side-nav/SideNav.client.test.tsx | 33 ++++++- .../components/layout/side-nav/SideNav.tsx | 12 ++- .../side-nav/SideNavItem.client.test.tsx | 18 ++++ .../layout/side-nav/SideNavItem.tsx | 2 +- apps/web/src/lib/server/source-control.ts | 7 +- .../web/src/trpc/commands/setup/index.test.ts | 43 +++++++++ apps/web/src/trpc/commands/setup/index.ts | 35 ++++++- .../setup/setup-session-completion.test.ts | 93 +++++++++++++++++++ .../setup/setup-session-completion.ts | 38 ++++++++ .../src/trpc/commands/setup/setup-session.ts | 34 +++---- .../commands/source-control/index.test.ts | 13 ++- .../src/trpc/commands/source-control/index.ts | 37 +++++--- .../server/fast-agent/fast-agent-prompt.ts | 4 +- .../fast-agent/fast-agent-setup-tools.test.ts | 5 +- 22 files changed, 499 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSandboxCard.client.test.tsx create mode 100644 apps/web/src/trpc/commands/setup/setup-session-completion.test.ts create mode 100644 apps/web/src/trpc/commands/setup/setup-session-completion.ts diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx index b0ab3b09fd..36e4327451 100644 --- a/apps/docs/self-hosting.mdx +++ b/apps/docs/self-hosting.mdx @@ -195,12 +195,14 @@ recommendations. Detailed provider instructions and credential entry open in a dialog from the source-control card; Roomote never asks for credentials in chat. -When repositories are connected, Roomote offers the preselected starter tasks -in one structured multi-select question. Choosing at least one completes setup -immediately, then Roomote launches each choice as visible work attached to the -same session. A launch failure does not reopen setup; ask Roomote to retry that -item. Automation recommendations are analyzed after repository sync and appear -only after at least one starter task launches. They remain optional. +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 +question. Starter work is optional and does not gate setup completion; selected +work launches visibly in the same session. A launch failure does not reopen +setup; ask Roomote to retry that item. Automation recommendations are analyzed +after repository sync and appear only after at least one starter task launches. +They remain optional. After setup, run a small task that uses the first environment if you did not start one from the starter list. A healthy deployment should let you: diff --git a/apps/web/src/app/(authenticated)/AuthenticatedLayoutClient.client.test.tsx b/apps/web/src/app/(authenticated)/AuthenticatedLayoutClient.client.test.tsx index 71a488e974..517b9d630a 100644 --- a/apps/web/src/app/(authenticated)/AuthenticatedLayoutClient.client.test.tsx +++ b/apps/web/src/app/(authenticated)/AuthenticatedLayoutClient.client.test.tsx @@ -160,6 +160,30 @@ describe('AuthenticatedLayoutClient', () => { }); }); + it('unlocks authenticated pages when the setup Session completes before the setup-status cache refreshes', () => { + useQueryMock.mockImplementation((options: { queryKey: string[] }) => ({ + data: + options.queryKey[0] === 'setup.sessionStatus' + ? { sessionId: 'setup-session-id', completed: true } + : { + hasGitHub: true, + hasEnvironments: true, + setupCompletedAt: null, + }, + isLoading: false, + isError: false, + })); + + render( + +
Home content
+
, + ); + + expect(screen.getByText('Home content')).toBeVisible(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + it('keeps non-setup pages gated while an incomplete admin setup-session lookup is pending', () => { useQueryMock.mockImplementation((options: { queryKey: string[] }) => ({ data: @@ -209,6 +233,31 @@ describe('AuthenticatedLayoutClient', () => { expect(replaceMock).not.toHaveBeenCalled(); }); + it('keeps Settings accessible while admin setup is incomplete', () => { + mockPathname = '/settings/integrations'; + useQueryMock.mockImplementation((options: { queryKey: string[] }) => ({ + data: + options.queryKey[0] === 'setup.sessionStatus' + ? { sessionId: 'setup-session-id', completed: false } + : { + hasGitHub: false, + hasEnvironments: false, + setupCompletedAt: null, + }, + isLoading: options.queryKey[0] === 'setup.sessionStatus', + isError: false, + })); + + render( + +
Settings content
+
, + ); + + expect(screen.getByText('Settings content')).toBeVisible(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + it('renders authenticated pages when setup is complete but environments are still missing', () => { mockPathname = '/settings/previews'; useQueryMock.mockImplementation((options: { queryKey: string[] }) => ({ diff --git a/apps/web/src/app/(authenticated)/AuthenticatedLayoutClient.tsx b/apps/web/src/app/(authenticated)/AuthenticatedLayoutClient.tsx index dbce00b487..4a923abc43 100644 --- a/apps/web/src/app/(authenticated)/AuthenticatedLayoutClient.tsx +++ b/apps/web/src/app/(authenticated)/AuthenticatedLayoutClient.tsx @@ -47,10 +47,6 @@ function AuthenticatedLayoutShell({ children }: { children: React.ReactNode }) { staleTime: 30_000, }), ); - const setupRedirectPath = - shouldCheckSetup && !isSetupError && setupStatus != null - ? getSetupRedirectPath(setupStatus) - : null; const { data: setupSessionStatus, isLoading: isSetupSessionLoading } = useQuery( trpc.setup.sessionStatus.queryOptions(undefined, { @@ -58,6 +54,13 @@ function AuthenticatedLayoutShell({ children }: { children: React.ReactNode }) { staleTime: 10_000, }), ); + const setupRedirectPath = + shouldCheckSetup && + !isSetupError && + setupStatus != null && + setupSessionStatus?.completed !== true + ? getSetupRedirectPath(setupStatus) + : null; const setupSessionPath = setupSessionStatus?.sessionId ? `/sessions/${setupSessionStatus.sessionId}` : null; @@ -65,20 +68,25 @@ function AuthenticatedLayoutShell({ children }: { children: React.ReactNode }) { setupSessionPath !== null && (pathname === setupSessionPath || pathname.startsWith(`${setupSessionPath}/`)); + const isSettingsRoute = + pathname === '/settings' || pathname.startsWith('/settings/'); // An incomplete administrator must not briefly see another authenticated // page while we look up their setup Session. A known setup Session remains // accessible during a background refresh. const isSetupSessionLookupPending = setupRedirectPath !== null && isSetupSessionLoading && + !isSettingsRoute && !isOnKnownSetupSession; - const effectiveSetupRedirectPath = setupSessionPath ?? setupRedirectPath; + const effectiveSetupRedirectPath = + setupRedirectPath === null ? null : (setupSessionPath ?? setupRedirectPath); // Treat the redirect target itself and any page beneath it as allowed so // setup can keep ownership of any remaining required bootstrap screens. const isRedirectingForSetup = !isSetupSessionLookupPending && effectiveSetupRedirectPath !== null && + !isSettingsRoute && pathname !== effectiveSetupRedirectPath && !pathname.startsWith(`${effectiveSetupRedirectPath}/`); const isRedirectingForOnboarding = @@ -131,7 +139,7 @@ function AuthenticatedLayoutShell({ children }: { children: React.ReactNode }) {
- + {children}
diff --git a/apps/web/src/app/(sandbox)/SandboxShell.tsx b/apps/web/src/app/(sandbox)/SandboxShell.tsx index e5639a7044..a1ee1c88a8 100644 --- a/apps/web/src/app/(sandbox)/SandboxShell.tsx +++ b/apps/web/src/app/(sandbox)/SandboxShell.tsx @@ -77,7 +77,9 @@ export function SandboxShell({ ? `/sessions/${setupSessionStatus.sessionId}` : null; const needsAdminSetup = - user?.isAdmin === true && setupStatus?.setupCompletedAt == null; + user?.isAdmin === true && + setupStatus?.setupCompletedAt == null && + setupSessionStatus?.completed !== true; const isAllowedSetupSession = setupSessionPath !== null && pathname === setupSessionPath; const sandboxLayoutValue = useMemo( @@ -131,7 +133,7 @@ export function SandboxShell({ {/* Main layout with side nav on desktop */}
- {isSignedIn && } + {isSignedIn && }
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.client.test.tsx index d6f34b09fd..426a9f95f1 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.client.test.tsx @@ -170,7 +170,7 @@ describe('SessionUserInputCard', () => { expect(screen.getByText('First task ideas')).toBeInTheDocument(); expect( screen.getByText( - 'I found a few things I could do right away. Click the button to get it going:', + 'Optional: choose something I can start working on right away.', ), ).toBeInTheDocument(); expect( diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSandboxCard.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSandboxCard.client.test.tsx new file mode 100644 index 0000000000..fbc8bd085c --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSandboxCard.client.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +const { state, fetchQueryMock } = vi.hoisted(() => ({ + state: { computeReady: false }, + fetchQueryMock: vi.fn(), +})); + +vi.mock('@/components/system', () => ({ + Container: () => , +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + setup: { + sessionStatus: { queryOptions: () => ({ query: 'session-status' }) }, + }, + setupNew: { + status: { + queryKey: () => ['setup-new-status'], + queryOptions: () => ({ query: 'setup-new-status' }), + }, + saveComputeProviderChoice: { mutationOptions: () => ({}) }, + }, + }), +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ isPending: false, mutate: vi.fn() }), + useQuery: () => ({ + data: { + computeSetup: { + setupSatisfied: state.computeReady, + selectedProvider: null, + }, + setupNewState: { setupSession: { starterTaskSelection: null } }, + }, + }), + useQueryClient: () => ({ + fetchQuery: fetchQueryMock, + invalidateQueries: vi.fn(), + }), +})); + +vi.mock('./SandboxConfiguration', () => ({ + SandboxConfiguration: () =>
Sandbox configuration
, +})); + +vi.mock('./SandboxProviderPicker', () => ({ + SandboxProviderPicker: () =>
Sandbox provider picker
, +})); + +vi.mock('./SetupSessionActionCard', () => ({ + SetupSessionActionCard: ({ + title, + children, + }: { + title: string; + children: ReactNode; + }) => ( +
+

{title}

+ {children} +
+ ), +})); + +import { SetupSandboxCard } from './SetupSandboxCard'; + +describe('SetupSandboxCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + state.computeReady = false; + }); + + it('offers sandbox setup before optional starter work is selected', () => { + render(); + + expect( + screen.getByRole('heading', { name: 'I need a sandbox to run tasks' }), + ).toBeInTheDocument(); + expect(screen.getByText('Sandbox provider picker')).toBeInTheDocument(); + }); + + it('stays hidden once compute setup is ready', () => { + state.computeReady = true; + + render(); + + expect(screen.queryByText('Sandbox provider picker')).toBeNull(); + expect(fetchQueryMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSandboxCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSandboxCard.tsx index eeab5e2c20..1caa52829f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSandboxCard.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSandboxCard.tsx @@ -15,8 +15,8 @@ import { SetupSessionActionCard } from './SetupSessionActionCard'; /** * Inline sandbox setup for the conversational setup session. Runtime/env-var * configured providers make the compute status ready and therefore never show - * this card. Otherwise, starter work remains persisted as intent while this - * trusted provider/configuration UI is completed. + * this card. Otherwise, this trusted provider/configuration UI remains + * available before or after the administrator optionally selects starter work. */ export function SetupSandboxCard() { const trpc = useTRPC(); @@ -42,9 +42,6 @@ export function SetupSandboxCard() { ); const computeSetup = statusQuery.data?.computeSetup; - const setupSessionHasStarterSelection = Boolean( - statusQuery.data?.setupNewState.setupSession?.starterTaskSelection, - ); const computeReady = computeSetup?.setupSatisfied === true; // A provisioning completion can make the card disappear on the next status @@ -55,7 +52,7 @@ export function SetupSandboxCard() { void queryClient.fetchQuery(trpc.setup.sessionStatus.queryOptions()); }, [computeReady, queryClient, trpc.setup.sessionStatus]); - if (!computeSetup || computeReady || !setupSessionHasStarterSelection) { + if (!computeSetup || computeReady) { return null; } @@ -64,7 +61,7 @@ export function SetupSandboxCard() { return ( } intro="Tasks run in isolated VMs called sandboxes, where I can verify my work." > diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupStarterTasksCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupStarterTasksCard.tsx index ae3f23d6a6..17ca450ae9 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupStarterTasksCard.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupStarterTasksCard.tsx @@ -21,7 +21,7 @@ export function SetupStarterTasksCard({ } - intro="I found a few things I could do right away. Click the button to get it going:" + intro="Optional: choose something I can start working on right away." > ({ onClick, tooltip, expanded, + disabled, }: { href?: string; onClick?: () => void; tooltip: string; expanded?: boolean; + disabled?: boolean; }) => href ? ( -
+
) : ( - ) : href != null ? ( + ) : href != null && !disabled ? ( ); + + return disabled ? ( + + {control} + + {SETUP_INCOMPLETE_NAV_TOOLTIP} + + + ) : ( + {control} + ); })} ); - if (expanded || !tooltip) { + if ((expanded && !isFocusableDisabled) || !tooltip) { return control; } From 1633a90ea5bf13a6e78d1413f0a9a5477df1f28b Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:43:46 +0000 Subject: [PATCH 18/30] chore: add setup completion hotfix changeset --- .changeset/setup-completion-actionable-navigation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/setup-completion-actionable-navigation.md diff --git a/.changeset/setup-completion-actionable-navigation.md b/.changeset/setup-completion-actionable-navigation.md new file mode 100644 index 0000000000..94b6fc83fd --- /dev/null +++ b/.changeset/setup-completion-actionable-navigation.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': patch +--- + +Complete setup as soon as infrastructure and repository prerequisites are ready, while keeping setup-gated destinations visible but disabled with an explanation until setup finishes. From 10540a20d922b30b87f656bae76eb3383644e354 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:44:13 +0000 Subject: [PATCH 19/30] chore: release Roomote 1.2.4 --- .../setup-completion-actionable-navigation.md | 5 ----- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) delete mode 100644 .changeset/setup-completion-actionable-navigation.md diff --git a/.changeset/setup-completion-actionable-navigation.md b/.changeset/setup-completion-actionable-navigation.md deleted file mode 100644 index 94b6fc83fd..0000000000 --- a/.changeset/setup-completion-actionable-navigation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@roomote/web': patch ---- - -Complete setup as soon as infrastructure and repository prerequisites are ready, while keeping setup-gated destinations visible but disabled with an explanation until setup finishes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc83f9dfb..949783cf41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 1.2.4 (2026-09-04) + +Roomote 1.2.4 completes setup from infrastructure readiness and makes unavailable product areas clear without interrupting the active setup Session. + +### Highlights + +- Finish setup without choosing optional starter work once infrastructure and repository prerequisites are ready. +- See which product areas become available after setup, with unavailable destinations disabled and explained. + +### Patch changes + +- Complete setup as soon as infrastructure and repository prerequisites are ready, while keeping setup-gated destinations visible but disabled with an explanation until setup finishes. + ## 1.2.3 (2026-09-03) Roomote 1.2.3 restores Fast Sessions for deployments that rely on the default task model. diff --git a/package.json b/package.json index 299c68c9a6..39010dde2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "1.2.3", + "version": "1.2.4", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { From 21742d157fe74f0ebbf098edadc09ea6fc664bce Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:37:01 +0000 Subject: [PATCH 20/30] Amend Roomote 1.3.0 with GPT-6 Astra --- CHANGELOG.md | 1 + apps/docs/models.mdx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d9c24eda..0254c2b781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Roomote 1.3 brings every supported entry point into continuous Sessions, expands - Create durable artifacts from any Fast turn, open Session and task artifact links or images in the side panel, and use Build This to delegate a plan through its owning Session. - Launch Roomote's structured pull-request review directly from a Session and keep automatic reviews attached to the Session that opened the pull request. - Add an opt-in Therapist Mode that names the remembered fact that informed a Session or task without exposing internal Memory metadata. +- Add GPT-6 Astra to the curated model catalog for OpenRouter, OpenAI API, Roomote inference, and ChatGPT subscription, including ChatGPT Fast mode. ### Patch changes diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index 37cfe32530..04d82b50b2 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -131,6 +131,10 @@ off, and they cannot be deleted while their provider stays connected — turn a model off to stop using it. To go beyond the recommended set, add any model by its slug from the add-model field. +Roomote 1.3 adds **GPT-6 Astra** to this recommended set for OpenRouter, OpenAI +API, ChatGPT Subscription, and Roomote inference. Existing model defaults do not +change; enable Astra and assign it to a role when you want to use it. + ### Recommended default models Providers also carry recommended defaults for the model roles below — for From 237177a6751ed3c36a32cf39af5f8ded28802ffb Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:28:37 +0000 Subject: [PATCH 21/30] fix: avoid dangling refs in Fast integration tool schemas (#2295) Co-authored-by: @daniel-lxs <57051444+daniel-lxs@users.noreply.github.com> --- packages/cloud-agents/package.json | 1 + .../fast-agent-native-tool-schemas.test.ts | 97 +++++++++++++++---- .../fast-agent-native-tool-bridge.ts | 4 +- pnpm-lock.yaml | 3 + 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/packages/cloud-agents/package.json b/packages/cloud-agents/package.json index bb0636afa2..5b7f118e97 100644 --- a/packages/cloud-agents/package.json +++ b/packages/cloud-agents/package.json @@ -93,6 +93,7 @@ "@types/ioredis-mock": "^8.2.6", "@types/node": "^24.10.13", "@types/jsdom": "21.1.7", + "ajv": "^8.20.0", "ioredis-mock": "^8.13.1", "vitest": "^4.1.1" } 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 bf644133ac..9f487a53bd 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 @@ -4,7 +4,12 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { FAST_AGENT_NATIVE_TOOL_NAMES } from '@roomote/types'; +import { + CALL_INTEGRATION_TOOL_TOOL, + FAST_AGENT_NATIVE_TOOL_NAMES, +} from '@roomote/types'; +import { z } from 'zod'; +import { Ajv2020 } from 'ajv/dist/2020.js'; import { getFastAgentNativeToolRuntime } from '../fast-agent-native-tool-bridge'; @@ -12,8 +17,9 @@ import { getFastAgentNativeToolRuntime } from '../fast-agent-native-tool-bridge' * Guards the JSON schema OpenAI receives for every Fast native tool. * * OpenCode loads each generated tool module with its own zod 4, treats - * `args` as a record of field schemas (wrapping it in `z.object`), and ships - * `z.toJSONSchema` of that to the provider. A tool that declares `args` as a + * `args` as a record of field schemas (wrapping it in `z.object`), and + * normalizes `z.toJSONSchema` before sending it to the provider. A tool + * that declares `args` as a * bare schema instead of a record (a `z.union`, say) turns into a schema * carrying zod internals, which OpenAI rejects with * `invalid_function_parameters` on every request, taking down every Fast turn @@ -192,12 +198,22 @@ function toOpenCodeJsonSchema(zod: ZodV4, args: unknown) { )} ${nonZod.length === 1 ? 'is' : 'are'} not. OpenCode wraps args in z.object itself; a bare schema (z.union, z.object) as args ships its internals to the provider.`, ); } - return zod.z.toJSONSchema(zod.z.object(args as Record), { - io: 'input', - }); + const schema = zod.z.toJSONSchema( + zod.z.object(args as Record), + { + io: 'input', + }, + ); + // OpenCode v1.18.10 tool/registry.ts zodJsonSchema renames the dictionary + // without rewriting refs. Testing raw Zod output missed this boundary. + const { $defs, ...rest } = schema; + return JSON.parse( + JSON.stringify($defs ? { ...rest, definitions: $defs } : rest), + ); } describe('Fast native tool schemas as OpenAI receives them', () => { + const validator = new Ajv2020({ strict: false }); let workDir: string; let zod: ZodV4; let tools: LoadedTool[]; @@ -266,6 +282,7 @@ describe('Fast native tool schemas as OpenAI receives them', () => { let schema: unknown; try { schema = toOpenCodeJsonSchema(zod, tool.args ?? {}); + validator.compile(schema as object); } catch (error) { failures.push( `${tool.name}: ${error instanceof Error ? error.message : String(error)}`, @@ -311,23 +328,13 @@ describe('Fast native tool schemas as OpenAI receives them', () => { ); const schema = toOpenCodeJsonSchema(zod, callTool?.args ?? {}) as { properties?: Record; - $defs?: Record; }; const argsSchema = schema.properties?.args as - | { type?: string; additionalProperties?: { $ref?: string } } + | { type?: string; additionalProperties?: { anyOf?: unknown[] } } | undefined; - const valueSchemaName = argsSchema?.additionalProperties?.$ref?.replace( - '#/$defs/', - '', - ); - const valueSchema = valueSchemaName - ? (schema.$defs?.[valueSchemaName] as - | { anyOf?: Array<{ type?: string }> } - | undefined) - : undefined; expect(argsSchema?.type).toBe('object'); - expect(valueSchema?.anyOf).toEqual( + expect(argsSchema?.additionalProperties?.anyOf).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'string' }), expect.objectContaining({ type: 'object' }), @@ -336,6 +343,60 @@ describe('Fast native tool schemas as OpenAI receives them', () => { ); }); + it('detects dangling refs after OpenCode normalizes recursive Zod schemas', () => { + const args = { + args: zod.z.record(zod.z.string(), zod.z.json()).optional(), + }; + expect(() => + validator.compile( + zod.z.toJSONSchema(zod.z.object(args), { io: 'input' }), + ), + ).not.toThrow(); + expect(() => validator.compile(toOpenCodeJsonSchema(zod, args))).toThrow( + /can't resolve reference #\/\$defs\//, + ); + }); + + it('preserves nested JSON through serialized native schema validation and server parsing', () => { + const callTool = tools.find( + (tool) => tool.name === FAST_AGENT_NATIVE_TOOL_NAMES.callIntegrationTool, + )!; + const validate = validator.compile( + toOpenCodeJsonSchema(zod, callTool.args), + ); + const nativeSchema = zod.z.object(callTool.args as Record); + const serverSchema = z.object(CALL_INTEGRATION_TOOL_TOOL.inputSchema); + const base = { integrationId: 'example', toolName: 'nested_tool' }; + for (const args of [ + undefined, + {}, + { + text: 'value', + number: 1.5, + enabled: true, + nullable: null, + list: [ + null, + false, + 42, + 'text', + [], + {}, + { nested: [{ 'arbitrary/key': { values: [1, null] } }] }, + ], + object: { nested: { list: [[{ value: 'preserved' }]] } }, + }, + ]) { + const input = JSON.parse(JSON.stringify({ ...base, args })); + expect(validate(input), JSON.stringify(validate.errors)).toBe(true); + expect(nativeSchema.parse(input)).toEqual(input); + expect(serverSchema.parse(input)).toEqual(input); + } + for (const args of [null, 'text', [], 42, false]) { + expect(validate({ ...base, args })).toBe(false); + } + }); + it('rejects a bare union or object as args, the shape that broke OpenAI models', () => { const { z } = zod; const question = z.object({ id: z.string() }); diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index 62e07a6eb9..d3111bbe38 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 @@ -437,7 +437,9 @@ export default { args: { integrationId: z.string().min(1).describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.integrationId)}), toolName: z.string().min(1).describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.toolName)}), - args: z.record(z.string(), z.json()).optional().describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.args)}), + // OpenCode renames $defs without rewriting refs. Keep JSON value types + // concrete but non-recursive; nested values are validated server-side. + args: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.unknown()), z.record(z.string(), z.unknown())])).optional().describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.args)}), }, execute: (args, context) => invoke(${JSON.stringify(CALL_INTEGRATION_TOOL_TOOL.name)}, args, context), } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e37dd3d257..55d2bb3226 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1201,6 +1201,9 @@ importers: '@types/node': specifier: ^24.10.13 version: 24.12.4 + ajv: + specifier: ^8.20.0 + version: 8.20.0 ioredis-mock: specifier: ^8.13.1 version: 8.13.1(@types/ioredis-mock@8.2.6(ioredis@5.10.1))(ioredis@5.10.1) From 6c20f966a54f059bee7bfea516ef173c4ad6c441 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:14:38 +0000 Subject: [PATCH 22/30] chore: describe Fast integration schema hotfix --- .changeset/fast-integration-schema-hotfix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fast-integration-schema-hotfix.md diff --git a/.changeset/fast-integration-schema-hotfix.md b/.changeset/fast-integration-schema-hotfix.md new file mode 100644 index 0000000000..74e9ef3f58 --- /dev/null +++ b/.changeset/fast-integration-schema-hotfix.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': patch +--- + +Fix Fast turns failing with integration tool schema errors while preserving support for nested integration arguments. From ccae4eae9d68b32fd64e7892599caaef8057ed4a Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:18:12 +0000 Subject: [PATCH 23/30] chore: release Roomote 1.3.2 --- .changeset/fast-integration-schema-hotfix.md | 5 ----- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) delete mode 100644 .changeset/fast-integration-schema-hotfix.md diff --git a/.changeset/fast-integration-schema-hotfix.md b/.changeset/fast-integration-schema-hotfix.md deleted file mode 100644 index 74e9ef3f58..0000000000 --- a/.changeset/fast-integration-schema-hotfix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@roomote/web': patch ---- - -Fix Fast turns failing with integration tool schema errors while preserving support for nested integration arguments. diff --git a/CHANGELOG.md b/CHANGELOG.md index cc95b89496..8ee260fa72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 1.3.2 (2026-09-06) + +Roomote 1.3.2 fixes integration tool schema errors that can prevent Fast conversations from responding. + +### Highlights + +- Keep Fast integration calls working with nested objects, arrays, and other JSON arguments. + +### Patch changes + +- Fix Fast turns failing with integration tool schema errors while preserving support for nested integration arguments. + ## 1.3.1 (2026-09-05) Roomote 1.3.1 improves Fast, Live Preview, chat, and MCP coordination while adding focused controls for pull request reviews and custom automations. diff --git a/package.json b/package.json index d68c6277af..431b721841 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "1.3.1", + "version": "1.3.2", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { From ac65d877aec2524b483df2f50f561a843459c770 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 8 Sep 2026 15:02:58 -0400 Subject: [PATCH 24/30] [Fix] Sandbox integration calls send args: null on gpt-5.x models (#2364) --- .changeset/integration-tool-args-required.md | 5 +++ ...n-demand-integrations-registration.test.ts | 22 +++++++------ .../fast-agent-native-tool-schemas.test.ts | 5 ++- .../fast-agent-native-tool-bridge.ts | 4 ++- packages/types/src/integration-tool-lookup.ts | 32 ++++++++++++------- 5 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 .changeset/integration-tool-args-required.md diff --git a/.changeset/integration-tool-args-required.md b/.changeset/integration-tool-args-required.md new file mode 100644 index 0000000000..e4096ea5a1 --- /dev/null +++ b/.changeset/integration-tool-args-required.md @@ -0,0 +1,5 @@ +--- +'roomote': patch +--- + +Fix on-demand integration calls from sandbox tasks: `call_integration_tool` now declares `args` as a required, non-recursive object on both the member MCP server and Fast, so gpt-5.x models stop sending `args: null` and Sentry, Linear, and Notion lookups run again. diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations-registration.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations-registration.test.ts index fa668cbef8..ef5753ae89 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations-registration.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations-registration.test.ts @@ -43,7 +43,7 @@ describe('roomote MCP on-demand integration tool registration', () => { expect(tools.call_integration_tool).toBeDefined(); }); - it('exposes integration call args as an object with arbitrary JSON values', async () => { + it('exposes integration call args as a required, ref-free object', async () => { process.env.ROOMOTE_ON_DEMAND_MCP_CATALOG_PATH = '/tmp/catalog.json'; const { roomoteMcpServer } = await import('../index.js'); const [clientTransport, serverTransport] = @@ -59,17 +59,21 @@ describe('roomote MCP on-demand integration tool registration', () => { ); const argsSchema = callTool?.inputSchema.properties?.args as | { - anyOf?: Array<{ - type?: string; - additionalProperties?: { anyOf?: Array<{ type?: string }> }; - }>; + type?: string; + anyOf?: unknown[]; + additionalProperties?: { anyOf?: Array<{ type?: string }> }; } | undefined; - const objectSchema = argsSchema?.anyOf?.find( - (schema) => schema.type === 'object', - ); - expect(objectSchema?.additionalProperties?.anyOf).toEqual( + // The member server makes optional fields nullable, which would turn + // `args` into `anyOf [object, null]`; gpt-5.x models then send null on + // every call. A recursive value schema would serialize to `$ref`s that + // downstream schema rewrites leave dangling. Neither may come back. + expect(callTool?.inputSchema.required).toContain('args'); + expect(argsSchema?.type).toBe('object'); + expect(argsSchema?.anyOf).toBeUndefined(); + expect(JSON.stringify(callTool?.inputSchema)).not.toContain('$ref'); + expect(argsSchema?.additionalProperties?.anyOf).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'string' }), expect.objectContaining({ type: 'object' }), 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 48ad0e8a2c..6963a81798 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 @@ -368,7 +368,6 @@ describe('Fast native tool schemas as OpenAI receives them', () => { const serverSchema = z.object(CALL_INTEGRATION_TOOL_TOOL.inputSchema); const base = { integrationId: 'example', toolName: 'nested_tool' }; for (const args of [ - undefined, {}, { text: 'value', @@ -395,6 +394,10 @@ describe('Fast native tool schemas as OpenAI receives them', () => { for (const args of [null, 'text', [], 42, false]) { expect(validate({ ...base, args })).toBe(false); } + // Omitting args is rejected too: the field is required so the provider + // schema never carries a null alternative. + expect(validate(base)).toBe(false); + expect(serverSchema.safeParse(base).success).toBe(false); }); it('preserves required Sentry organization scope through generated tool execution and server parsing', async () => { 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 7611fa3ac3..18790a0624 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 @@ -463,7 +463,9 @@ export default { toolName: z.string().min(1).describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.toolName)}), // OpenCode renames $defs without rewriting refs. Keep JSON value types // concrete but non-recursive; nested values are validated server-side. - args: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.unknown()), z.record(z.string(), z.unknown())])).optional().describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.args)}), + // Required (not optional) so no provider ever sees a null alternative + // that gpt-5.x models prefer over filling in an object. + args: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.unknown()), z.record(z.string(), z.unknown())])).describe(${JSON.stringify(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.args)}), }, execute: (args, context) => invoke(${JSON.stringify(CALL_INTEGRATION_TOOL_TOOL.name)}, args, context), } diff --git a/packages/types/src/integration-tool-lookup.ts b/packages/types/src/integration-tool-lookup.ts index 8523b56ad9..ecf03ba1d7 100644 --- a/packages/types/src/integration-tool-lookup.ts +++ b/packages/types/src/integration-tool-lookup.ts @@ -77,19 +77,24 @@ export const FIND_INTEGRATION_TOOLS_ARG_DESCRIPTIONS = { export const CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS = { integrationId: `Exact on-demand integration id from ${FAST_AGENT_NATIVE_TOOL_NAMES.findIntegrationTools}`, toolName: 'Exact tool name on that integration', - args: "Tool arguments matching the tool's input schema", + args: "Tool arguments matching the tool's input schema. Pass {} when the tool takes none.", } as const; -const integrationToolArgumentValueSchema: z.ZodType = z.lazy(() => - z.union([ - z.string(), - z.number(), - z.boolean(), - z.null(), - z.array(integrationToolArgumentValueSchema), - z.record(integrationToolArgumentValueSchema), - ]), -); +/** + * Argument values stay concrete but non-recursive on the wire. A recursive + * `z.lazy` schema serializes to JSON-pointer `$ref`s that OpenCode and + * provider schema rewrites leave dangling; gpt-5.x models then read `args` + * as unsatisfiable and send `null` on every call. Nested values are + * validated by the integration's own schema when the tool runs. + */ +const integrationToolArgumentValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(z.unknown()), + z.record(z.unknown()), +]); /** * The on-demand integration tools as the model sees them on every surface. @@ -154,9 +159,12 @@ export const CALL_INTEGRATION_TOOL_TOOL = { .trim() .min(1) .describe(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.toolName), + // Required on purpose: an optional field becomes `anyOf [object, null]` + // on the member MCP server (see NullableOptionalsMcpServer), and gpt-5.x + // models take the null branch for a property-less object even when the + // integration tool needs arguments. args: z .record(integrationToolArgumentValueSchema) - .optional() .describe(CALL_INTEGRATION_TOOL_ARG_DESCRIPTIONS.args), }, annotations: { From 860cb044ede6af5e2d3f7be4d12ec8e7b2bbc62e Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:21:46 +0000 Subject: [PATCH 25/30] docs: clarify integration hotfix release note --- .changeset/integration-tool-args-required.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/integration-tool-args-required.md b/.changeset/integration-tool-args-required.md index e4096ea5a1..321fc42d0f 100644 --- a/.changeset/integration-tool-args-required.md +++ b/.changeset/integration-tool-args-required.md @@ -1,5 +1,5 @@ --- -'roomote': patch +'@roomote/web': patch --- -Fix on-demand integration calls from sandbox tasks: `call_integration_tool` now declares `args` as a required, non-recursive object on both the member MCP server and Fast, so gpt-5.x models stop sending `args: null` and Sentry, Linear, and Notion lookups run again. +On-demand integration lookups work again with GPT-5.x models in sandbox tasks and Fast, fixing failed requests to services such as Sentry, Linear, and Notion without changing their connection settings. From 0f6ce3618c079f3824d4ce2cb7085fb0334e1b0d Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:24:46 +0000 Subject: [PATCH 26/30] chore: release Roomote 1.4.1 --- .changeset/integration-tool-args-required.md | 5 ----- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) delete mode 100644 .changeset/integration-tool-args-required.md diff --git a/.changeset/integration-tool-args-required.md b/.changeset/integration-tool-args-required.md deleted file mode 100644 index 321fc42d0f..0000000000 --- a/.changeset/integration-tool-args-required.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@roomote/web': patch ---- - -On-demand integration lookups work again with GPT-5.x models in sandbox tasks and Fast, fixing failed requests to services such as Sentry, Linear, and Notion without changing their connection settings. diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5695b7b3..fa2d97c427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 1.4.1 (2026-09-08) + +Roomote 1.4.1 restores integration lookups that failed with GPT-5.x models. + +### Highlights + +- Use connected services such as Sentry, Linear, and Notion again without changing their connection settings. + +### Patch changes + +- On-demand integration lookups work again with GPT-5.x models in sandbox tasks and Fast, fixing failed requests to services such as Sentry, Linear, and Notion without changing their connection settings. + ## 1.4.0 (2026-09-08) Roomote 1.4 brings reminders, clearer shared Sessions, and richer video evidence together with easier automation setup and more reliable everyday work. diff --git a/package.json b/package.json index 8ca73dfffd..e549d8d079 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "1.4.0", + "version": "1.4.1", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { From 6e4253d85e5e6fc90896ab123e538982eb5af65f Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 9 Sep 2026 15:26:27 +0000 Subject: [PATCH 27/30] feat: introduce optional integration discovery during setup --- apps/docs/self-hosting.mdx | 12 + .../(authenticated)/home/OnboardingCard.tsx | 35 +- .../FastSessionTranscript.client.test.tsx | 125 +++-- .../[sessionId]/FastSessionTranscript.tsx | 7 + .../SessionUserInputCard.client.test.tsx | 27 + .../[sessionId]/SessionUserInputCard.tsx | 11 +- .../SetupIntegrationsCard.client.test.tsx | 260 +++++++++ .../setup/SetupIntegrationsCard.tsx | 349 ++++++++++++ .../components/settings/Integrations.test.tsx | 58 +- .../src/components/settings/Integrations.tsx | 77 ++- .../trpc/commands/fast-sessions/index.test.ts | 286 ++++++++++ .../src/trpc/commands/fast-sessions/index.ts | 43 +- .../trpc/commands/setup/setup-session.test.ts | 498 ++++++++++++++++++ .../src/trpc/commands/setup/setup-session.ts | 266 ++++++++-- .../fast-agent-native-tool-schemas.test.ts | 20 + .../__tests__/fast-agent-service.test.ts | 103 ++++ .../fast-agent/fast-agent-conversation.ts | 5 +- .../fast-agent-native-tool-bridge.ts | 5 +- .../server/fast-agent/fast-agent-prompt.ts | 14 +- .../server/fast-agent/fast-agent-service.ts | 39 +- .../fast-agent/fast-agent-setup-tools.test.ts | 30 ++ .../types/src/acp-request-user-input.test.ts | 17 + packages/types/src/acp.ts | 12 +- packages/types/src/index.ts | 1 + .../types/src/onboarding-integrations.test.ts | 186 +++++++ packages/types/src/onboarding-integrations.ts | 203 +++++++ packages/types/src/setup-new.test.ts | 17 + packages/types/src/setup-new.ts | 12 + 28 files changed, 2550 insertions(+), 168 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx create mode 100644 apps/web/src/trpc/commands/setup/setup-session.test.ts create mode 100644 packages/types/src/onboarding-integrations.test.ts create mode 100644 packages/types/src/onboarding-integrations.ts diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx index 8809652286..71c27e9cdd 100644 --- a/apps/docs/self-hosting.mdx +++ b/apps/docs/self-hosting.mdx @@ -202,6 +202,18 @@ recommendations. Detailed provider instructions and credential entry open in a dialog from the source-control card; Roomote never asks for credentials in chat. +The conversation also asks briefly about the tools your team uses for +documents, monitoring, and project tracking, one topic at a time. +You can skip these questions. The optional integrations card highlights matching +available connectors and opens their secure configuration without leaving setup. +Tools without a built-in connector are not presented as supported. Use +**Continue without connections** to move on; you can connect tools later in +Settings. Integration choices do not change the starter tasks offered. +Services that are also source-control, communications, inference, or sandbox +providers are excluded from this optional step; their separate setup is unchanged. +The Vercel deployments integration remains available separately from Vercel AI +Gateway inference. + Setup completes once inference and a sandbox provider are ready, source control is successfully configured, and at least one repository has synchronized. Roomote then offers preselected starter tasks in one structured multi-select diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx index 1a45f0b95e..fd2bb4b1f0 100644 --- a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx +++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx @@ -5,7 +5,12 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { AnimatePresence, motion } from 'motion/react'; import { toast } from 'sonner'; -import { MCP_INTEGRATIONS } from '@roomote/types'; +import { + MCP_INTEGRATIONS, + ADMIN_INTEGRATION_ORDER, + COMMUNICATION_PROVIDER_ORDER, + SOURCE_CONTROL_PROVIDER_ORDER, +} from '@roomote/types'; import { useAuthorizedUser } from '@/hooks/useUser'; import { @@ -50,19 +55,6 @@ import { TelegramLinkAccountStep } from '@/components/settings/TelegramLinkAccou const DISMISSED_KEY = 'OnboardingCardsDismissedByOrg'; const DISMISSED_DEPLOYMENT_KEY = 'deployment'; -const ADMIN_INTEGRATION_ORDER = [ - 'notion', - 'sentry', - 'linear', - 'jira', - 'monday', - 'vercel', - 'supabase', - 'posthog', - 'grafana', - 'asana', -] as const; - const PERSONAL_MCP_INTEGRATION_ORDER = ['monday', 'supabase'] as const; const CARD_EXIT_TRANSITION = { @@ -82,21 +74,6 @@ const CARD_ANIMATION = { exit: { opacity: 0, y: -20, transition: CARD_EXIT_TRANSITION }, } as const; -const COMMUNICATION_PROVIDER_ORDER = [ - 'slack', - 'microsoft', - 'telegram', - 'discord', -] as const; - -const SOURCE_CONTROL_PROVIDER_ORDER = [ - 'github', - 'gitlab', - 'gitea', - 'bitbucket', - 'ado', -] as const; - type CardConfig = { id: string; icon: ReactNode; diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index fd0aafce29..7451a883fa 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,60 +485,76 @@ describe('FastSessionTranscript', () => { }); }); - it('removes a structured-input card when its response control event arrives', () => { - const requestId = 'rui:setup-starters'; - const request = { - ...textMessage({ - id: 'starter-request', - role: 'assistant', - text: 'Choose starter tasks', - ts: 1, - }), - eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, - payload: { - requestId, - status: 'pending', - sessionId: 'session-1', - turnId: 'turn-1', - callId: 'call-1', - preset: 'setup_starter_tasks', - questions: [ - { - id: 'starters', - question: 'What should I work on first?', - multiple: true, - isOther: false, - isSecret: false, - options: [{ label: 'Speed up CI', description: 'Improve CI.' }], - }, - ], - }, - }; - const response = { - ...textMessage({ - id: 'starter-response', - role: 'user', - text: 'Structured response', - ts: 2, - }), - eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse, - payload: { - requestId, - answers: { starters: { answers: ['Speed up CI'] } }, - resolution: 'submitted', - }, - }; - - render( - , - ); + it.each(['setup_starter_tasks', 'setup_integrations'])( + 'renders and removes the %s card when its response control event arrives', + (preset) => { + const requestId = 'rui:setup-starters'; + const request = { + ...textMessage({ + id: 'starter-request', + role: 'assistant', + text: 'Choose starter tasks', + ts: 1, + }), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, + payload: { + requestId, + status: 'pending', + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + preset, + questions: [ + { + id: 'starters', + question: 'What should I work on first?', + multiple: true, + isOther: false, + isSecret: false, + options: [{ label: 'Speed up CI', description: 'Improve CI.' }], + }, + ], + }, + }; + const response = { + ...textMessage({ + id: 'starter-response', + role: 'user', + text: 'Structured response', + ts: 2, + }), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse, + payload: { + requestId, + answers: { starters: { answers: ['Speed up CI'] } }, + resolution: 'submitted', + }, + }; + + const { unmount } = render( + , + ); + const cardLabel = + preset === 'setup_integrations' + ? 'Optional integration setup' + : 'Setup starter tasks'; + expect(screen.getByText(cardLabel)).toBeInTheDocument(); + unmount(); + render( + , + ); - expect(screen.queryByText('Structured input request')).toBeNull(); - expect(screen.queryByText('Structured response')).toBeNull(); - }); + expect(screen.queryByText('Structured input request')).toBeNull(); + expect(screen.queryByText('Structured response')).toBeNull(); + expect(screen.queryByText(cardLabel)).toBeNull(); + }, + ); it.each([ [1, '1 task running'], diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 72ed37fe06..be5b587922 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -63,6 +63,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 { @@ -855,6 +856,12 @@ export function FastSessionTranscript({ sessionId={sessionId} request={pendingInputRequest} /> + ) : pendingInputRequest.preset === 'setup_integrations' ? ( + ) : ( { mockMutate.mockClear(); }); + it('allows skipping tool discovery before entering an answer', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Skip tool setup' })); + expect(mockMutate).toHaveBeenCalledWith({ + sessionId: 's', + requestId: 'tools', + answers: {}, + resolution: 'cancelled', + }); + }); + it('requires the minimum number of selections before submitting', () => { render(); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx index 3af15b1246..c1d7479e21 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx @@ -3,6 +3,8 @@ import { useMemo, useState } from 'react'; import { parseAcpRequestUserInputPayload, + SETUP_INTEGRATION_CATEGORIES, + getSetupIntegrationQuestionId, type AcpRequestUserInputPayload, } from '@roomote/types'; @@ -356,7 +358,14 @@ export function SessionUserInputCard({ }) } > - Cancel + {request.questions.some((question) => + SETUP_INTEGRATION_CATEGORIES.some( + (category) => + getSetupIntegrationQuestionId(category.id) === question.id, + ), + ) + ? 'Skip tool setup' + : 'Cancel'} ) : null} + + ); + })} + +
+ + +
+

+ Don't see your tool? There may not be a built-in connector for it + yet. No credentials belong in this conversation. +

+ + {submit.isError ? ( +

+ Couldn't continue setup. Please try again. +

+ ) : null} + { + if (!open) { + setActiveId(null); + refresh(); + } + }} + > + + + + {active ? `Connect ${active.name}` : 'Connect a tool'} + + + Use the secure configuration below. You can cancel and continue + setup without connecting. + + + {active ? : null} + {active && + activeDefinition && + active.id !== 'linear' && + !isDeploymentScopedMcpIntegration(activeDefinition) && + enabledIds.has(active.id) && + !authenticatedIds.has(active.id) ? ( + + ) : null} + + + + + + + ); +} diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 4ad0f023ba..766d0eec08 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -94,6 +94,7 @@ const state = vi.hoisted(() => ({ }, }, linearRedirectPath: '', + pathname: '/settings/integrations', searchParams: '', })); @@ -150,7 +151,7 @@ function cloneMcpToolsData() { } vi.mock('next/navigation', () => ({ - usePathname: () => '/settings/integrations', + usePathname: () => state.pathname, useSearchParams: () => new URLSearchParams(state.searchParams), })); @@ -515,6 +516,7 @@ describe('Integrations settings', () => { linearOrganizationName: 'Roomote', }; state.linearRedirectPath = ''; + state.pathname = '/settings/integrations'; state.asanaConnection = null; state.notionConnection = null; state.ripplingConnection = null; @@ -552,6 +554,60 @@ describe('Integrations settings', () => { ); }); + it('renders only requested integrations in passed order without custom servers or groups', () => { + render(); + expect( + screen.getAllByRole('heading').map((heading) => heading.textContent), + ).toEqual(['Integrations', 'Notion', 'Sentry', 'Linear']); + expect(screen.queryByText('Add custom server')).not.toBeInTheDocument(); + }); + + it('does not leak custom servers when filtered integrations are disabled', () => { + state.integrationsEnabled = false; + render(); + expect( + screen.getByText('Integrations disabled by deployment operator'), + ).toBeInTheDocument(); + expect(screen.queryByText('Add custom server')).not.toBeInTheDocument(); + }); + + it('preserves the embedded pathname for Linear and MCP OAuth', () => { + state.pathname = '/sessions/setup-session'; + state.linearInstallation = null; + render(); + expect(state.linearRedirectPath).toBe('/sessions/setup-session'); + fireEvent.click( + screen.getByRole('button', { name: 'Connect and enable Pylon' }), + ); + expect(mutations.connectMcp).toHaveBeenCalledWith( + { mcpId: 'pylon', redirectTo: '/sessions/setup-session' }, + expect.any(Object), + ); + }); + + it('keeps filtered deployment configuration read-only for non-admins', () => { + state.isAdmin = false; + render(); + expect(screen.getByRole('heading', { name: 'Notion' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Configure Notion' }), + ).not.toBeInTheDocument(); + }); + + it('clears an embedded secret on cancellation without saving', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' })); + fireEvent.change(screen.getByLabelText('Internal integration secret'), { + target: { value: 'test-secret' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(mutations.saveNotionConnection).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' })); + expect(screen.getByLabelText('Internal integration secret')).toHaveValue( + '', + ); + }); + it('uses the settings action for missing Linear OAuth setup', () => { state.linearInstallation = null; state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index 21332eae1b..11a488d624 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -1379,7 +1379,11 @@ function VercelConnectionFields({ ); } -export function Integrations() { +export function Integrations({ + integrationIds, +}: { + integrationIds?: readonly string[]; +} = {}) { const pathname = usePathname(); const searchParams = useSearchParams(); const { isAdmin } = useAuthorizedUser(); @@ -1479,7 +1483,9 @@ export function Integrations() { const [isLinearOauthSetupOpen, setIsLinearOauthSetupOpen] = useState(false); const linearInstallation = useLinearInstallation(); - const connectLinear = useConnectLinear(`${pathname}?service=linear`); + const connectLinear = useConnectLinear( + integrationIds === undefined ? `${pathname}?service=linear` : pathname, + ); const disconnectLinear = useDisconnectLinear(); const deploymentEnablements = useDeploymentMcpEnablements(); @@ -2228,7 +2234,13 @@ export function Integrations() { }), ]; - return sortIntegrationItems(baseItems, highlightedIntegrationId); + return integrationIds === undefined + ? sortIntegrationItems(baseItems, highlightedIntegrationId) + : [...new Set(integrationIds)].flatMap((id) => + baseItems.filter( + (item) => item.id === (id === 'sentry' ? 'sentry-mcp' : id), + ), + ); }, [ connectLinear, connectMcp, @@ -2257,6 +2269,7 @@ export function Integrations() { saveVercelConnection.isPending, deploymentEnablements.data, pathname, + integrationIds, setDeploymentEnabled, saveSnowflakeConnection.isPending, asanaConnection.isPending, @@ -2906,7 +2919,7 @@ export function Integrations() { instance. - {customMcpEnabled ? ( + {integrationIds === undefined && customMcpEnabled ? ( <> {customMcpDialogs} @@ -3170,32 +3183,42 @@ export function Integrations() { deepLinkDialogItem.onAction?.(); }} /> - {customMcpDialogs} - {customMcpEnabled ? ( - - ) : null} - - You haven't connected any integrations yet. -

- } - /> - {configured.length > 0 && ( + {integrationIds !== undefined ? ( + ) : ( + <> + {customMcpDialogs} + {customMcpEnabled ? ( + + ) : null} + + You haven't connected any integrations yet. +

+ } + /> + {configured.length > 0 && ( + + )} + + )} -
); } diff --git a/apps/web/src/trpc/commands/fast-sessions/index.test.ts b/apps/web/src/trpc/commands/fast-sessions/index.test.ts index ab959e372b..f8520e565f 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.test.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.test.ts @@ -23,6 +23,9 @@ const mocks = vi.hoisted(() => ({ dbSelect: vi.fn(), dbInnerJoin: vi.fn(), dbSelectLimit: vi.fn(), + resolveSetupContext: vi.fn().mockResolvedValue(null), + submitSetupInput: vi.fn(), + upsertMessage: vi.fn(), sql: vi.fn(), })); @@ -35,6 +38,7 @@ vi.mock('@roomote/cloud-agents/server', () => ({ FastAgentDurableRetryScheduledError: class FastAgentDurableRetryScheduledError extends Error {}, getOrCreateFastAgentSession: mocks.getOrCreateSession, resolveApiBaseUrl: vi.fn(), + upsertFastAgentMessage: mocks.upsertMessage, })); vi.mock('@roomote/sdk/server', () => ({ @@ -82,6 +86,11 @@ vi.mock('./pinned-launch', () => ({ startPinnedFastSessionLaunch: mocks.startPinnedLaunch, })); +vi.mock('../setup/setup-session', () => ({ + resolveSetupSessionTurnContext: mocks.resolveSetupContext, + submitSetupSessionUserInputCommand: mocks.submitSetupInput, +})); + import { getFastSessionTasksCommand, handleFastSessionPrReviewActionCommand, @@ -90,6 +99,7 @@ import { startFastSessionCommand, startSetupFastSessionCommand, updateFastSessionModelSelectionCommand, + submitFastSessionUserInputCommand, } from './index'; describe('getFastSessionTasksCommand', () => { @@ -140,6 +150,282 @@ describe('getFastSessionTasksCommand', () => { }); }); +describe('setup context on ordinary Fast session input', () => { + afterEach(() => { + mocks.resolveSetupContext.mockReset().mockResolvedValue(null); + }); + const resolvePreset = vi.fn(); + const initialSnapshot = JSON.stringify({ + integrationDiscovery: { completed: false, answeredCategoryIds: [] }, + }); + const freshSnapshot = JSON.stringify({ + integrationDiscovery: { + completed: false, + answeredCategoryIds: ['documents'], + matchedIntegrationIds: ['granola'], + }, + }); + const setupContext = { + setupSession: true, + adapterExtensions: { resolveUserInputPreset: resolvePreset }, + setupSnapshot: initialSnapshot, + }; + const question = { + id: 'setup-tools-documents', + header: 'Documents', + question: 'Where do you keep documents?', + isOther: true, + isSecret: false, + }; + const request = { + eventId: 'request-event', + turnId: 'request-turn', + payload: { + requestId: 'request-1', + sessionId: 'session-1', + turnId: 'request-turn', + callId: 'request-call', + status: 'pending', + questions: [question], + }, + }; + const input = { + sessionId: 'session-1', + requestId: 'request-1', + answers: { 'setup-tools-documents': { answers: ['Granola'] } }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveSetupContext.mockReset().mockResolvedValue(null); + mocks.upsertMessage.mockReset().mockResolvedValue(undefined); + mocks.findAccessibleSession.mockResolvedValue(session); + mocks.acquireTurnLock.mockResolvedValue( + Object.assign(vi.fn().mockResolvedValue(undefined), { + signal: new AbortController().signal, + }), + ); + mocks.answerQuestion.mockResolvedValue('Ready'); + mocks.buildReplyDelivery.mockResolvedValue({ + conversation: { + surface: 'web', + workspaceId: 'user-1', + conversationId: 'session-1', + }, + adapter: { launchTask: mocks.launchTask, postReply: vi.fn() }, + }); + mocks.retireReviewActions.mockResolvedValue([]); + mocks.updateOfferStatus.mockResolvedValue(undefined); + mocks.dbSelect.mockReturnValue({ + from: () => ({ + where: () => ({ + limit: mocks.dbSelectLimit, + orderBy: () => ({ limit: mocks.dbSelectLimit }), + }), + }), + }); + mocks.dbSelectLimit.mockReset().mockResolvedValue([]); + mocks.submitSetupInput.mockResolvedValue({ success: true }); + }); + + async function runScheduled() { + expect(mocks.after).toHaveBeenCalledOnce(); + await mocks.after.mock.calls[0]![0](); + return mocks.answerQuestion.mock.calls[0]![0]; + } + + it('attaches setup adapters and snapshot to ordinary prose replies', async () => { + mocks.resolveSetupContext.mockResolvedValue(setupContext); + await replyToFastSessionCommand(auth, { + sessionId: session.id, + text: 'We use Granola. Skip the other questions.', + }); + const turn = await runScheduled(); + expect(turn).toMatchObject({ + setupSession: true, + setupSnapshot: initialSnapshot, + adapter: { resolveUserInputPreset: resolvePreset }, + }); + expect(mocks.resolveSetupContext).toHaveBeenCalledWith(auth, session.id); + }); + + it('leaves ordinary non-setup replies unchanged', async () => { + await replyToFastSessionCommand(auth, { + sessionId: session.id, + text: 'Review this change.', + }); + const turn = await runScheduled(); + expect(turn.setupSession).toBeUndefined(); + expect(turn.setupSnapshot).toBeUndefined(); + expect(turn.adapter.resolveUserInputPreset).toBeUndefined(); + }); + + it('refreshes setup snapshots after category response persistence, overriding stale caller context', async () => { + mocks.dbSelectLimit + .mockResolvedValueOnce([request]) + .mockResolvedValueOnce([]); + mocks.resolveSetupContext + .mockResolvedValueOnce(setupContext) + .mockImplementation(async () => { + expect(mocks.upsertMessage).toHaveBeenCalledOnce(); + return { ...setupContext, setupSnapshot: freshSnapshot }; + }); + await submitFastSessionUserInputCommand(auth, input, { + setupSession: true, + setupSnapshot: initialSnapshot, + }); + const turn = await runScheduled(); + expect(turn).toMatchObject({ + setupSession: true, + setupSnapshot: freshSnapshot, + adapter: { resolveUserInputPreset: resolvePreset }, + }); + expect(mocks.upsertMessage).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.objectContaining({ + payload: expect.objectContaining({ + answers: input.answers, + resolution: 'submitted', + }), + }), + }), + ); + }); + + it.each(['documents', 'communication'])( + 'resumes cancelled %s discovery questions as an early skip without marking discovery complete', + async (category) => { + mocks.dbSelectLimit + .mockResolvedValueOnce([ + { + ...request, + payload: { + ...request.payload, + questions: [{ ...question, id: `setup-tools-${category}` }], + }, + }, + ]) + .mockResolvedValueOnce([]); + const skippedSnapshot = JSON.stringify({ + integrationDiscovery: { completed: false, skipped: true }, + }); + mocks.resolveSetupContext + .mockResolvedValueOnce(setupContext) + .mockImplementation(async () => { + expect(mocks.upsertMessage).toHaveBeenCalledOnce(); + return { ...setupContext, setupSnapshot: skippedSnapshot }; + }); + await submitFastSessionUserInputCommand(auth, { + ...input, + answers: {}, + resolution: 'cancelled', + }); + const turn = await runScheduled(); + expect(turn).toMatchObject({ + setupSession: true, + setupSnapshot: skippedSnapshot, + }); + expect(turn.question).toContain('"resolution":"cancelled"'); + }, + ); + + it('keeps generic non-setup submissions and cancellation behavior unchanged', async () => { + mocks.dbSelectLimit + .mockResolvedValueOnce([request]) + .mockResolvedValueOnce([]); + await submitFastSessionUserInputCommand(auth, input); + const turn = await runScheduled(); + expect(turn.setupSession).toBe(false); + expect(turn.setupSnapshot).toBeUndefined(); + expect(turn.adapter.resolveUserInputPreset).toBeUndefined(); + expect(turn.question).toBe( + `${JSON.stringify({ requestId: input.requestId, answers: input.answers })}`, + ); + mocks.after.mockClear(); + mocks.dbSelectLimit + .mockResolvedValueOnce([request]) + .mockResolvedValueOnce([]); + await submitFastSessionUserInputCommand(auth, { + ...input, + answers: {}, + resolution: 'cancelled', + }); + expect(mocks.after).not.toHaveBeenCalled(); + }); + + it('replays a saved setup category response with a fresh snapshot without persisting twice', async () => { + const saved = { + eventId: 'response-event', + payload: { + requestId: 'request-1', + sessionId: 'session-1', + turnId: 'request-turn', + callId: 'request-call', + answers: input.answers, + resolution: 'submitted', + }, + }; + mocks.dbSelectLimit + .mockResolvedValueOnce([request]) + .mockResolvedValueOnce([saved]); + mocks.resolveSetupContext.mockResolvedValue({ + ...setupContext, + setupSnapshot: freshSnapshot, + }); + await submitFastSessionUserInputCommand(auth, input); + expect(mocks.upsertMessage).not.toHaveBeenCalled(); + expect(await runScheduled()).toMatchObject({ + setupSession: true, + setupSnapshot: freshSnapshot, + }); + }); + + it('routes final presets through setup-specific persistence, not ordinary response writes', async () => { + mocks.resolveSetupContext.mockResolvedValue(setupContext); + const final = { + ...request, + payload: { + ...request.payload, + preset: 'setup_integrations', + questions: [ + { + ...question, + id: 'setup-integrations', + isOther: false, + options: [ + { + id: 'continue', + label: 'Continue', + description: 'Continue without connections', + }, + ], + }, + ], + }, + }; + mocks.dbSelectLimit + .mockResolvedValueOnce([final]) + .mockResolvedValueOnce([]); + const finalInput = { + ...input, + answers: { 'setup-integrations': { answers: ['Continue'] } }, + }; + await submitFastSessionUserInputCommand(auth, finalInput); + expect(mocks.submitSetupInput).toHaveBeenCalledWith(auth, finalInput); + expect(mocks.upsertMessage).not.toHaveBeenCalled(); + expect(mocks.after).not.toHaveBeenCalled(); + }); + + it('checks setup admin ownership before an ordinary response is persisted', async () => { + mocks.resolveSetupContext.mockRejectedValue(new Error('Unauthorized')); + await expect( + submitFastSessionUserInputCommand(auth, input), + ).rejects.toThrow('Unauthorized'); + expect(mocks.upsertMessage).not.toHaveBeenCalled(); + expect(mocks.after).not.toHaveBeenCalled(); + }); +}); + const auth = { userId: 'user-1', isAdmin: false, diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts index 0db5c6f25d..f4f83740b2 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.ts @@ -43,6 +43,7 @@ import { formatErrorForLog, getAcpRequestUserInputValidationError, getUserDisplayName, + isSetupIntegrationDiscoveryQuestionId, parseAcpRequestUserInputAnswers, parseAcpRequestUserInputPayload, parseAcpRequestUserInputResponsePayload, @@ -666,6 +667,9 @@ export async function replyToFastSessionCommand( if (!session) { throw new Error('Fast session not found'); } + const { resolveSetupSessionTurnContext } = + await import('../setup/setup-session'); + const setupContext = await resolveSetupSessionTurnContext(auth, session.id); const senderDisplayName = getUserDisplayName({ name: auth.name, email: auth.primaryEmail }) ?? null; @@ -708,6 +712,7 @@ export async function replyToFastSessionCommand( reasoningEffort: settings.reasoningEffort, ...(senderDisplayName ? { senderDisplayName } : {}), durableSessionId: session.id, + ...setupContext, }); return { success: true }; @@ -778,6 +783,10 @@ export async function submitFastSessionUserInputCommand( if (!session) { throw new Error('Fast session not found'); } + const { resolveSetupSessionTurnContext, submitSetupSessionUserInputCommand } = + await import('../setup/setup-session'); + // Check setup ownership before persisting input; rebuild its snapshot after the write. + const setupContext = await resolveSetupSessionTurnContext(auth, session.id); const [request] = await db .select({ @@ -832,7 +841,19 @@ export async function submitFastSessionUserInputCommand( throw new Error(validationError); } - const scheduleResponseTurn = (answers: AcpRequestUserInputAnswers) => { + const scheduleResponseTurn = async ( + answers: AcpRequestUserInputAnswers, + responseResolution: 'submitted' | 'cancelled', + ) => { + const freshSetupContext = setupContext + ? await resolveSetupSessionTurnContext(auth, session.id) + : null; + const skippedDiscovery = + freshSetupContext && + requestPayload.questions.some((question) => + isSetupIntegrationDiscoveryQuestionId(question.id), + ); + if (responseResolution === 'cancelled' && !skippedDiscovery) return; const responseTurnId = `input-response:${input.requestId}`; const conversation = session.surface === 'automation' @@ -861,6 +882,9 @@ export async function submitFastSessionUserInputCommand( question: `${JSON.stringify({ requestId: input.requestId, answers, + ...(responseResolution === 'cancelled' + ? { resolution: responseResolution } + : {}), })}`, turnSource: 'platform_event', platformEventKind: 'input_response', @@ -878,6 +902,7 @@ export async function submitFastSessionUserInputCommand( ? { setupSnapshot: options.setupSnapshot } : {}), setupSession: options.setupSession ?? false, + ...freshSetupContext, }); }; @@ -885,17 +910,20 @@ export async function submitFastSessionUserInputCommand( const persistedResponse = parseAcpRequestUserInputResponsePayload( existingResponse.payload, ); - if ( - !requestPayload.preset && - persistedResponse?.resolution === 'submitted' - ) { - scheduleResponseTurn(persistedResponse.answers); + if (!requestPayload.preset && persistedResponse) { + await scheduleResponseTurn( + persistedResponse.answers, + persistedResponse.resolution, + ); } return { success: true }; } const responseEventId = `${request.eventId}:response`; if (requestPayload.preset) { + if (setupContext && !options.persistSetupPresetResponse) { + return submitSetupSessionUserInputCommand(auth, input); + } if (!options.persistSetupPresetResponse || resolution !== 'submitted') { throw new Error('This trusted setup response cannot be handled here.'); } @@ -941,8 +969,7 @@ export async function submitFastSessionUserInputCommand( }, }); - if (resolution === 'cancelled') return { success: true }; - scheduleResponseTurn(submitted); + await scheduleResponseTurn(submitted, resolution); return { success: true }; } diff --git a/apps/web/src/trpc/commands/setup/setup-session.test.ts b/apps/web/src/trpc/commands/setup/setup-session.test.ts new file mode 100644 index 0000000000..37fb0c9c62 --- /dev/null +++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts @@ -0,0 +1,498 @@ +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + schedule: vi.fn(), + submit: vi.fn(), + complete: vi.fn(), +})); +vi.mock('../setup-new', () => ({ getSetupNewStatusCommand: mocks.getStatus })); +vi.mock('../fast-sessions', () => ({ + scheduleWebFastAgentTurn: mocks.schedule, + submitFastSessionUserInputCommand: mocks.submit, +})); +vi.mock('./setup-session-completion', () => ({ + completeConversationalSetupIfReady: mocks.complete, +})); +vi.mock('@/lib/server/setup-funnel-telemetry', () => ({ + recordSetupFunnelMilestones: vi.fn(), +})); +vi.mock('@roomote/sdk/server', () => ({ + buildFastAgentArtifactCreator: vi.fn(), + LINEAR_ORG_CONNECTION_ROLE: 'organization', +})); +vi.mock('@roomote/cloud-agents/server', () => ({ + createFastAgentWebTaskLauncher: vi.fn(), +})); +vi.mock('@roomote/telemetry/server', () => ({ captureEvent: vi.fn() })); + +import { + db, + deploymentSettings, + ensureSessionForFastConversation, + eq, + fastAgentConversations, + fastAgentMessages, + sessions, + userFactory, + users, +} from '@roomote/db/server'; +import { + ACP_ENVELOPE_EVENT_TYPES, + createSetupNewSetupSession, + normalizeSetupNewState, + type AcpRequestUserInputPayload, +} from '@roomote/types'; +import type { UserAuthSuccess } from '@/types'; +import { SETUP_STARTER_TASKS } from '@/lib/setup-starter-tasks'; +import { + getOrCreateSetupSessionCommand, + reconcileSetupPlatformEvents, + resolveSetupSessionTurnContext, + scheduleSetupPlatformEvent, + submitSetupSessionUserInputCommand, +} from './setup-session'; + +describe('optional setup integration discovery', () => { + let auth: UserAuthSuccess; + let sessionId: string; + let conversationId: string; + let ts: number; + + async function readState() { + const [row] = await db + .select() + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')); + return normalizeSetupNewState(row?.setupNewState); + } + async function context() { + return (await resolveSetupSessionTurnContext(auth, sessionId))!; + } + async function request(payload: AcpRequestUserInputPayload) { + const row = { + eventId: `event:${payload.requestId}`, + turnId: payload.turnId, + payload, + }; + await db.insert(fastAgentMessages).values({ + ...row, + payload: { ...payload }, + conversationId, + turnSeq: 0, + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, + role: 'assistant', + ts: ts++, + source: 'web', + }); + return row; + } + async function answeredCategory( + category: string, + values: string[], + resolution: 'submitted' | 'cancelled' = 'submitted', + ) { + const payload: AcpRequestUserInputPayload = { + requestId: `category:${category}`, + sessionId: conversationId, + turnId: `turn:${category}`, + callId: category, + status: 'pending', + questions: [ + { + id: `setup-tools-${category}`, + header: category, + question: 'Your tools?', + isOther: true, + isSecret: false, + }, + ], + }; + await request(payload); + await db.insert(fastAgentMessages).values({ + conversationId, + eventId: `response:${category}`, + turnId: payload.turnId, + turnSeq: 1, + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse, + role: 'user', + ts: ts++, + source: 'web', + payload: { + requestId: payload.requestId, + sessionId: conversationId, + turnId: payload.turnId, + callId: category, + answers: { [`setup-tools-${category}`]: { answers: values } }, + resolution, + }, + }); + } + async function continueDiscovery() { + const questions = await ( + await context() + ).adapterExtensions.resolveUserInputPreset!('setup_integrations'); + const row = await request({ + requestId: 'integrations', + sessionId: conversationId, + turnId: 'integrations', + callId: 'integrations', + status: 'pending', + preset: 'setup_integrations', + questions, + }); + mocks.submit.mockImplementation(async (_auth, input, options) => { + await options.persistSetupPresetResponse({ + fastConversationId: conversationId, + request: row, + answers: input.answers, + }); + return { success: true }; + }); + return submitSetupSessionUserInputCommand(auth, { + sessionId, + requestId: 'integrations', + answers: { 'setup-integrations': { answers: ['Continue'] } }, + }); + } + + beforeEach(async () => { + vi.clearAllMocks(); + ts = Date.now(); + const user = await userFactory.create({ role: 'admin' }); + auth = { userId: user.id, isAdmin: true } as UserAuthSuccess; + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + surface: 'web', + userId: user.id, + workspaceId: user.id, + conversationId: `setup-test:${user.id}`, + }) + .returning(); + conversationId = conversation!.id; + const session = await ensureSessionForFastConversation(db, conversationId); + sessionId = session.id; + const state = normalizeSetupNewState({ + setupSession: createSetupNewSetupSession({ sessionId }), + }); + await db + .insert(deploymentSettings) + .values({ id: 'default', setupNewState: state }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { setupNewState: state }, + }); + mocks.getStatus.mockImplementation(async () => ({ + setupNewState: await readState(), + setupCompletedAt: null, + modelSetup: { setupSatisfied: true }, + computeSetup: { setupSatisfied: true, providers: [] }, + sourceControlSetup: { + setupSatisfied: true, + providers: [ + { + provider: 'github', + label: 'GitHub', + connected: true, + repositoryCount: 1, + }, + ], + }, + })); + mocks.complete.mockResolvedValue(true); + }); + afterEach(async () => { + await db + .update(deploymentSettings) + .set({ setupNewState: normalizeSetupNewState({}) }) + .where(eq(deploymentSettings.id, 'default')); + await db.delete(sessions).where(eq(sessions.id, sessionId)); + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, conversationId)); + await db.delete(users).where(eq(users.id, auth.userId)); + }); + + it('continues without any connector or source connection and persists completion', async () => { + mocks.getStatus.mockImplementation(async () => ({ + setupNewState: await readState(), + setupCompletedAt: null, + modelSetup: { setupSatisfied: true }, + computeSetup: { setupSatisfied: false, providers: [] }, + sourceControlSetup: { setupSatisfied: false, providers: [] }, + })); + const questions = await ( + await context() + ).adapterExtensions.resolveUserInputPreset!('setup_integrations'); + expect(questions[0]?.options?.map((option) => option.id)).toEqual([ + 'continue', + ]); + await expect(continueDiscovery()).resolves.toEqual({ success: true }); + expect( + (await readState()).setupSession?.integrationDiscoveryCompletedAt, + ).toEqual(expect.any(String)); + expect((await readState()).setupSession?.starterTaskSelection).toBeNull(); + expect( + JSON.parse((await context()).setupSnapshot).integrationDiscovery + .completed, + ).toBe(true); + const responses = await db + .select() + .from(fastAgentMessages) + .where(eq(fastAgentMessages.eventId, 'event:integrations:response')); + expect(responses).toHaveLength(1); + expect(responses[0]?.payload).toMatchObject({ + resolution: 'submitted', + answers: { 'setup-integrations': { answers: ['Continue'] } }, + }); + await expect( + (await context()).adapterExtensions.resolveUserInputPreset!( + 'setup_integrations', + ), + ).rejects.toThrow('already complete'); + }); + + it('persists cancellation as an early skip while leaving final continuation optional', async () => { + await answeredCategory('communication', [], 'cancelled'); + const snapshot = JSON.parse( + (await context()).setupSnapshot, + ).integrationDiscovery; + expect(snapshot).toMatchObject({ + skipped: true, + completed: false, + matchedIntegrationIds: [], + }); + await reconcileSetupPlatformEvents(auth); + expect(mocks.schedule).not.toHaveBeenCalled(); + const questions = await ( + await context() + ).adapterExtensions.resolveUserInputPreset!('setup_integrations'); + expect(questions[0]?.options?.map((option) => option.id)).toEqual([ + 'continue', + ]); + await continueDiscovery(); + expect( + JSON.parse((await context()).setupSnapshot).integrationDiscovery + .completed, + ).toBe(true); + }); + + it('keeps canonical prose-derived connector matches on the persisted final request across reloads', async () => { + const questions = await ( + await context() + ).adapterExtensions.resolveUserInputPreset!('setup_integrations', { + documents: { answers: ['Granola', 'Google Docs'] }, + 'project-tracking': { answers: ['Vercel'] }, + }); + expect(questions[0]?.options?.map((option) => option.id)).toEqual([ + 'vercel', + 'granola', + 'continue', + ]); + await request({ + requestId: 'prose-tools', + sessionId: conversationId, + turnId: 'prose-tools', + callId: 'prose-tools', + status: 'pending', + preset: 'setup_integrations', + questions, + }); + const [saved] = await db + .select() + .from(fastAgentMessages) + .where(eq(fastAgentMessages.eventId, 'event:prose-tools')); + expect(saved?.payload).toMatchObject({ + preset: 'setup_integrations', + questions: [ + { + options: [ + { id: 'vercel', label: 'Vercel' }, + { id: 'granola', label: 'Granola' }, + { id: 'continue', label: 'Continue' }, + ], + }, + ], + }); + expect( + JSON.parse((await context()).setupSnapshot).integrationDiscovery, + ).toMatchObject({ + completed: false, + matchedIntegrationIds: ['vercel', 'granola'], + }); + }); + + it('resumes persisted category answers and exactly matches catalog options in homepage order', async () => { + await answeredCategory('communication', ['Discord', 'slack']); + await answeredCategory('monitoring', ['Grafana', 'Sentry', 'Datadog']); + const turn = await context(); + const snapshot = JSON.parse(turn.setupSnapshot).integrationDiscovery; + expect(snapshot.answeredCategoryIds).toEqual(['monitoring']); + expect(snapshot.unsupportedTools).toEqual(['Datadog']); + expect( + snapshot.categories.map((category: { id: string }) => category.id), + ).toEqual(['documents', 'monitoring', 'project-tracking']); + const questions = await turn.adapterExtensions.resolveUserInputPreset!( + 'setup_integrations', + { + communication: { answers: ['Teams'] }, + documents: { answers: ['notion'] }, + 'project-tracking': { answers: ['Jira-like'] }, + }, + ); + expect(questions[0]?.options?.map((option) => option.id)).toEqual([ + 'notion', + 'sentry', + 'grafana', + 'continue', + ]); + await continueDiscovery(); + expect( + JSON.parse((await context()).setupSnapshot).integrationDiscovery + .matchedIntegrationIds, + ).toEqual(['sentry', 'grafana']); + }); + + it('filters provider IDs out of old persisted preset options and new hints', async () => { + await request({ + requestId: 'legacy-integrations', + sessionId: conversationId, + turnId: 'legacy', + callId: 'legacy', + status: 'pending', + preset: 'setup_integrations', + questions: [ + { + id: 'setup-integrations', + header: 'Tools', + question: 'Your tools?', + isOther: false, + isSecret: false, + options: [ + { id: 'slack', label: 'Slack', description: 'Old provider option' }, + { + id: 'vercel', + label: 'Vercel', + description: 'Old provider option', + }, + { + id: 'supabase', + label: 'Supabase', + description: 'Eligible connector', + }, + ], + }, + ], + }); + const turn = await context(); + expect( + JSON.parse(turn.setupSnapshot).integrationDiscovery.matchedIntegrationIds, + ).toEqual(['vercel', 'supabase']); + const questions = await turn.adapterExtensions.resolveUserInputPreset!( + 'setup_integrations', + { + documents: { answers: ['Slack', 'Vercel', 'Railway'] }, + communication: { answers: ['discord'] }, + }, + ); + expect(questions[0]?.options?.map(({ id }) => id)).toEqual([ + 'vercel', + 'supabase', + 'railway', + 'continue', + ]); + }); + + it('suppresses async setup events and starter choices during discovery without gating setup completion', async () => { + expect(await reconcileSetupPlatformEvents(auth)).toBe(true); + expect(mocks.complete).toHaveBeenCalled(); + expect( + mocks.schedule.mock.calls.map( + ([turn]) => + JSON.parse(turn.question.replace(/<\/?platform_event>/g, '')).type, + ), + ).toEqual(['session_creation']); + await answeredCategory('documents', ['Notion']); + mocks.schedule.mockClear(); + await reconcileSetupPlatformEvents(auth); + for (const kind of [ + 'provider_selection', + 'source_connection', + 'compute_readiness', + 'starter_selection', + 'recommendation_readiness', + ] as const) { + expect( + await scheduleSetupPlatformEvent(auth, { + kind, + fingerprint: 'test', + payload: {}, + }), + ).toEqual({ scheduled: false }); + } + expect(mocks.schedule).not.toHaveBeenCalled(); + await expect( + (await context()).adapterExtensions.resolveUserInputPreset!( + 'setup_starter_tasks', + ), + ).rejects.toThrow('optional tool discovery'); + await continueDiscovery(); + expect( + mocks.schedule.mock.calls.some(([turn]) => + turn.question.includes('starter_request'), + ), + ).toBe(true); + const questions = await ( + await context() + ).adapterExtensions.resolveUserInputPreset!('setup_starter_tasks'); + expect(questions[0]?.options).toEqual( + SETUP_STARTER_TASKS.map((task) => ({ + label: task.title, + description: task.description, + })), + ); + }); + + it('preserves old sessions without retroactively starting optional discovery', async () => { + const state = await readState(); + delete state.setupSession!.integrationDiscoveryCompletedAt; + await db + .update(deploymentSettings) + .set({ setupNewState: state }) + .where(eq(deploymentSettings.id, 'default')); + expect( + JSON.parse((await context()).setupSnapshot).integrationDiscovery + .completed, + ).toBe(true); + await expect(getOrCreateSetupSessionCommand(auth)).resolves.toEqual({ + sessionId, + created: false, + }); + expect( + mocks.schedule.mock.calls.some(([turn]) => + turn.question.includes('starter_request'), + ), + ).toBe(true); + }); + + it('restricts setup continuation and context to its admin owner', async () => { + await expect( + submitSetupSessionUserInputCommand( + { ...auth, isAdmin: false }, + { sessionId, requestId: 'integrations', answers: {} }, + ), + ).rejects.toThrow('Unauthorized'); + await expect( + submitSetupSessionUserInputCommand( + { ...auth, userId: 'other-admin' }, + { sessionId, requestId: 'integrations', answers: {} }, + ), + ).rejects.toThrow('does not belong'); + await expect( + resolveSetupSessionTurnContext( + { ...auth, userId: 'other-admin' }, + sessionId, + ), + ).resolves.toBeNull(); + expect(mocks.submit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts index a25ab4064e..a93156a476 100644 --- a/apps/web/src/trpc/commands/setup/setup-session.ts +++ b/apps/web/src/trpc/commands/setup/setup-session.ts @@ -22,6 +22,15 @@ import { normalizeSetupNewState, normalizeSetupNewSetupSession, RunStatus, + SETUP_INTEGRATION_CATEGORIES, + SETUP_INTEGRATIONS, + SETUP_INTEGRATIONS_QUESTION_ID, + SETUP_INTEGRATIONS_CONTINUE_OPTION, + getSetupIntegrationQuestionId, + isSetupIntegrationDiscoveryQuestionId, + matchSetupIntegrationAnswers, + parseAcpRequestUserInputPayload, + parseAcpRequestUserInputResponsePayload, type AcpRequestUserInputAnswers, type AcpRequestUserInputPayload, type AutomationRecommendationBatch, @@ -89,6 +98,14 @@ async function assertSetupStarterWorkReady( const setupSession = normalizeSetupNewSetupSession( status.setupNewState.setupSession, ); + if ( + setupSession?.integrationDiscoveryCompletedAt === null && + !setupSession.starterTaskSelection + ) { + throw new Error( + 'Finish or skip the optional tool discovery before choosing first work. No connections are required.', + ); + } if (options.requireStarterSelection && !setupSession?.starterTaskSelection) { throw new Error('Choose your first work before starting a task.'); } @@ -191,6 +208,9 @@ function buildSetupEventTurnId(input: { function buildSetupSnapshot(input: { status: Awaited>; hasSuccessfulStarterLaunch: boolean; + integrationDiscovery: Awaited< + ReturnType + >; }): string { const state = normalizeSetupNewState(input.status.setupNewState); const setupSession = normalizeSetupNewSetupSession(state.setupSession); @@ -200,6 +220,7 @@ function buildSetupSnapshot(input: { ); return JSON.stringify({ + integrationDiscovery: input.integrationDiscovery, rail: deriveSetupRailMilestones(input.status), sourceControl: { selectedProvider: state.sourceControlProvider, @@ -232,6 +253,7 @@ async function resolveSetupSnapshot(auth: UserAuthSuccess): Promise { ); return buildSetupSnapshot({ status, + integrationDiscovery: await readSetupIntegrationDiscovery(auth), hasSuccessfulStarterLaunch: setupSession?.starterTaskSelection ? await hasSuccessfulSetupSessionTaskLaunch( auth, @@ -241,6 +263,109 @@ async function resolveSetupSnapshot(auth: UserAuthSuccess): Promise { }); } +async function readSetupIntegrationDiscovery( + auth: UserAuthSuccess, + suppliedAnswers: AcpRequestUserInputAnswers = {}, +) { + const state = await readSetupNewState(); + const setupSession = normalizeSetupNewSetupSession(state.setupSession); + const conversation = await findSetupSessionConversation(auth); + const messages = conversation + ? await db + .select({ + eventType: fastAgentMessages.eventType, + payload: fastAgentMessages.payload, + }) + .from(fastAgentMessages) + .where( + and( + eq( + fastAgentMessages.conversationId, + conversation.fastConversationId, + ), + sql`${fastAgentMessages.eventType} IN (${ACP_ENVELOPE_EVENT_TYPES.RequestUserInput}, ${ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse})`, + ), + ) + .orderBy(fastAgentMessages.ts, fastAgentMessages.id) + : []; + const requests = new Map(); + const answers: AcpRequestUserInputAnswers = { ...suppliedAnswers }; + let finalMatches: string[] = []; + let skipped = false; + for (const message of messages) { + if (message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) { + const request = parseAcpRequestUserInputPayload(message.payload); + if (request) { + requests.set(request.requestId, request); + if (request.preset === 'setup_integrations') { + finalMatches = request.questions.flatMap( + (question) => + question.options?.flatMap((option) => + option.id ? [option.id] : [], + ) ?? [], + ); + } + } + } + } + // Resolve by request ID rather than assuming distinct or monotonic timestamps. + for (const message of messages) { + if ( + message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse + ) { + const response = parseAcpRequestUserInputResponsePayload(message.payload); + const request = response ? requests.get(response.requestId) : undefined; + if (!response || !request || request.preset) continue; + if (response.resolution === 'cancelled') { + if ( + request.questions.some((question) => + isSetupIntegrationDiscoveryQuestionId(question.id), + ) + ) + skipped = true; + continue; + } + for (const category of SETUP_INTEGRATION_CATEGORIES) { + const questionId = getSetupIntegrationQuestionId(category.id); + if (request.questions.some((question) => question.id === questionId)) { + const answer = response.answers[questionId]; + if (!answer) continue; + if ( + answer.answers.some((value) => + ['skip', 'skip for now'].includes(value.trim().toLowerCase()), + ) + ) + skipped = true; + // Persisted user answers take precedence over model-extracted prose preferences. + answers[questionId] = answer; + } + } + } + } + const matches = matchSetupIntegrationAnswers(answers); + const completed = + setupSession?.integrationDiscoveryCompletedAt !== null || + Boolean(setupSession?.starterTaskSelection); + return { + completed, + skipped, + ...matches, + matchedIntegrationIds: SETUP_INTEGRATIONS.filter( + (integration) => + finalMatches.includes(integration.id) || + matches.matchedIntegrationIds.includes(integration.id), + ).map((integration) => integration.id), + hasInputRequest: requests.size > 0, + categories: SETUP_INTEGRATION_CATEGORIES.map((category) => ({ + ...category, + questionId: getSetupIntegrationQuestionId(category.id), + integrations: SETUP_INTEGRATIONS.filter((integration) => + (category.integrationIds as readonly string[]).includes(integration.id), + ), + })), + }; +} + async function hasSuccessfulSetupSessionTaskLaunch( auth: UserAuthSuccess, selectedAt: string, @@ -303,7 +428,38 @@ async function buildSetupSessionAdapterExtensions( auth: UserAuthSuccess, ): Promise> { return { - resolveUserInputPreset: async (preset) => { + resolveUserInputPreset: async (preset, setupIntegrationAnswers) => { + assertAdmin(auth); + if (!(await findSetupSessionConversation(auth))) + throw new Error('This request does not belong to the setup Session.'); + if (preset === 'setup_integrations') { + const discovery = await readSetupIntegrationDiscovery( + auth, + setupIntegrationAnswers, + ); + if (discovery.completed) + throw new Error('Optional tool discovery is already complete.'); + return [ + { + id: SETUP_INTEGRATIONS_QUESTION_ID, + header: 'Your tools', + question: + 'Connect any useful tools, or continue without connections.', + isOther: false, + isSecret: false, + options: [ + ...SETUP_INTEGRATIONS.filter((integration) => + discovery.matchedIntegrationIds.includes(integration.id), + ).map((integration) => ({ + id: integration.id, + label: integration.name, + description: `Connect ${integration.name} in Settings.`, + })), + SETUP_INTEGRATIONS_CONTINUE_OPTION, + ], + }, + ]; + } if (preset !== 'setup_starter_tasks') { throw new Error('Unsupported setup input preset.'); } @@ -355,6 +511,9 @@ async function buildSetupPlatformEventTurn( prepared?: { conversation: SetupSessionConversation; setupSnapshot: string; + integrationDiscovery: Awaited< + ReturnType + >; }, ): Promise[0] | null> { assertAdmin(auth); @@ -362,6 +521,15 @@ async function buildSetupPlatformEventTurn( prepared?.conversation ?? (await findSetupSessionConversation(auth)); if (!conversation) return null; + const integrationDiscovery = + prepared?.integrationDiscovery ?? + (await readSetupIntegrationDiscovery(auth)); + if ( + !integrationDiscovery.completed && + (input.kind !== 'session_creation' || integrationDiscovery.hasInputRequest) + ) + return null; + const currentMessageId = buildSetupEventTurnId({ sessionId: conversation.sessionId, workflowVersion: conversation.workflowVersion, @@ -430,9 +598,11 @@ export async function reconcileSetupPlatformEvents( setupSession.starterTaskSelection.selectedAt, ) : false; + const integrationDiscovery = await readSetupIntegrationDiscovery(auth); const setupSnapshot = buildSetupSnapshot({ status, hasSuccessfulStarterLaunch, + integrationDiscovery, }); const connected = status.sourceControlSetup.providers.filter( @@ -620,6 +790,7 @@ export async function reconcileSetupPlatformEvents( const turn = await buildSetupPlatformEventTurn(auth, event, { conversation, setupSnapshot, + integrationDiscovery, }); if (turn) scheduleWebFastAgentTurn(turn); } @@ -802,10 +973,18 @@ async function persistSetupPresetResponse(input: { }): Promise { assertAdmin(input.auth); const preset = input.request.payload.preset; - if (preset !== 'setup_starter_tasks') { + if (preset !== 'setup_starter_tasks' && preset !== 'setup_integrations') { throw new Error('The setup starter-task preset is missing.'); } - await assertSetupStarterWorkReady(input.auth); + if (preset === 'setup_starter_tasks') + await assertSetupStarterWorkReady(input.auth); + else if ( + input.answers[SETUP_INTEGRATIONS_QUESTION_ID]?.answers.length !== 1 || + input.answers[SETUP_INTEGRATIONS_QUESTION_ID]?.answers[0] !== + SETUP_INTEGRATIONS_CONTINUE_OPTION.label + ) { + throw new Error('Continue with or without connecting tools.'); + } await db.transaction(async (tx) => { await tx.execute( @@ -860,7 +1039,7 @@ async function persistSetupPresetResponse(input: { }) ?? [], ), ]; - if (taskIds.length === 0) { + if (preset === 'setup_starter_tasks' && taskIds.length === 0) { throw new Error('Select at least one starter task.'); } const selectedAt = new Date(); @@ -868,11 +1047,15 @@ async function persistSetupPresetResponse(input: { ...state, setupSession: { ...setupSession, - starterTaskSelection: { - requestId: input.request.payload.requestId, - taskIds, - selectedAt: selectedAt.toISOString(), - }, + ...(preset === 'setup_integrations' + ? { integrationDiscoveryCompletedAt: selectedAt.toISOString() } + : { + starterTaskSelection: { + requestId: input.request.payload.requestId, + taskIds, + selectedAt: selectedAt.toISOString(), + }, + }), }, }; const now = new Date(); @@ -914,29 +1097,30 @@ async function persistSetupPresetResponse(input: { }, source: 'web', }); - await tx - .insert(fastAgentMessages) - .values({ - conversationId: input.fastConversationId, - ...buildSetupReceiptMessage({ - sessionId: setupSession.sessionId, - workflowVersion: setupSession.workflowVersion, - userId: input.auth.userId, - kind: 'starter_selection', - fingerprint: input.request.payload.requestId, - text: formatStarterSelectionReceipt( - taskIds.map( - (taskId) => - SETUP_STARTER_TASKS.find((task) => task.id === taskId)!.title, + if (preset === 'setup_starter_tasks') + await tx + .insert(fastAgentMessages) + .values({ + conversationId: input.fastConversationId, + ...buildSetupReceiptMessage({ + sessionId: setupSession.sessionId, + workflowVersion: setupSession.workflowVersion, + userId: input.auth.userId, + kind: 'starter_selection', + fingerprint: input.request.payload.requestId, + text: formatStarterSelectionReceipt( + taskIds.map( + (taskId) => + SETUP_STARTER_TASKS.find((task) => task.id === taskId)!.title, + ), ), - ), - payload: { taskIds }, - ts: now.getTime(), - }), - }) - .onConflictDoNothing({ - target: [fastAgentMessages.conversationId, fastAgentMessages.eventId], - }); + payload: { taskIds }, + ts: now.getTime(), + }), + }) + .onConflictDoNothing({ + target: [fastAgentMessages.conversationId, fastAgentMessages.eventId], + }); }); } @@ -968,3 +1152,23 @@ export async function submitSetupSessionUserInputCommand( }, }); } + +/** Attach setup capabilities to ordinary replies as well as structured input turns. */ +export async function resolveSetupSessionTurnContext( + auth: UserAuthSuccess, + sessionId: string, +) { + const conversation = await findSetupSessionConversation(auth); + if ( + !conversation || + (conversation.sessionId !== sessionId && + conversation.fastConversationId !== sessionId) + ) + return null; + assertAdmin(auth); + return { + adapterExtensions: await buildSetupSessionAdapterExtensions(auth), + setupSnapshot: await resolveSetupSnapshot(auth), + setupSession: true as const, + }; +} diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts index 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..ae42a78180 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,109 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }); }); + it.each([undefined, { documents: { answers: ['Notion'] } }])( + 'resolves integration discovery with optional prose preferences: %j', + async (setupIntegrationAnswers) => { + const questions = [ + { + id: 'setup-integrations', + header: 'Connections', + question: 'Which tools would you like to connect?', + isOther: false, + isSecret: false, + options: [ + { id: 'notion', label: 'Notion', description: 'Documents' }, + ], + }, + ]; + const requestUserInput = vi.fn(); + const resolveUserInputPreset = vi.fn(async () => questions); + mocks.generateText.mockImplementation( + async (_params, _session, options) => { + await options.onSessionReady('opencode-session-1'); + await invokeTool(nativeToolNames.requestUserInput, { + preset: 'setup_integrations', + ...(setupIntegrationAnswers !== undefined + ? { setupIntegrationAnswers } + : {}), + questions: [ + { id: 'ignored', header: 'Ignored', question: 'Ignored' }, + ], + }); + return ''; + }, + ); + await answerFastAgentQuestion({ + ...baseParams, + conversation: { + surface: 'web', + workspaceId: 'deployment-1', + conversationId: 'setup-session-1', + }, + turnSource: 'platform_event', + platformEventKind: 'setup', + platformEventVisibility: 'required', + setupSession: true, + adapter: callbacks({ requestUserInput, resolveUserInputPreset }), + }); + expect(resolveUserInputPreset.mock.calls).toEqual([ + setupIntegrationAnswers === undefined + ? ['setup_integrations'] + : ['setup_integrations', setupIntegrationAnswers], + ]); + expect(requestUserInput).toHaveBeenCalledWith({ + requestId: expect.any(String), + preset: 'setup_integrations', + questions, + }); + expect(mocks.upsertMessage).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.objectContaining({ + payload: expect.objectContaining({ questions }), + }), + }), + ); + }, + ); + + it.each(['setup_starter_tasks', undefined])( + 'rejects integration preferences outside their preset: %s', + async (preset) => { + const resolveUserInputPreset = vi.fn(); + const requestUserInput = vi.fn(); + let toolResult: unknown; + mocks.generateText.mockImplementation( + async (_params, _session, options) => { + await options.onSessionReady('opencode-session-1'); + toolResult = await invokeTool(nativeToolNames.requestUserInput, { + ...(preset + ? { preset } + : { + questions: [ + { id: 'q', header: 'Tools', question: 'Which tools?' }, + ], + }), + setupIntegrationAnswers: { documents: { answers: ['Notion'] } }, + }); + return 'Please choose your tools.'; + }, + ); + await answerFastAgentQuestion({ + ...baseParams, + conversation: { + surface: 'web', + workspaceId: 'deployment-1', + conversationId: 'setup-session-1', + }, + setupSession: true, + adapter: callbacks({ requestUserInput, resolveUserInputPreset }), + }); + expect(toolResult).toEqual(expect.objectContaining({ success: false })); + expect(resolveUserInputPreset).not.toHaveBeenCalled(); + expect(requestUserInput).not.toHaveBeenCalled(); + }, + ); + it('rejects request_user_input calls with neither questions nor a preset', async () => { let toolResult: unknown; const requestUserInput = vi.fn(); diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts index c2e46fd888..9f0245c228 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts @@ -174,12 +174,12 @@ export type FastAgentInputRequest = { question: string; isOther: boolean; isSecret: boolean; - options?: Array<{ label: string; description: string }>; + options?: Array<{ id?: string; label: string; description: string }>; multiple?: boolean; }>; }; -export type FastAgentInputPreset = 'setup_starter_tasks'; +export type FastAgentInputPreset = 'setup_starter_tasks' | 'setup_integrations'; /** Surface adapter for side effects available during one Fast turn. */ export type FastAgentTurnAdapter = { @@ -210,6 +210,7 @@ export type FastAgentTurnAdapter = { /** Resolve a trusted preset without accepting model-supplied options. */ resolveUserInputPreset?: ( preset: FastAgentInputPreset, + setupIntegrationAnswers?: Record, ) => Promise; /** * Called when an interrupted turn is still safe to replay and has handed diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index 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..9fc2ae499c 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,9 +273,13 @@ This is often the user's first interaction with Roomote. Make the experience wel ## Conversational Setup You are guiding this deployment's first administrator from runtime readiness to optional starter work. - Treat the setup snapshot as authoritative deployment state. Fast cannot mutate that state. -- Environment creation and communication-provider configuration are out of scope. Never ask for them and never block activation on them. +- Environment creation is out of scope. Optional integration discovery is separate from source-control, communication, inference, and sandbox provider setup; those existing provider flows are unaffected. Use the server's eligible connector catalog, not a broader provider or authentication exclusion. Vercel's deployments connector remains eligible and is distinct from Vercel AI Gateway inference. Do not ask provider-configuration questions in this optional discovery. - The renderer owns presentation of trusted setup controls, but some controls require an explicit tool call from you. Keep those controls separate from my side of the conversation. In user-visible prose, state only the user's goal, the capability I need, the outcome that changed, or the decision the user needs to make. Never name, locate, or instruct the user to interact with UI elements such as cards, rails, dialogs, panels, buttons, presets, or setup steps. Do not describe what the interface displays or will display. Never ask for credentials in chat; detailed source-control instructions and credential entry remain in the trusted interface. -- Source control must be connected and repositories synchronized before setup completes or starter tasks are offered. Inference and sandbox readiness remain prerequisites for completion. When source control is not connected, explain that I need access to the user's source code, then stop after the user-visible response; source-control controls are state-driven. When all completion requirements are ready and the setup snapshot has no starter selection, the server emits a starter-request setup event. Starter work is optional and never gates setup completion. On that event, call \`request_user_input\` with exactly \`{ preset: "setup_starter_tasks" }\`. Do not send a closeout first: that tool call creates the user-visible first-work control and is the terminal response for the turn. Do not replace the tool call with prose asking the user to choose. The server supplies the choices; never invent or repeat their catalog in prose. Never ask where I should run the work before collecting the first-work selection. +- Source control must be connected and repositories synchronized before setup completes or starter tasks are offered. Inference and sandbox readiness remain prerequisites for completion, but none of these prerequisites delay optional integration discovery. After discovery is completed, when source control is not connected, explain that I need access to the user's source code, then stop after the user-visible response; source-control controls are state-driven. When all completion requirements are ready, optional integration discovery is completed, and the setup snapshot has no starter selection, the server emits a starter-request setup event. Starter work is optional and never gates setup completion. On that event, only after discovery is completed, call \`request_user_input\` with exactly \`{ preset: "setup_starter_tasks" }\`. Do not send a closeout first: that tool call creates the user-visible first-work control and is the terminal response for the turn. Do not replace the tool call with prose asking the user to choose. The server supplies the choices; never invent or repeat their catalog in prose. Never ask where I should run the work before collecting the first-work selection. +- Integration discovery is optional and never gates setup completion. Use the snapshot's \`integrationDiscovery\`: \`completed\`, \`answeredCategoryIds\`, \`matchedIntegrationIds\`, \`categories\`, and \`unsupportedTools\`. Existing starter selection or completed old setup means no restart of optional discovery, including when an older snapshot has no discovery state. +- When \`integrationDiscovery.completed\` is false, begin or resume discovery now, even if source control or compute is not ready. Naturally ask about documents, monitoring, and project-tracking tools in the server snapshot's \`integrationDiscovery.categories\` order. Use normal \`request_user_input\` for one category at a time, with stable question IDs \`setup-tools-\` using the category ID. Offer skipping early. Avoid a repetitive questionnaire: never re-ask categories in \`answeredCategoryIds\` or already supplied in prose, and do not force all three topics when the user wants to move on. Never revive a legacy communication discovery question. +- Finish discovery with the trusted \`setup_integrations\` preset. Carry tools already supplied in prose through optional \`setupIntegrationAnswers: Record\`, keyed by category IDs (not question IDs). These are untrusted user preferences: the server exact-matches its catalog and supplies canonical connector IDs and options. Never invent connector IDs, tool hint fields, or configuration instructions from user answers. Unsupported tools are not promised as connectable. On skip, including a cancelled discovery question or snapshot \`integrationDiscovery.skipped\`, go straight to \`{ preset: "setup_integrations" }\`; no need to fill missing answers or ask further categories. The final trusted card's Continue without connections choice is durable discovery completion, not a requirement to connect anything. Never ask for credentials in chat. +- All asynchronous setup events must preserve active discovery without interrupting or restarting it. Never emit the starter preset until discovery is completed; existing starter selection or completed old setup remains exempt from restarting discovery. Readiness, provider, source, compute, recommendation, and stale starter-request events are not permission to replace a pending discovery question or final integration choice. Reconcile their facts without re-asking answered topics. - Starter selection records the administrator's durable intent before this model turn resumes. Launch is deferred until the setup snapshot says the sandbox provider is ready. While it is not ready, do not call \`launch_task\`; explain that I need a workspace where I can run the selected work, then let the renderer supply the interaction. Once a trusted starter-selection event is emitted after sandbox readiness, call generic \`launch_task\` exactly once for each selected task, use its catalog prompt exactly, set \`environmentId\` to null, and omit \`model\` unless the administrator explicitly requested one. Do not launch other tasks in that turn. After attempting all selected launches, send one concise closeout. When at least one task started, explain that the work will continue and the administrator is free to start something new or explore the app while I work; do not imply that they need to wait in or remain on the setup session. - Partial launch failure never reverses setup completion. Name failed launches and continue with successful work. Mention automation recommendations only after the snapshot says at least one selected task launched successfully and the recommendation batch is ready. - In the setup session, always refer to Roomote in the first person: use "I", "me", and "my" in user-visible messages. Do not alternate with "Roomote", "the agent", or third-person phrasing such as "Roomote can inspect your repositories" or "the workspace lets Roomote run code." Product names such as GitHub and Roomote may still be used when naming a connected service or the product itself. @@ -322,7 +326,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 +431,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." : '' } ${ @@ -474,7 +478,7 @@ ${ ${ platformEventKind === 'setup' ? `- For a setup-session-started event, briefly introduce myself and explain the next unmet user need in ordinary language. -- For a starter-request event, call \`request_user_input\` exactly once with only \`{ preset: "setup_starter_tasks" }\`, then stop. Do not replace the tool call with prose asking the user to choose. +- For a starter-request event, call \`request_user_input\` exactly once with only \`{ preset: "setup_starter_tasks" }\` only after integration discovery is completed (or existing starter selection/completed old setup exempts discovery), then stop. Otherwise preserve discovery without interrupting or restarting it. Do not replace the tool call with prose asking the user to choose. - For a starter-tasks-selected event, launch each canonical task definition exactly once with "launch_task": use its prompt verbatim, null for environmentId, and no model unless explicitly requested. The event is emitted only after the sandbox readiness fact is true; if the trusted snapshot disagrees, do not launch and report the configuration blocker. After all launch attempts, post one concise closeout. If any selected task started, say that the started work will continue while the user starts something new or explores the app. The persisted selection is authoritative and setup is already complete; launch failures do not reverse it. - For provider, source, compute, or recommendation events, use the supplied trusted facts and snapshot without claiming that I made configuration changes myself. ` diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 6433a635cf..f90e70e291 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,12 @@ export async function answerFastAgentQuestion({ const questions = 'questions' in args ? args.questions - : await adapter.resolveUserInputPreset!( - args.preset as FastAgentInputPreset, - ); + : args.setupIntegrationAnswers !== undefined + ? await adapter.resolveUserInputPreset!( + args.preset, + args.setupIntegrationAnswers, + ) + : await adapter.resolveUserInputPreset!(args.preset); for (const question of questions) { if (question.options && question.isSecret) { return { diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts index f61942bc36..de57f6ac35 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 @@ -132,6 +132,36 @@ describe('setup prompt guidance and snapshot injection', () => { expect(prompt).not.toContain('update_plan'); }); + it('keeps discovery optional, ordered, resumable, and server-resolved', () => { + const prompt = buildFastAgentSystemPrompt({ + ...baseInput, + setupSession: true, + }); + for (const rule of [ + 'Integration discovery is optional and never gates setup completion', + 'documents, monitoring, and project-tracking', + 'those existing provider flows are unaffected', + 'Do not ask provider-configuration questions in this optional discovery', + 'integrationDiscovery.categories', + 'setup-tools-', + 'Offer skipping early', + 'already supplied in prose', + 'setupIntegrationAnswers', + 'keyed by category IDs (not question IDs)', + 'server exact-matches its catalog', + 'Continue without connections', + 'no need to fill missing answers', + 'All asynchronous setup events must preserve active discovery', + 'Never emit the starter preset until discovery is completed', + 'Existing starter selection or completed old setup means no restart', + 'answeredCategoryIds', + 'matchedIntegrationIds', + 'unsupportedTools', + ]) + expect(prompt).toContain(rule); + expect(prompt).not.toContain('Naturally ask about communication'); + }); + it('omits setup sections for ordinary sessions', () => { const prompt = buildFastAgentSystemPrompt(baseInput); diff --git a/packages/types/src/acp-request-user-input.test.ts b/packages/types/src/acp-request-user-input.test.ts index 397fd8e3e1..d4d3efb40a 100644 --- a/packages/types/src/acp-request-user-input.test.ts +++ b/packages/types/src/acp-request-user-input.test.ts @@ -102,6 +102,23 @@ describe('request_user_input multi-select payloads', () => { preset: 'setup_starter_tasks', })?.preset, ).toBe('setup_starter_tasks'); + expect( + parseAcpRequestUserInputPayload({ + ...payload, + preset: 'setup_integrations', + questions: [ + { + ...singleQuestion, + options: [ + { id: 'slack', label: 'Slack', description: 'Connect Slack' }, + ], + }, + ], + }), + ).toMatchObject({ + preset: 'setup_integrations', + questions: [{ options: [{ id: 'slack', label: 'Slack' }] }], + }); expect( parseAcpRequestUserInputPayload({ ...payload, preset: 'untrusted' }) ?.preset, diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts index e8b801e414..f6965ba98e 100644 --- a/packages/types/src/acp.ts +++ b/packages/types/src/acp.ts @@ -164,6 +164,8 @@ export const ACP_REQUEST_USER_INPUT_METHOD = export const ACP_REQUEST_USER_INPUT_REQUEST_ID_PREFIX = 'rui' as const; export interface AcpRequestUserInputQuestionOption { + /** Canonical option identity supplied by trusted server presets. */ + id?: string; label: string; description: string; } @@ -233,7 +235,7 @@ export interface AcpRequestUserInputRequestParams { export interface AcpRequestUserInputPayload extends AcpRequestUserInputRequestParams { requestId: string; status: 'pending'; - preset?: 'setup_starter_tasks'; + preset?: 'setup_starter_tasks' | 'setup_integrations'; } export interface AcpRequestUserInputResponsePayload { @@ -307,7 +309,8 @@ function parseAcpRequestUserInputQuestionOption( return null; } - return { label, description }; + const id = asStringOrNull(record?.id); + return { label, description, ...(id ? { id } : {}) }; } export function parseAcpRequestUserInputQuestion( @@ -405,7 +408,10 @@ export function parseAcpRequestUserInputPayload( const requestId = asStringOrNull(payload?.requestId); const request = parseAcpRequestUserInputRequestParams(payload); const preset = - payload?.preset === 'setup_starter_tasks' ? payload.preset : undefined; + payload?.preset === 'setup_starter_tasks' || + payload?.preset === 'setup_integrations' + ? payload.preset + : undefined; if (!requestId || !request) { return null; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 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/onboarding-integrations.test.ts b/packages/types/src/onboarding-integrations.test.ts new file mode 100644 index 0000000000..9d53eb79e0 --- /dev/null +++ b/packages/types/src/onboarding-integrations.test.ts @@ -0,0 +1,186 @@ +import { MCP_INTEGRATIONS } from './mcp-oauth'; +import { communicationProviders } from './communication'; +import { sourceControlProviders } from './source-control'; +import { computeProviders } from './compute-providers/compute-provider'; +import { SETUP_MODEL_PROVIDER_IDS } from './model-provider-config'; +import { + ADMIN_INTEGRATION_ORDER, + COMMUNICATION_PROVIDER_ORDER, + SOURCE_CONTROL_PROVIDER_ORDER, + SETUP_INTEGRATION_CATEGORIES, + SETUP_INTEGRATIONS, + SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS, + getSetupIntegrationCategories, + isSetupIntegrationDiscoveryQuestionId, + matchSetupIntegrationAnswers, +} from './onboarding-integrations'; + +describe('setup integration discovery catalog', () => { + it('excludes the four provider categories, retaining the distinct Vercel connector and homepage priority', () => { + const excluded = new Set([ + ...sourceControlProviders, + ...communicationProviders, + ...computeProviders, + ...SETUP_MODEL_PROVIDER_IDS.filter((id) => id !== 'vercel'), + ]); + expect(SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS).toEqual(excluded); + const ids = SETUP_INTEGRATIONS.map(({ id }) => id); + expect(ids).toEqual( + [ + ...new Set([ + ...ADMIN_INTEGRATION_ORDER, + ...MCP_INTEGRATIONS.map(({ id }) => id), + ]), + ].filter( + (id) => + !excluded.has(id) && + MCP_INTEGRATIONS.some((integration) => integration.id === id), + ), + ); + expect(new Set(ids).size).toBe(ids.length); + for (const id of excluded) expect(ids).not.toContain(id); + expect(ids).toContain('vercel'); + expect(SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS.has('microsoft')).toBe( + false, + ); + expect( + MCP_INTEGRATIONS.filter(({ id }) => + (SETUP_MODEL_PROVIDER_IDS as readonly string[]).includes(id), + ).map(({ id }) => id), + ).toEqual(['vercel']); + expect(ids).toEqual( + expect.arrayContaining(['railway', 'supabase', 'granola']), + ); + for (const integration of SETUP_INTEGRATIONS) { + expect(integration.kind).toBe('mcp'); + expect(integration.name).toBe( + MCP_INTEGRATIONS.find(({ id }) => id === integration.id)?.name, + ); + } + expect(SETUP_INTEGRATION_CATEGORIES.map(({ id }) => id)).toEqual([ + 'documents', + 'monitoring', + 'project-tracking', + ]); + for (const category of SETUP_INTEGRATION_CATEGORIES) { + expect(category.integrationIds.length).toBeGreaterThan(0); + for (const id of category.integrationIds) expect(ids).toContain(id); + } + }); + + it('preserves the separate homepage provider controls and ordering', () => { + expect(COMMUNICATION_PROVIDER_ORDER).toEqual([ + 'slack', + 'microsoft', + 'telegram', + 'discord', + ]); + expect(SOURCE_CONTROL_PROVIDER_ORDER).toEqual([ + 'github', + 'gitlab', + 'gitea', + 'bitbucket', + 'ado', + ]); + expect(ADMIN_INTEGRATION_ORDER).toContain('vercel'); + }); + + it('derives category order from eligible first appearances, not provider entries', () => { + const categories = getSetupIntegrationCategories([ + 'slack', + 'vercel', + 'asana', + 'grafana', + 'linear', + 'microsoft', + 'notion', + ...ADMIN_INTEGRATION_ORDER, + ]); + expect(categories.map(({ id }) => id)).toEqual([ + 'project-tracking', + 'monitoring', + 'documents', + ]); + expect( + categories.find(({ id }) => id === 'project-tracking')?.integrationIds, + ).toEqual(['asana', 'linear', 'jira', 'monday']); + expect( + categories.flatMap(({ integrationIds }) => integrationIds), + ).not.toContain('vercel'); + }); + + it('matches all eligible catalog names and IDs globally', () => { + for (const category of SETUP_INTEGRATION_CATEGORIES) { + expect( + matchSetupIntegrationAnswers({ + [category.id]: { + answers: SETUP_INTEGRATIONS.flatMap(({ id, name }) => [id, name]), + }, + }), + ).toEqual({ + answeredCategoryIds: [category.id], + matchedIntegrationIds: SETUP_INTEGRATIONS.map(({ id }) => id), + unsupportedTools: [], + }); + } + }); + + it('never restores providers from legacy answers or model-extracted hints', () => { + expect( + matchSetupIntegrationAnswers({ + 'setup-tools-communication': { + answers: ['slack', 'Discord', 'Notion'], + }, + communication: { answers: ['Microsoft Teams'] }, + documents: { + answers: [ + 'Vercel', + 'slack', + 'Microsoft Teams', + 'github', + 'Granola', + 'Google Docs', + ], + }, + }), + ).toEqual({ + answeredCategoryIds: ['documents'], + matchedIntegrationIds: ['vercel', 'granola'], + unsupportedTools: ['Google Docs'], + }); + expect( + isSetupIntegrationDiscoveryQuestionId('setup-tools-communication'), + ).toBe(true); + expect(isSetupIntegrationDiscoveryQuestionId('setup-tools-documents')).toBe( + true, + ); + expect(isSetupIntegrationDiscoveryQuestionId('unrelated')).toBe(false); + }); + + it('matches whole names only and deduplicates in homepage order', () => { + expect( + matchSetupIntegrationAnswers({ + documents: { answers: ['Notion Calendar', 'notion', 'notion'] }, + monitoring: { answers: ['Grafana, Sentry; PostHog\nDatadog'] }, + 'project-tracking': { answers: ['asana', 'linear', 'none', 'skip'] }, + unrelated: { answers: ['jira'] }, + }), + ).toEqual({ + answeredCategoryIds: ['documents', 'monitoring', 'project-tracking'], + matchedIntegrationIds: [ + 'notion', + 'sentry', + 'linear', + 'posthog', + 'grafana', + 'asana', + ], + unsupportedTools: ['Notion Calendar', 'Datadog'], + }); + expect( + matchSetupIntegrationAnswers({ + documents: { answers: ['We do not use Notion'] }, + }).matchedIntegrationIds, + ).toEqual([]); + }); +}); diff --git a/packages/types/src/onboarding-integrations.ts b/packages/types/src/onboarding-integrations.ts new file mode 100644 index 0000000000..13bb8a5353 --- /dev/null +++ b/packages/types/src/onboarding-integrations.ts @@ -0,0 +1,203 @@ +import { + communicationProviders, + communicationProviderDisplayNames, +} from './communication'; +import { MCP_INTEGRATIONS } from './mcp-oauth'; +import { sourceControlProviders } from './source-control'; +import { computeProviders } from './compute-providers/compute-provider'; +import { SETUP_MODEL_PROVIDER_IDS } from './model-provider-config'; +import type { AcpRequestUserInputAnswers } from './acp'; + +export const ADMIN_INTEGRATION_ORDER = [ + 'notion', + 'sentry', + 'linear', + 'jira', + 'monday', + 'vercel', + 'supabase', + 'posthog', + 'grafana', + 'asana', +] as const; + +// The homepage account-linking provider is named microsoft, while chat uses teams. +export const COMMUNICATION_PROVIDER_ORDER = [ + 'slack', + 'microsoft', + 'telegram', + 'discord', +] as const; +export const SOURCE_CONTROL_PROVIDER_ORDER = [ + 'github', + 'gitlab', + 'gitea', + 'bitbucket', + 'ado', +] as const; + +export const SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS: ReadonlySet = + new Set([ + ...sourceControlProviders, + ...communicationProviders, + ...computeProviders, + // Vercel's deployments connector is distinct from Vercel AI Gateway inference. + ...SETUP_MODEL_PROVIDER_IDS.filter((id) => id !== 'vercel'), + ]); + +export type SetupIntegrationId = (typeof MCP_INTEGRATIONS)[number]['id']; + +export const SETUP_INTEGRATIONS = [ + ...new Set([ + ...ADMIN_INTEGRATION_ORDER, + ...MCP_INTEGRATIONS.map((integration) => integration.id), + ]), +] + .filter((id) => !SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS.has(id)) + .flatMap<{ + id: SetupIntegrationId; + name: string; + kind: 'mcp'; + }>((id) => { + const integration = MCP_INTEGRATIONS.find( + (candidate) => candidate.id === id, + ); + return integration + ? [{ id, name: integration.name, kind: 'mcp' as const }] + : []; + }); + +const setupIntegrationCategories = [ + { + id: 'documents', + label: 'Documents', + question: 'Where do you keep team documents and knowledge?', + integrationIds: ['notion', 'granola', 'supermemory'], + }, + { + id: 'monitoring', + label: 'Monitoring', + question: 'What do you use for monitoring and product analytics?', + integrationIds: [ + 'sentry', + 'posthog', + 'grafana', + 'betterstack', + 'braintrust', + ], + }, + { + id: 'project-tracking', + label: 'Project tracking', + question: 'Where do you track projects and issues?', + integrationIds: ['linear', 'jira', 'monday', 'asana'], + }, +] as const; + +export type SetupIntegrationCategoryId = + (typeof setupIntegrationCategories)[number]['id']; + +export function getSetupIntegrationCategories( + homepageOrder: readonly string[] = ADMIN_INTEGRATION_ORDER, +) { + const order = [ + ...new Set([ + ...homepageOrder, + ...SETUP_INTEGRATIONS.map((integration) => integration.id), + ]), + ].filter((id) => + SETUP_INTEGRATIONS.some((integration) => integration.id === id), + ); + return setupIntegrationCategories + .map((category) => ({ + ...category, + integrationIds: order.filter((id) => + (category.integrationIds as readonly string[]).includes(id), + ), + })) + .filter((category) => category.integrationIds.length > 0) + .sort( + (left, right) => + order.indexOf(left.integrationIds[0]!) - + order.indexOf(right.integrationIds[0]!), + ); +} + +export const SETUP_INTEGRATION_CATEGORIES = getSetupIntegrationCategories(); + +export const SETUP_INTEGRATIONS_QUESTION_ID = 'setup-integrations'; +export const SETUP_INTEGRATIONS_CONTINUE_OPTION = { + id: 'continue', + label: 'Continue', + description: + 'Continue with or without connecting tools. You can connect them later in Settings.', +} as const; + +export function getSetupIntegrationQuestionId( + categoryId: SetupIntegrationCategoryId, +): string { + return `setup-tools-${categoryId}`; +} + +/** Older sessions can still have an unanswered communication discovery question. */ +export function isSetupIntegrationDiscoveryQuestionId( + questionId: string, +): boolean { + return ( + questionId === 'setup-tools-communication' || + SETUP_INTEGRATION_CATEGORIES.some( + (category) => getSetupIntegrationQuestionId(category.id) === questionId, + ) + ); +} + +/** Only whole catalog IDs/names match; unsupported tools never become a guessed connector. */ +export function matchSetupIntegrationAnswers( + answers: AcpRequestUserInputAnswers, +) { + const matched = new Set(); + const unsupported = new Set(); + const answeredCategoryIds: SetupIntegrationCategoryId[] = []; + for (const category of SETUP_INTEGRATION_CATEGORIES) { + const response = + answers[getSetupIntegrationQuestionId(category.id)] ?? + answers[category.id]; + if (!response) continue; + answeredCategoryIds.push(category.id); + for (const value of response.answers.flatMap((answer) => + answer.split(/[,;\n]/), + )) { + const token = value.trim().toLowerCase(); + if ( + !token || + ['none', 'skip', 'skip for now', 'not sure'].includes(token) + ) + continue; + const integration = SETUP_INTEGRATIONS.find( + (candidate) => + candidate.id.toLowerCase() === token || + candidate.name.toLowerCase() === token, + ); + if (integration) matched.add(integration.id); + else if ( + !SETUP_INTEGRATION_EXCLUDED_PROVIDER_IDS.has(token) && + !Object.values(communicationProviderDisplayNames).some( + (name) => name.toLowerCase() === token, + ) && + !MCP_INTEGRATIONS.some( + (candidate) => + candidate.id.toLowerCase() === token || + candidate.name.toLowerCase() === token, + ) + ) + unsupported.add(value.trim()); + } + } + return { + answeredCategoryIds, + matchedIntegrationIds: SETUP_INTEGRATIONS.filter((integration) => + matched.has(integration.id), + ).map((integration) => integration.id), + unsupportedTools: [...unsupported], + }; +} diff --git a/packages/types/src/setup-new.test.ts b/packages/types/src/setup-new.test.ts index 9048de2688..8d6e756fd5 100644 --- a/packages/types/src/setup-new.test.ts +++ b/packages/types/src/setup-new.test.ts @@ -25,6 +25,23 @@ import { } from './setup-new'; describe('setup-session metadata', () => { + it('adds pending discovery only to new sessions and preserves continuation on resume', () => { + const session = createSetupNewSetupSession({ sessionId: 'session' }); + expect( + normalizeSetupNewSetupSession(session)?.integrationDiscoveryCompletedAt, + ).toBeNull(); + const completedAt = '2026-09-09T12:00:00.000Z'; + expect( + normalizeSetupNewSetupSession({ + ...session, + integrationDiscoveryCompletedAt: completedAt, + })?.integrationDiscoveryCompletedAt, + ).toBe(completedAt); + const { integrationDiscoveryCompletedAt: _, ...legacy } = session; + expect(normalizeSetupNewSetupSession(legacy)).not.toHaveProperty( + 'integrationDiscoveryCompletedAt', + ); + }); it('normalizes state without setup-session metadata to null', () => { const state = normalizeSetupNewState({}); diff --git a/packages/types/src/setup-new.ts b/packages/types/src/setup-new.ts index 647c53f4f0..1f2dcbd063 100644 --- a/packages/types/src/setup-new.ts +++ b/packages/types/src/setup-new.ts @@ -158,6 +158,8 @@ export function isSetupStarterTaskId( */ export type SetupNewSetupSession = { workflowVersion: number; + /** Missing on pre-discovery sessions; null means the optional conversation is pending. */ + integrationDiscoveryCompletedAt?: string | null; /** Unified (canonical) session ID shown in routes and transcript. */ sessionId: string; startedAt: string; @@ -177,6 +179,7 @@ export function createSetupNewSetupSession(input: { sessionId: input.sessionId, startedAt: input.startedAt ?? new Date().toISOString(), starterTaskSelection: null, + integrationDiscoveryCompletedAt: null, }; } @@ -228,6 +231,15 @@ export function normalizeSetupNewSetupSession( sessionId, startedAt, starterTaskSelection, + ...(record.integrationDiscoveryCompletedAt === null + ? { integrationDiscoveryCompletedAt: null } + : asIsoTimestamp(record.integrationDiscoveryCompletedAt) + ? { + integrationDiscoveryCompletedAt: asIsoTimestamp( + record.integrationDiscoveryCompletedAt, + ), + } + : {}), }; } From ad0c9e27bcbaf0e39461d1aa190c07d54a462902 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 9 Sep 2026 15:43:57 +0000 Subject: [PATCH 28/30] improve: suggest only named tools during setup --- apps/docs/self-hosting.mdx | 7 +- .../SetupIntegrationsCard.client.test.tsx | 240 +++++++++++++----- .../setup/SetupIntegrationsCard.tsx | 162 ++++++------ .../server/fast-agent/fast-agent-prompt.ts | 2 +- .../fast-agent/fast-agent-setup-tools.test.ts | 4 +- 5 files changed, 259 insertions(+), 156 deletions(-) diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx index 71c27e9cdd..1d93032ae6 100644 --- a/apps/docs/self-hosting.mdx +++ b/apps/docs/self-hosting.mdx @@ -204,10 +204,11 @@ chat. The conversation also asks briefly about the tools your team uses for documents, monitoring, and project tracking, one topic at a time. -You can skip these questions. The optional integrations card highlights matching -available connectors and opens their secure configuration without leaving setup. +You can skip these questions. The optional integrations card lists only supported +tools you said you use and opens their secure configuration without leaving setup. +If there are no eligible matches, setup moves on without showing suggestions. Tools without a built-in connector are not presented as supported. Use -**Continue without connections** to move on; you can connect tools later in +**Keep going** to move on without connecting; you can connect tools later in Settings. Integration choices do not change the starter tasks offered. Services that are also source-control, communications, inference, or sandbox providers are excluded from this optional step; their separate setup is unchanged. diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx index 3c1bba9220..e3e05451bc 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.client.test.tsx @@ -1,4 +1,5 @@ import { + act, fireEvent, render, screen, @@ -6,6 +7,7 @@ import { within, } from '@testing-library/react'; import { + SETUP_INTEGRATIONS, SETUP_INTEGRATIONS_CONTINUE_OPTION, type AcpRequestUserInputPayload, } from '@roomote/types'; @@ -19,6 +21,7 @@ const mocks = vi.hoisted(() => ({ pending: false, submitError: false, submitPending: false, + onSuccess: () => {}, enabled: true, connections: [] as { mcpId: string; authStatus: string }[], enablements: [] as { mcpId: string; enabled: boolean }[], @@ -37,7 +40,14 @@ vi.mock('@/hooks/useTelemetry', () => ({ vi.mock('@/trpc/client', () => ({ useTRPC: () => ({ onboarding: { status: { queryOptions: () => ({}) } }, - setup: { submitSessionUserInput: { mutationOptions: () => ({}) } }, + setup: { + submitSessionUserInput: { + mutationOptions: (options: { onSuccess: () => void }) => { + mocks.onSuccess = options.onSuccess; + return options; + }, + }, + }, }), })); vi.mock('@tanstack/react-query', () => ({ @@ -118,7 +128,7 @@ beforeEach(() => { }); }); -it('highlights catalog matches without claiming support for unknown options, in homepage order', () => { +it('shows only eligible option IDs in catalog order without badges or unmentioned defaults', () => { render(); const rows = within( screen.getByRole('list', { name: 'Available integrations' }), @@ -127,13 +137,12 @@ it('highlights catalog matches without claiming support for unknown options, in rows.map((row) => within(row).getByRole('button').getAttribute('aria-label'), ), - ).toEqual([ - 'Configure Notion', - 'Configure Sentry', - 'Configure Linear', - 'Configure Jira', - ]); - expect(screen.getAllByText('Your tools')).toHaveLength(2); + ).toEqual(['Connect Notion', 'Connect Jira']); + expect(screen.queryByText('Your tools')).not.toBeInTheDocument(); + expect(screen.queryByText('Available')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Refresh|See all/ }), + ).not.toBeInTheDocument(); expect(screen.queryByText('Google Docs')).not.toBeInTheDocument(); expect(mocks.capture).toHaveBeenCalledWith('setup_integrations_shown', { matchedCount: 2, @@ -142,9 +151,7 @@ it('highlights catalog matches without claiming support for unknown options, in it('continues with zero connections using the durable setup input contract', () => { render(); - fireEvent.click( - screen.getByRole('button', { name: 'Continue without connections' }), - ); + fireEvent.click(screen.getByRole('button', { name: 'Keep going' })); expect(mocks.mutate).toHaveBeenCalledWith({ sessionId: 's', requestId: 'integration-request', @@ -156,12 +163,14 @@ it('shows live connected, disabled and attention states instead of inferring a c mocks.connections = [{ mcpId: 'notion', authStatus: 'authenticated' }]; mocks.enablements = [ { mcpId: 'notion', enabled: true }, - { mcpId: 'sentry', enabled: true }, + { mcpId: 'jira', enabled: true }, ]; render(); expect(screen.getByText('Connected')).toBeInTheDocument(); expect(screen.getByText('Needs connection')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Continue setup' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Manage Notion' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Connect Jira' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled(); }); it('keeps continuation available during loading, status failure and operator disablement', () => { @@ -169,34 +178,46 @@ it('keeps continuation available during loading, status failure and operator dis mocks.error = true; mocks.enabled = false; render(); + expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeDisabled(); expect( - screen.getByRole('button', { name: 'Continue without connections' }), - ).toBeEnabled(); + screen.getByText(/couldn't load connection status/), + ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Configure Notion' }), - ).toBeDisabled(); - fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); - expect(mocks.refetch).toHaveBeenCalledTimes(4); + screen.queryByRole('button', { name: /Refresh/ }), + ).not.toBeInTheDocument(); + expect(mocks.refetch).not.toHaveBeenCalled(); }); -it('opens existing secure configuration inline and refreshes after cancellation', async () => { - render(); - fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' })); - await waitFor(() => - expect( - screen.getByText('Secure configuration: notion'), - ).toBeInTheDocument(), - ); - expect(mocks.capture).toHaveBeenCalledWith( - 'setup_integration_configuration_opened', - { integration_id: 'notion' }, - ); - fireEvent.click(screen.getByRole('button', { name: 'Back to setup' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(mocks.refetch).toHaveBeenCalledTimes(4); -}); +it.each(['Back to setup', 'Escape'])( + 'opens secure configuration inline and automatically refreshes on %s', + async (closeAction) => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Connect Notion' })); + await waitFor(() => + expect( + screen.getByText('Secure configuration: notion'), + ).toBeInTheDocument(), + ); + expect(mocks.capture).toHaveBeenCalledWith( + 'setup_integration_configuration_opened', + { integration_id: 'notion' }, + ); + expect(mocks.refetch).not.toHaveBeenCalled(); + if (closeAction === 'Escape') { + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + } else { + fireEvent.click(screen.getByRole('button', { name: 'Back to setup' })); + } + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + expect(mocks.refetch).toHaveBeenCalledTimes(4); + expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled(); + }, +); -it('never reintroduces provider overlaps from an old pending request', () => { +it('filters provider overlaps from an old request while allowing matched Vercel', () => { const oldRequest = { ...request, questions: request.questions.map((question) => ({ @@ -204,35 +225,42 @@ it('never reintroduces provider overlaps from an old pending request', () => { options: [ ...(question.options ?? []), { id: 'slack', label: 'Slack', description: '' }, + { id: 'github', label: 'GitHub', description: '' }, + { id: 'gitlab', label: 'GitLab', description: '' }, + { id: 'teams', label: 'Teams', description: '' }, + { id: 'discord', label: 'Discord', description: '' }, + { id: 'telegram', label: 'Telegram', description: '' }, { id: 'vercel', label: 'Vercel', description: '' }, ], })), }; render(); - fireEvent.click(screen.getByRole('button', { name: /See all/ })); - expect( - screen.queryByRole('button', { name: 'Configure Slack' }), - ).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Configure Vercel' }), - ).toBeInTheDocument(); + for (const name of [ + 'Slack', + 'GitHub', + 'GitLab', + 'Teams', + 'Discord', + 'Telegram', + ]) { + expect( + screen.queryByRole('button', { name: `Connect ${name}` }), + ).not.toBeInTheDocument(); + } expect( - screen.getByRole('button', { name: 'Configure Supabase' }), + screen.getByRole('button', { name: 'Connect Vercel' }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Continue without connections' }), - ).toBeEnabled(); + screen.queryByRole('button', { name: 'Connect Supabase' }), + ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled(); }); it('requires an administrator for configuration, not for displaying the optional continue action', () => { mocks.isAdmin = false; render(); - expect( - screen.getByRole('button', { name: 'Configure Notion' }), - ).toBeDisabled(); - expect( - screen.getByRole('button', { name: 'Continue without connections' }), - ).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled(); }); it('allows retry after a failed continue and does not disclose callback reason values', () => { @@ -242,19 +270,109 @@ it('allows retry after a failed continue and does not disclose callback reason v expect(screen.getByText(/Authorization didn't finish/)).toBeInTheDocument(); expect(screen.queryByText(/private-value/)).not.toBeInTheDocument(); expect(screen.getByText(/Couldn't continue setup/)).toBeInTheDocument(); - fireEvent.click( - screen.getByRole('button', { name: 'Continue without connections' }), + fireEvent.click(screen.getByRole('button', { name: 'Keep going' })); + expect(mocks.mutate).toHaveBeenCalledTimes(1); +}); + +it('shows every match immediately even when many connectors match', () => { + const manyRequest = { + ...request, + questions: request.questions.map((question) => ({ + ...question, + options: SETUP_INTEGRATIONS.map((integration) => ({ + id: integration.id, + label: integration.name, + description: '', + })), + })), + }; + render(); + expect(screen.getAllByRole('listitem')).toHaveLength( + SETUP_INTEGRATIONS.length, + ); + for (const integration of SETUP_INTEGRATIONS) { + expect( + screen.getByRole('button', { name: `Connect ${integration.name}` }), + ).toBeVisible(); + } + expect( + screen.queryByRole('button', { name: /See all|Show less/ }), + ).not.toBeInTheDocument(); +}); + +it.each([ + ['no mentions', []], + [ + 'unsupported mentions only', + [{ id: 'google-docs', label: 'Google Docs', description: '' }], + ], +] as const)( + 'auto-skips %s once per request ID without rendering a card', + (_name, options) => { + const skipped = { + ...request, + questions: request.questions.map((question) => ({ + ...question, + options: [...options, SETUP_INTEGRATIONS_CONTINUE_OPTION], + })), + }; + const { container, rerender } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + expect(mocks.mutate).toHaveBeenCalledExactlyOnceWith({ + sessionId: 's', + requestId: request.requestId, + answers: { 'setup-integrations': { answers: ['Continue'] } }, + }); + expect(mocks.capture).not.toHaveBeenCalled(); + rerender(); + expect(mocks.mutate).toHaveBeenCalledTimes(1); + rerender( + , + ); + expect(mocks.mutate).toHaveBeenCalledTimes(2); + }, +); + +it('shows retry only after auto-continue fails and hides it after success', () => { + const skipped = { ...request, questions: [] }; + const { container, rerender } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + mocks.submitError = true; + rerender(); + expect(screen.getByRole('alert')).toHaveTextContent( + /Couldn't continue setup/, ); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); expect(mocks.mutate).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Keep going' })); + expect(mocks.mutate).toHaveBeenCalledTimes(2); + act(() => mocks.onSuccess()); + expect(container).toBeEmptyDOMElement(); + expect(mocks.capture).toHaveBeenCalledWith('setup_integrations_continued'); }); -it('makes the rest of the supported catalog available on demand', () => { +it('shows unavailable status without offering manual refresh after a status error', () => { + mocks.error = true; render(); + expect(screen.getAllByText('Status unavailable')).toHaveLength(2); + expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled(); expect( - screen.queryByRole('button', { name: 'Configure Granola' }), + screen.queryByRole('button', { name: /Refresh/ }), ).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /See all/ })); - expect( - screen.getByRole('button', { name: 'Configure Granola' }), - ).toBeInTheDocument(); +}); + +it('does not treat an authenticated but disabled connector as connected', () => { + mocks.connections = [{ mcpId: 'notion', authStatus: 'authenticated' }]; + render(); + expect(screen.getByText('Not enabled')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeEnabled(); + expect(screen.queryByText('Connected')).not.toBeInTheDocument(); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx index b9f01d492b..95761fb3ed 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx @@ -8,7 +8,6 @@ import { toast } from 'sonner'; import { MCP_INTEGRATIONS, SETUP_INTEGRATIONS, - SETUP_INTEGRATION_CATEGORIES, SETUP_INTEGRATIONS_CONTINUE_OPTION, SETUP_INTEGRATIONS_QUESTION_ID, isDeploymentScopedMcpIntegration, @@ -16,7 +15,7 @@ import { } from '@roomote/types'; import { - Badge, + ArrowRight, Button, Dialog, DialogContent, @@ -25,7 +24,6 @@ import { DialogHeader, DialogTitle, Plug, - RefreshCw, Skeleton, } from '@/components/system'; import { McpIcon } from '@/components/settings/McpIcon'; @@ -66,22 +64,28 @@ export function SetupIntegrationsCard({ const connections = useUserMcpConnections(); const availability = useCuratedIntegrationsAvailability(); const connectMcp = useConnectMcp(); - const [showAll, setShowAll] = useState(false); const [activeId, setActiveId] = useState(null); const [continued, setContinued] = useState(false); const shownRequest = useRef(null); + const skippedRequest = useRef(null); const matchedIds = new Set( request.questions .find((question) => question.id === SETUP_INTEGRATIONS_QUESTION_ID) ?.options?.map((option) => option.id) ?? [], ); - const matchedCount = SETUP_INTEGRATIONS.filter((integration) => + const visibleIntegrations = SETUP_INTEGRATIONS.filter((integration) => matchedIds.has(integration.id), - ).length; + ); + const matchedCount = visibleIntegrations.length; useEffect(() => { - if (!enabled || shownRequest.current === request.requestId) return; + if ( + !enabled || + matchedCount === 0 || + shownRequest.current === request.requestId + ) + return; shownRequest.current = request.requestId; capture('setup_integrations_shown', { matchedCount }); }, [capture, enabled, matchedCount, request.requestId]); @@ -95,6 +99,21 @@ export function SetupIntegrationsCard({ onError: (error) => toast.error(error.message), }), ); + const { mutate } = submit; + useEffect(() => { + if (matchedCount !== 0 || skippedRequest.current === request.requestId) + return; + skippedRequest.current = request.requestId; + mutate({ + sessionId, + requestId: request.requestId, + answers: { + [SETUP_INTEGRATIONS_QUESTION_ID]: { + answers: [SETUP_INTEGRATIONS_CONTINUE_OPTION.label], + }, + }, + }); + }, [matchedCount, mutate, request.requestId, sessionId]); const refresh = () => { void onboarding.refetch(); void enablements.refetch(); @@ -127,45 +146,56 @@ export function SetupIntegrationsCard({ const getStatus = (integration: (typeof SETUP_INTEGRATIONS)[number]) => { if (statusPending || statusError) return null; if (integration.id === 'linear') - return onboarding.data?.orgHasLinear ? 'Connected' : 'Available'; + return onboarding.data?.orgHasLinear ? 'Connected' : null; if (authenticatedIds.has(integration.id)) return enabledIds.has(integration.id) ? 'Connected' : 'Not enabled'; - return enabledIds.has(integration.id) ? 'Needs connection' : 'Available'; + return enabledIds.has(integration.id) ? 'Needs connection' : null; }; - const hasConnections = SETUP_INTEGRATIONS.some( - (integration) => getStatus(integration) === 'Connected', - ); - const previewIds = new Set( - SETUP_INTEGRATION_CATEGORIES.map((category) => category.integrationIds[0]), - ); - const visibleIntegrations = SETUP_INTEGRATIONS.filter( - (integration) => - showAll || - matchedIds.has(integration.id) || - previewIds.has(integration.id), - ); const authFailed = searchParams.get('mcp') === 'error' || searchParams.get('error') !== null; - if (continued) - return ( -

- You can connect more tools any time in Settings. -

- ); + if (continued) return null; + + const keepGoing = ( + + ); + const continuationError = submit.isError ? ( +

+ Couldn't continue setup. Please try again. +

+ ) : null; + if (matchedCount === 0) { + return submit.isError ? ( +
+ {continuationError} + {keepGoing} +
+ ) : null; + } return ( } - intro="Connect the tools you use so I can work with your team's context, not just your code. This is optional." + intro="Connect the tools you use so I can work with your team's context." > - {matchedCount > 0 ? ( -

- I've highlighted the available connectors that match your - answers. -

- ) : null} {authFailed ? (

Authorization didn't finish. You can try connecting again or @@ -174,8 +204,8 @@ export function SetupIntegrationsCard({ ) : null} {statusError ? (

- I couldn't refresh connection status. Try Refresh status, or - continue setup. + I couldn't load connection status. You can still connect a tool + or keep going.

) : null} {availability.data?.enabled === false ? ( @@ -211,22 +241,18 @@ export function SetupIntegrationsCard({ {integration.name} {statusPending ? ( - ) : ( + ) : unavailable || status || statusError ? (

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

- )} + ) : null}
- {matchedIds.has(integration.id) ? ( - Your tools - ) : null} ); })} -
- - -
-

- Don't see your tool? There may not be a built-in connector for it - yet. No credentials belong in this conversation. -

- - {submit.isError ? ( -

- Couldn't continue setup. Please try again. -

- ) : null} + {keepGoing} + {continuationError} { diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index 9fc2ae499c..949528fee7 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 @@ -278,7 +278,7 @@ You are guiding this deployment's first administrator from runtime readiness to - Source control must be connected and repositories synchronized before setup completes or starter tasks are offered. Inference and sandbox readiness remain prerequisites for completion, but none of these prerequisites delay optional integration discovery. After discovery is completed, when source control is not connected, explain that I need access to the user's source code, then stop after the user-visible response; source-control controls are state-driven. When all completion requirements are ready, optional integration discovery is completed, and the setup snapshot has no starter selection, the server emits a starter-request setup event. Starter work is optional and never gates setup completion. On that event, only after discovery is completed, call \`request_user_input\` with exactly \`{ preset: "setup_starter_tasks" }\`. Do not send a closeout first: that tool call creates the user-visible first-work control and is the terminal response for the turn. Do not replace the tool call with prose asking the user to choose. The server supplies the choices; never invent or repeat their catalog in prose. Never ask where I should run the work before collecting the first-work selection. - Integration discovery is optional and never gates setup completion. Use the snapshot's \`integrationDiscovery\`: \`completed\`, \`answeredCategoryIds\`, \`matchedIntegrationIds\`, \`categories\`, and \`unsupportedTools\`. Existing starter selection or completed old setup means no restart of optional discovery, including when an older snapshot has no discovery state. - When \`integrationDiscovery.completed\` is false, begin or resume discovery now, even if source control or compute is not ready. Naturally ask about documents, monitoring, and project-tracking tools in the server snapshot's \`integrationDiscovery.categories\` order. Use normal \`request_user_input\` for one category at a time, with stable question IDs \`setup-tools-\` using the category ID. Offer skipping early. Avoid a repetitive questionnaire: never re-ask categories in \`answeredCategoryIds\` or already supplied in prose, and do not force all three topics when the user wants to move on. Never revive a legacy communication discovery question. -- Finish discovery with the trusted \`setup_integrations\` preset. Carry tools already supplied in prose through optional \`setupIntegrationAnswers: Record\`, keyed by category IDs (not question IDs). These are untrusted user preferences: the server exact-matches its catalog and supplies canonical connector IDs and options. Never invent connector IDs, tool hint fields, or configuration instructions from user answers. Unsupported tools are not promised as connectable. On skip, including a cancelled discovery question or snapshot \`integrationDiscovery.skipped\`, go straight to \`{ preset: "setup_integrations" }\`; no need to fill missing answers or ask further categories. The final trusted card's Continue without connections choice is durable discovery completion, not a requirement to connect anything. Never ask for credentials in chat. +- Finish discovery with the trusted \`setup_integrations\` preset. Carry tools already supplied in prose through optional \`setupIntegrationAnswers: Record\`, keyed by category IDs (not question IDs). These are untrusted user preferences: the server exact-matches its catalog and supplies canonical connector IDs and options. Suggest only eligible supported tools the user actually said they use; never suggest unmentioned alternatives. Never invent connector IDs, tool hint fields, or configuration instructions from user answers. Unsupported tools are not promised as connectable. On skip, including a cancelled discovery question or snapshot \`integrationDiscovery.skipped\`, go straight to \`{ preset: "setup_integrations" }\`; no need to fill missing answers or ask further categories. With no eligible supported matches, the renderer skips suggestions and automatically records continuation without showing an empty card. Otherwise Keep going records durable discovery completion without requiring any connection. Never ask for credentials in chat. - All asynchronous setup events must preserve active discovery without interrupting or restarting it. Never emit the starter preset until discovery is completed; existing starter selection or completed old setup remains exempt from restarting discovery. Readiness, provider, source, compute, recommendation, and stale starter-request events are not permission to replace a pending discovery question or final integration choice. Reconcile their facts without re-asking answered topics. - Starter selection records the administrator's durable intent before this model turn resumes. Launch is deferred until the setup snapshot says the sandbox provider is ready. While it is not ready, do not call \`launch_task\`; explain that I need a workspace where I can run the selected work, then let the renderer supply the interaction. Once a trusted starter-selection event is emitted after sandbox readiness, call generic \`launch_task\` exactly once for each selected task, use its catalog prompt exactly, set \`environmentId\` to null, and omit \`model\` unless the administrator explicitly requested one. Do not launch other tasks in that turn. After attempting all selected launches, send one concise closeout. When at least one task started, explain that the work will continue and the administrator is free to start something new or explore the app while I work; do not imply that they need to wait in or remain on the setup session. - Partial launch failure never reverses setup completion. Name failed launches and continue with successful work. Mention automation recommendations only after the snapshot says at least one selected task launched successfully and the recommendation batch is ready. diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts index de57f6ac35..cab70a011a 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 @@ -149,7 +149,9 @@ describe('setup prompt guidance and snapshot injection', () => { 'setupIntegrationAnswers', 'keyed by category IDs (not question IDs)', 'server exact-matches its catalog', - 'Continue without connections', + 'Keep going records durable discovery completion', + 'Suggest only eligible supported tools the user actually said they use', + 'without showing an empty card', 'no need to fill missing answers', 'All asynchronous setup events must preserve active discovery', 'Never emit the starter preset until discovery is completed', From 7fd17a743e9de461805b18856b179f0821396ab9 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 15:39:12 +0000 Subject: [PATCH 29/30] feat: make guided interactions reliable --- .../__tests__/callback-actions.test.ts | 4 + .../__tests__/request-user-input.test.ts | 221 +++++++++++++ .../handlers/discord/request-user-input.ts | 88 ++++- .../FastSessionTranscript.client.test.tsx | 299 ++++++++++++++++- .../[sessionId]/FastSessionTranscript.tsx | 311 +++++++++++++----- .../SetupIntegrationsCard.client.test.tsx | 42 ++- .../setup/SetupIntegrationsCard.tsx | 63 ++-- .../components/settings/Integrations.test.tsx | 29 ++ .../src/components/settings/Integrations.tsx | 87 +++-- apps/web/src/hooks/linear/useConnectLinear.ts | 6 +- .../src/hooks/linear/useDisconnectLinear.ts | 6 +- .../linear/useInvalidateLinearOauthSetup.ts | 8 +- apps/web/src/hooks/mcp-connections/index.ts | 4 +- ...alidateMcpIntegrationStatusQueries.test.ts | 32 ++ .../invalidateMcpIntegrationStatusQueries.ts | 26 ++ .../hooks/mcp-connections/useConnectMcp.ts | 5 +- .../hooks/mcp-connections/useDisconnectMcp.ts | 8 +- ...lity.ts => useEffectiveMcpIntegrations.ts} | 4 +- .../mcp-connections/useMcpOauthReadiness.ts | 11 - .../mcp-connections/useSaveAsanaConnection.ts | 8 +- .../useSaveElevenLabsConnection.ts | 8 +- .../useSaveGrafanaConnection.ts | 8 +- .../useSaveGranolaConnection.ts | 8 +- .../useSaveNotionConnection.ts | 8 +- .../useSaveRipplingConnection.ts | 8 +- .../useSaveSnowflakeConnection.ts | 8 +- .../useSaveVercelConnection.ts | 8 +- .../mcp-connections/useSaveXConnection.ts | 8 +- .../useSetDeploymentMcpEnabled.ts | 8 +- .../mcp-connections/useSetDisabledMcpTools.ts | 2 + apps/web/src/lib/server/mcp-static-oauth.ts | 12 +- .../trpc/commands/fast-sessions/index.test.ts | 72 +++- .../src/trpc/commands/fast-sessions/index.ts | 43 ++- .../commands/mcp-connections/index.test.ts | 47 +++ .../trpc/commands/mcp-connections/index.ts | 103 ++++++ .../trpc/commands/setup/setup-session.test.ts | 65 ++-- .../src/trpc/commands/setup/setup-session.ts | 157 ++++----- apps/web/src/trpc/routers/_app.ts | 5 + ...fast-agent-conversation-repository.test.ts | 23 +- .../fast-agent-integration-broker.test.ts | 23 +- .../__tests__/fast-agent-service.test.ts | 43 +++ .../__tests__/fast-agent-session.test.ts | 2 +- .../fast-agent-conversation-repository.ts | 33 +- .../fast-agent/fast-agent-conversation.ts | 2 + .../fast-agent-integration-broker.ts | 2 +- .../server/fast-agent/fast-agent-prompt.ts | 23 +- .../server/fast-agent/fast-agent-service.ts | 7 + .../server/fast-agent/fast-agent-session.ts | 3 + .../fast-agent/fast-agent-setup-context.ts | 157 +++++++++ .../fast-agent/fast-agent-setup-tools.test.ts | 70 +--- .../src/server/fast-agent/index.ts | 1 + .../discord-request-user-input.test.ts | 80 ++++- .../src/__tests__/request-user-input.test.ts | 165 ++++++++++ .../src/discord-request-user-input.ts | 25 +- .../communication/src/request-user-input.ts | 110 ++++++- .../lib/fast-agent-parent-event.test.ts | 12 + .../src/server/lib/fast-agent-parent-event.ts | 7 + .../server/routers/mcp-connections.test.ts | 12 + .../sdk/src/server/routers/mcp-connections.ts | 25 +- .../types/src/acp-request-user-input.test.ts | 37 +++ packages/types/src/acp.ts | 54 ++- packages/types/src/fast-agent.ts | 20 ++ packages/types/src/mcp-oauth.ts | 32 ++ 63 files changed, 2236 insertions(+), 572 deletions(-) create mode 100644 apps/api/src/handlers/discord/__tests__/request-user-input.test.ts create mode 100644 apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.test.ts create mode 100644 apps/web/src/hooks/mcp-connections/invalidateMcpIntegrationStatusQueries.ts rename apps/web/src/hooks/mcp-connections/{useCuratedIntegrationsAvailability.ts => useEffectiveMcpIntegrations.ts} (52%) delete mode 100644 apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts create mode 100644 packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts diff --git a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts index 53e44604a1..82f7cc91c1 100644 --- a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts @@ -3,6 +3,7 @@ import * as suggestionLaunch from '../../tasks/suggestion-launch.js'; const mocks = vi.hoisted(() => ({ findRun: vi.fn(), + findActiveCommunicationRun: vi.fn(), stopTaskRun: vi.fn(), reply: vi.fn(), findMappedUser: vi.fn(), @@ -49,6 +50,9 @@ vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply })); vi.mock('@roomote/sdk/server', () => ({ findDiscordMappedUserId: mocks.findMappedUser, })); +vi.mock('@roomote/sdk/server/communication', () => ({ + findActiveCommunicationTaskRun: mocks.findActiveCommunicationRun, +})); vi.mock('../../fast-agent-entry.js', () => ({ resolveFastAgentEntryMode: ({ userDefaultEnabled, diff --git a/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts b/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts new file mode 100644 index 0000000000..cad410b6c2 --- /dev/null +++ b/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TaskPayloadKind } from '@roomote/types'; + +const mocks = vi.hoisted(() => ({ + findActiveRun: vi.fn(), + getPending: vi.fn(), + rebindPending: vi.fn(), + reply: vi.fn(), + setActingUserOnSuccess: vi.fn(), + submitAnswer: vi.fn(), +})); + +vi.mock('@roomote/communication', async (importOriginal) => ({ + ...(await importOriginal()), + getPendingCommunicationRequestUserInput: mocks.getPending, + rebindPendingCommunicationRequestUserInputRun: mocks.rebindPending, + submitPendingCommunicationRequestUserInputAnswer: mocks.submitAnswer, +})); + +vi.mock('@roomote/db/server', () => ({ + setTrustedRunActingUserOnSuccess: mocks.setActingUserOnSuccess, +})); + +vi.mock('@roomote/sdk/server/communication', () => ({ + findActiveCommunicationTaskRun: mocks.findActiveRun, +})); + +vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply })); + +import { buildDiscordRequestUserInputAnswerCallbackData } from '@roomote/communication'; + +import { tryHandleDiscordRequestUserInputCallback } from '../request-user-input.js'; + +const pendingRequest = { + requestId: 'rui:session:turn:callid12', + runId: 42, + taskId: 'task-1', + provider: 'discord' as const, + conversationId: 'thread-1', + questions: [ + { + id: 'q1', + header: 'Bump', + question: 'What bump level should I cut?', + isOther: false, + isSecret: false, + options: [{ label: 'minor', description: 'Recommended' }], + }, + ], + status: 'pending' as const, + promptMessageId: 'prompt-1', + currentQuestionIndex: 0, + answers: {}, + createdAt: 123, +}; + +const channel = { + channelId: 'thread-1', + channelName: 'Task thread', + channelType: 11, + guildId: 'guild-1', + parentChannelId: 'channel-1', + isDirectMessage: false, + isThread: true, +}; + +const interaction = { + id: 'interaction-1', + application_id: 'app-1', + type: 3, + token: 'token-1', + channel_id: 'thread-1', + user: { id: 'discord-user-1', username: 'matt' }, + data: { component_type: 2 }, +}; + +function answerCustomId(): string { + return buildDiscordRequestUserInputAnswerCallbackData({ + runId: 42, + requestId: pendingRequest.requestId, + questionIndex: 0, + optionIndex: 0, + }); +} + +describe('Discord request_user_input callbacks', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getPending.mockResolvedValue(pendingRequest); + mocks.rebindPending.mockResolvedValue(true); + mocks.reply.mockResolvedValue({ messageId: 'response-1' }); + mocks.submitAnswer.mockResolvedValue(true); + mocks.setActingUserOnSuccess.mockImplementation( + async ({ operation }: { operation: () => Promise }) => + operation(), + ); + }); + + it('rejects a structured answer unless the task owns the active reply target', async () => { + mocks.findActiveRun.mockResolvedValue(undefined); + const provider = { editMessage: vi.fn() } as never; + + await expect( + tryHandleDiscordRequestUserInputCallback({ + provider, + applicationId: 'app-1', + channel, + interaction: interaction as never, + interactionDeferred: true, + customId: answerCustomId(), + userId: 'user-1', + }), + ).resolves.toBe(true); + + expect(mocks.findActiveRun).toHaveBeenCalledWith({ + provider: 'discord', + channelId: 'channel-1', + threadId: 'thread-1', + taskId: 'task-1', + }); + expect(mocks.setActingUserOnSuccess).not.toHaveBeenCalled(); + expect(mocks.submitAnswer).not.toHaveBeenCalled(); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'This prompt is no longer active.', + ephemeral: true, + }), + ); + }); + + it('accepts an authorized answer without rebinding the current run', async () => { + mocks.findActiveRun.mockResolvedValue({ id: 42 }); + const editMessage = vi.fn().mockResolvedValue(undefined); + + await tryHandleDiscordRequestUserInputCallback({ + provider: { editMessage } as never, + applicationId: 'app-1', + channel, + interaction: interaction as never, + interactionDeferred: true, + customId: answerCustomId(), + userId: 'user-1', + }); + + expect(mocks.rebindPending).not.toHaveBeenCalled(); + expect(mocks.setActingUserOnSuccess).toHaveBeenCalledWith( + expect.objectContaining({ runId: 42, userId: 'user-1' }), + ); + expect(mocks.submitAnswer).toHaveBeenCalledWith( + 'discord', + 'thread-1', + pendingRequest, + expect.objectContaining({ userId: 'user-1' }), + ); + expect(editMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'thread-1', + messageId: 'prompt-1', + buttons: [], + }), + ); + }); + + it('atomically rebinds an authorized legacy prompt to its resumed run', async () => { + mocks.findActiveRun.mockResolvedValue({ + id: 84, + payloadKind: TaskPayloadKind.SnapshotResume, + payload: { sourceRunId: 42 }, + }); + + await tryHandleDiscordRequestUserInputCallback({ + provider: { editMessage: vi.fn().mockResolvedValue(undefined) } as never, + applicationId: 'app-1', + channel, + interaction: interaction as never, + interactionDeferred: true, + customId: 'discord:rui:42:0:0:callid12', + userId: 'user-1', + }); + + expect(mocks.rebindPending).toHaveBeenCalledWith({ + provider: 'discord', + conversationId: 'thread-1', + taskId: 'task-1', + sourceRunId: 42, + resumedRunId: 84, + }); + expect(mocks.setActingUserOnSuccess).toHaveBeenCalledWith( + expect.objectContaining({ runId: 84, userId: 'user-1' }), + ); + expect(mocks.submitAnswer).toHaveBeenCalledWith( + 'discord', + 'thread-1', + { ...pendingRequest, runId: 84 }, + expect.objectContaining({ userId: 'user-1' }), + ); + }); + + it('does not rebind a later run without snapshot-resume lineage', async () => { + mocks.findActiveRun.mockResolvedValue({ id: 84, payload: {} }); + + await tryHandleDiscordRequestUserInputCallback({ + provider: { editMessage: vi.fn() } as never, + applicationId: 'app-1', + channel, + interaction: interaction as never, + interactionDeferred: true, + customId: answerCustomId(), + userId: 'user-1', + }); + + expect(mocks.rebindPending).not.toHaveBeenCalled(); + expect(mocks.submitAnswer).not.toHaveBeenCalled(); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'This prompt is no longer active.', + ephemeral: true, + }), + ); + }); +}); diff --git a/apps/api/src/handlers/discord/request-user-input.ts b/apps/api/src/handlers/discord/request-user-input.ts index 3441447cd6..7f6e0be69e 100644 --- a/apps/api/src/handlers/discord/request-user-input.ts +++ b/apps/api/src/handlers/discord/request-user-input.ts @@ -3,15 +3,21 @@ import { buildDiscordCancelledRequestUserInputText, getDiscordRequestUserInputCurrentQuestion, getPendingCommunicationRequestUserInput, + matchesDiscordRequestUserInputRequestToken, parseDiscordRequestUserInputAnswerCallbackData, parseDiscordRequestUserInputCancelCallbackData, + rebindPendingCommunicationRequestUserInputRun, submitPendingCommunicationRequestUserInputAnswer, type PendingCommunicationRequestUserInput, } from '@roomote/communication'; import type { DiscordInteraction } from '@roomote/communication/discord-event'; import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; -import { type AcpRequestUserInputAnswers } from '@roomote/types'; +import { + TaskPayloadKind, + type AcpRequestUserInputAnswers, +} from '@roomote/types'; import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server'; +import { findActiveCommunicationTaskRun } from '@roomote/sdk/server/communication'; import { apiLogger } from '../../logging.js'; import { replyToDiscordEvent } from './replies.js'; @@ -186,7 +192,7 @@ export async function tryHandleDiscordRequestUserInputCallback(params: { } const conversationId = conversationIdForChannel(params.channel); - const pendingRequest = await getPendingCommunicationRequestUserInput( + let pendingRequest = await getPendingCommunicationRequestUserInput( 'discord', conversationId, ); @@ -207,10 +213,15 @@ export async function tryHandleDiscordRequestUserInputCallback(params: { return true; } - const expectedToken = pendingRequest.requestId.slice(-8); const receivedToken = answerCallback?.requestToken ?? cancelCallback?.requestToken; - if (receivedToken !== expectedToken) { + if ( + !receivedToken || + !matchesDiscordRequestUserInputRequestToken( + pendingRequest.requestId, + receivedToken, + ) + ) { await replyToDiscordEvent({ provider: params.provider, applicationId: params.applicationId, @@ -225,6 +236,75 @@ export async function tryHandleDiscordRequestUserInputCallback(params: { return true; } + const activeRun = await findActiveCommunicationTaskRun({ + provider: 'discord', + channelId: params.channel.parentChannelId ?? params.channel.channelId, + ...(params.channel.parentChannelId + ? { threadId: params.channel.channelId } + : {}), + taskId: pendingRequest.taskId, + }); + if (!activeRun) { + await replyToDiscordEvent({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + interaction: { + interaction: params.interaction, + interactionDeferred: params.interactionDeferred, + }, + text: 'This prompt is no longer active.', + ephemeral: true, + }); + return true; + } + + if (activeRun.id !== pendingRequest.runId) { + const sourceRunId = + activeRun.payloadKind === TaskPayloadKind.SnapshotResume && + activeRun.payload && + typeof activeRun.payload === 'object' + ? (activeRun.payload as { sourceRunId?: unknown }).sourceRunId + : undefined; + if (sourceRunId !== pendingRequest.runId) { + await replyToDiscordEvent({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + interaction: { + interaction: params.interaction, + interactionDeferred: params.interactionDeferred, + }, + text: 'This prompt is no longer active.', + ephemeral: true, + }); + return true; + } + + const rebound = await rebindPendingCommunicationRequestUserInputRun({ + provider: 'discord', + conversationId, + taskId: pendingRequest.taskId, + sourceRunId: pendingRequest.runId, + resumedRunId: activeRun.id, + }); + if (!rebound) { + await replyToDiscordEvent({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + interaction: { + interaction: params.interaction, + interactionDeferred: params.interactionDeferred, + }, + text: 'This prompt is no longer active.', + ephemeral: true, + }); + return true; + } + pendingRequest = { ...pendingRequest, runId: activeRun.id }; + } + if (pendingRequest.status === 'submitted') { await postAlreadyReceivedNotice({ provider: params.provider, diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 7451a883fa..8d299bf6b5 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 @@ -547,15 +547,308 @@ describe('FastSessionTranscript', () => { , ); expect(screen.queryByText('Structured input request')).toBeNull(); - expect(screen.queryByText('Structured response')).toBeNull(); + expect(screen.getByText('Structured response')).toBeInTheDocument(); + expect(screen.getByLabelText('Test User')).toBeInTheDocument(); expect(screen.queryByText(cardLabel)).toBeNull(); }, ); + it('renders a structured response once in chronology as human-authored text', () => { + const requestId = 'rui:chronology'; + const question = 'Which direction should I take?'; + const request = { + ...textMessage({ + id: 'input-request', + role: 'assistant', + text: question, + ts: 2, + }), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, + payload: { + requestId, + status: 'pending', + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + questions: [ + { + id: 'direction', + header: 'Direction', + question, + isOther: true, + isSecret: false, + }, + ], + }, + }; + const response = { + ...textMessage({ + id: 'input-response', + role: 'user', + text: 'Legacy persisted answer', + ts: 3, + }), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse, + payload: { + requestId, + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + answers: { direction: { answers: ['Use the narrow path'] } }, + resolution: 'submitted', + }, + }; + + render( + , + ); + + const before = screen.getByText('Before the question'); + const answer = screen.getByText('Use the narrow path'); + const after = screen.getByText('After the answer'); + expect(before.compareDocumentPosition(answer)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(answer.compareDocumentPosition(after)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(screen.getAllByText(question)).toHaveLength(1); + expect(screen.queryByText('Legacy persisted answer')).toBeNull(); + expect(screen.getByLabelText('Transcript Owner')).toBeInTheDocument(); + }); + + it('hides request_user_input tool lifecycle rows while keeping the interaction card', () => { + const requestId = 'rui:hidden-tools'; + const toolPayload = { + toolCallId: 'turn-1:tool:0', + title: 'request_user_input', + kind: 'tool', + status: 'completed', + isExecute: false, + isRead: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + toolName: 'request_user_input', + command: null, + rawInput: { arguments: { question: 'Hidden tool question' } }, + }; + const toolBase = { + id: 'request-tool', + eventId: 'turn-1:tool:0', + turnId: 'turn-1', + turnSeq: 1, + ts: 1, + role: 'tool' as const, + metadata: { visibleInTranscript: true }, + source: 'web', + nativeSessionId: 'opencode-1', + nativeMessageId: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }; + const request = { + ...textMessage({ + id: 'input-request', + role: 'assistant', + text: 'Choose a path', + ts: 2, + }), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, + payload: { + requestId, + status: 'pending', + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + questions: [ + { + id: 'path', + header: 'Path', + question: 'Choose a path', + isOther: true, + isSecret: false, + }, + ], + }, + }; + + render( + , + ); + + expect(screen.getByText('Structured input request')).toBeInTheDocument(); + expect(screen.queryByText('Asked for')).toBeNull(); + expect(screen.queryByText('human guidance')).toBeNull(); + expect(screen.queryByText('Hidden tool result')).toBeNull(); + expect(screen.queryByText('Choose a path')).toBeNull(); + }); + + it('places a pending interaction at its chronological position', () => { + const request = { + ...textMessage({ + id: 'input-request', + role: 'assistant', + text: 'Choose a path', + ts: 2, + }), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, + payload: { + requestId: 'rui:pending-order', + status: 'pending', + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + questions: [ + { + id: 'path', + header: 'Path', + question: 'Choose a path', + isOther: true, + isSecret: false, + }, + ], + }, + }; + render( + , + ); + + const before = screen.getByText('Before pending input'); + const interaction = screen.getByText('Structured input request'); + const after = screen.getByText('Later transcript activity'); + expect(before.compareDocumentPosition(interaction)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(interaction.compareDocumentPosition(after)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + + it('keeps the composer available for non-preset input requests', () => { + const request = { + ...textMessage({ + id: 'input-request', + role: 'assistant', + text: 'Choose or write another direction', + ts: 1, + }), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, + payload: { + requestId: 'rui:optional', + status: 'pending', + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + questions: [ + { + id: 'direction', + header: 'Direction', + question: 'Choose or write another direction', + isOther: true, + isSecret: false, + }, + ], + }, + }; + + const { unmount } = render( + , + ); + expect(screen.getByPlaceholderText('Message agent')).toBeInTheDocument(); + + unmount(); + render( + , + ); + expect(screen.queryByPlaceholderText('Message agent')).toBeNull(); + expect(screen.getByText('Setup starter tasks')).toBeInTheDocument(); + }); + it.each([ [1, '1 task running'], [2, '2 tasks running'], @@ -1961,7 +2254,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 be5b587922..fabb8512c8 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, @@ -102,6 +105,25 @@ function getTranscriptMessageText(message: TranscriptMessage) { : text; } +function isRequestUserInputToolMessage(message: TranscriptMessage) { + if ( + message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCall && + message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCallUpdate && + message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolResult + ) { + return false; + } + + const payload = message.payload as { + toolName?: unknown; + title?: unknown; + } | null; + return ( + payload?.toolName === 'request_user_input' || + payload?.title === 'request_user_input' + ); +} + type PendingResponseState = { pendingAfter: TranscriptOrder | null; latestVisibleResponse: TranscriptOrder | null; @@ -590,59 +612,132 @@ export function FastSessionTranscript({ return { messageCount, assistantCount }; }, [serverMessages]); - const persistedUiMessages = useMemo( - () => - messages - .filter( - (message) => - !( - message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage && - (message.payload as { taskNavigation?: unknown } | null) - ?.taskNavigation === true - ) && - message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput && - message.eventType !== - ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse, - ) - .map((message) => { - 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, - }); + const pendingInputRequest = useMemo( + () => findPendingSessionInputRequest(messages), + [messages], + ); + const pendingInputRequestOrder = useMemo(() => { + if (!pendingInputRequest) return null; - if ( - uiMessage.role !== 'user' || - !owner || - uiMessage.userId !== owner.userId - ) { - return uiMessage; - } + return ( + messages.find((message) => { + if (message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) { + return false; + } + return ( + parseAcpRequestUserInputPayload(message.payload)?.requestId === + pendingInputRequest.requestId + ); + }) ?? null + ); + }, [messages, pendingInputRequest]); + const requestUserInputById = useMemo(() => { + const requests = new Map< + string, + NonNullable> + >(); + for (const message of messages) { + if (message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) { + continue; + } + const request = parseAcpRequestUserInputPayload(message.payload); + if (request) requests.set(request.requestId, request); + } + return requests; + }, [messages]); + const { persistedBeforeInput, persistedAfterInput } = useMemo(() => { + const before: AcpUiMessage[] = []; + const after: AcpUiMessage[] = []; - return { - ...uiMessage, - userName: uiMessage.userName ?? owner.name, - userEmail: uiMessage.userEmail ?? owner.email, - userImageUrl: uiMessage.userImageUrl ?? owner.imageUrl, - }; - }), - [messages, owner], - ); + for (const message of messages) { + if ( + (message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage && + (message.payload as { taskNavigation?: unknown } | null) + ?.taskNavigation === true) || + message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInput || + isRequestUserInputToolMessage(message) + ) { + continue; + } + + let uiMessage = toAcpUiMessage({ + // A reply keeps the id its streamed chunks rendered under, so the + // persisted row reconciles in place instead of remounting. + id: + message.role === 'assistant' && + message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage + ? `assistant:${message.eventId}` + : message.id, + ts: message.ts, + eventType: message.eventType as AcpEventType, + role: message.role, + kind: inferAcpMessageKind(message.eventType), + contentBlocks: message.contentBlocks, + metadata: message.metadata, + payload: message.payload, + text: getTranscriptMessageText(message), + userName: message.userName, + userEmail: message.userEmail, + userImageUrl: message.userImageUrl, + }); + + if ( + message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse + ) { + const response = parseAcpRequestUserInputResponsePayload( + message.payload, + ); + const requestId = + response?.requestId ?? + (typeof message.payload?.requestId === 'string' + ? message.payload.requestId + : null); + const request = requestId + ? (requestUserInputById.get(requestId) ?? null) + : null; + uiMessage = { + ...uiMessage, + role: 'user', + kind: 'text', + text: + response !== null + ? formatRequestUserInputResponseText(request, response) + : (getTranscriptMessageText(message) ?? + 'Submitted input response'), + data: request + ? { ...(message.payload ?? {}), request } + : (message.payload ?? {}), + userId: uiMessage.userId ?? owner?.userId, + userName: uiMessage.userName ?? owner?.name, + userEmail: uiMessage.userEmail ?? owner?.email, + userImageUrl: uiMessage.userImageUrl ?? owner?.imageUrl, + }; + } else if ( + uiMessage.role === 'user' && + owner && + uiMessage.userId === owner.userId + ) { + uiMessage = { + ...uiMessage, + userName: uiMessage.userName ?? owner.name, + userEmail: uiMessage.userEmail ?? owner.email, + userImageUrl: uiMessage.userImageUrl ?? owner.imageUrl, + }; + } + + const target = + pendingInputRequestOrder && + compareTranscriptOrder(message, pendingInputRequestOrder) > 0 + ? after + : before; + target.push(uiMessage); + } + + return { + persistedBeforeInput: before, + persistedAfterInput: after, + }; + }, [messages, owner, pendingInputRequestOrder, requestUserInputById]); const hasVisibleAssistantMessage = useMemo( () => messages.some( @@ -653,10 +748,6 @@ export function FastSessionTranscript({ ), [messages], ); - const pendingInputRequest = useMemo( - () => findPendingSessionInputRequest(messages), - [messages], - ); const reviewOffers = useMemo( () => messages.flatMap((message) => { @@ -665,15 +756,51 @@ 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, + shouldHideFirstMessage: false, + showInternalMessages: false, + hasLeadingTextBoundary: false, + keepDelegatedTasksVisible: true, + resetKey: `before:${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, + }); + const { + renderBlocks: renderBlocksAfterInput, + suppressMessage: suppressMessageAfterInput, + } = useAcpTranscriptBlocks({ + messages: uiMessagesAfterInput, artifacts: [], displayMode, initialPrompt: null, @@ -681,7 +808,7 @@ export function FastSessionTranscript({ showInternalMessages: false, hasLeadingTextBoundary: false, keepDelegatedTasksVisible: true, - resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, + resetKey: `after:${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, }); const sendReply = useCallback( @@ -816,9 +943,36 @@ export function FastSessionTranscript({

) : null} + {pendingInputRequest ? ( +
+ {pendingInputRequest.preset === 'setup_starter_tasks' ? ( + + ) : pendingInputRequest.preset === 'setup_integrations' ? ( + + ) : ( + + )} +
+ ) : null} + {hasVisibleAssistantMessage ? timelineExtras : null} @@ -849,31 +1003,10 @@ export function FastSessionTranscript({ } /> ))} - {pendingInputRequest ? ( -
- {pendingInputRequest.preset === 'setup_starter_tasks' ? ( - - ) : pendingInputRequest.preset === 'setup_integrations' ? ( - - ) : ( - - )} -
- ) : null} - {canReply && !pendingInputRequest ? ( + {canReply && !pendingInputRequest?.preset ? (
({ })); vi.mock('@/hooks/mcp-connections', () => ({ useConnectMcp: () => ({ mutate: mocks.mutate, isPending: false }), - useUserMcpConnections: () => ({ - data: mocks.connections, - refetch: mocks.refetch, - }), - useDeploymentMcpEnablements: () => ({ - data: mocks.enablements, - refetch: mocks.refetch, - }), - useCuratedIntegrationsAvailability: () => ({ - data: { enabled: mocks.enabled }, + useEffectiveMcpIntegrations: () => ({ + data: SETUP_INTEGRATIONS.map((integration) => { + const enabled = mocks.enablements.some( + (entry) => entry.mcpId === integration.id && entry.enabled, + ); + const connected = mocks.connections.some( + (entry) => + entry.mcpId === integration.id && + entry.authStatus === 'authenticated', + ); + return { + id: integration.id, + authStatus: connected ? 'authenticated' : null, + status: !mocks.enabled + ? 'unavailable' + : enabled + ? connected + ? 'connected' + : 'needs_connection' + : 'not_enabled', + }; + }), refetch: mocks.refetch, + isPending: mocks.pending, + isError: mocks.error, }), })); vi.mock('@/components/settings/Integrations', () => ({ @@ -212,7 +226,7 @@ it.each(['Back to setup', 'Escape'])( await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), ); - expect(mocks.refetch).toHaveBeenCalledTimes(4); + expect(mocks.refetch).toHaveBeenCalledOnce(); expect(screen.getByRole('button', { name: 'Keep going' })).toBeEnabled(); }, ); @@ -372,7 +386,11 @@ it('shows unavailable status without offering manual refresh after a status erro it('does not treat an authenticated but disabled connector as connected', () => { mocks.connections = [{ mcpId: 'notion', authStatus: 'authenticated' }]; render(); - expect(screen.getByText('Not enabled')).toBeInTheDocument(); + const notionRow = screen + .getByRole('button', { name: 'Connect Notion' }) + .closest('li'); + expect(notionRow).not.toBeNull(); + expect(within(notionRow!).getByText('Not enabled')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Connect Notion' })).toBeEnabled(); expect(screen.queryByText('Connected')).not.toBeInTheDocument(); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx index 95761fb3ed..af861cfcee 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupIntegrationsCard.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import { usePathname, useSearchParams } from 'next/navigation'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; import { MCP_INTEGRATIONS, @@ -29,9 +29,7 @@ import { import { McpIcon } from '@/components/settings/McpIcon'; import { useConnectMcp, - useCuratedIntegrationsAvailability, - useDeploymentMcpEnablements, - useUserMcpConnections, + useEffectiveMcpIntegrations, } from '@/hooks/mcp-connections'; import { useAuthorizedUser } from '@/hooks/useUser'; import { useTelemetry } from '@/hooks/useTelemetry'; @@ -59,10 +57,7 @@ export function SetupIntegrationsCard({ const searchParams = useSearchParams(); const { isAdmin } = useAuthorizedUser(); const { enabled, capture } = useTelemetry(); - const onboarding = useQuery(trpc.onboarding.status.queryOptions()); - const enablements = useDeploymentMcpEnablements(); - const connections = useUserMcpConnections(); - const availability = useCuratedIntegrationsAvailability(); + const effectiveIntegrations = useEffectiveMcpIntegrations(); const connectMcp = useConnectMcp(); const [activeId, setActiveId] = useState(null); const [continued, setContinued] = useState(false); @@ -115,41 +110,33 @@ export function SetupIntegrationsCard({ }); }, [matchedCount, mutate, request.requestId, sessionId]); const refresh = () => { - void onboarding.refetch(); - void enablements.refetch(); - void connections.refetch(); - void availability.refetch(); + void effectiveIntegrations.refetch(); }; - const statusPending = - onboarding.isPending || enablements.isPending || connections.isPending; - const statusError = - onboarding.isError || - enablements.isError || - connections.isError || - availability.isError; + const statusPending = effectiveIntegrations.isPending; + const statusError = effectiveIntegrations.isError; const active = SETUP_INTEGRATIONS.find( (integration) => integration.id === activeId, ); const activeDefinition = MCP_INTEGRATIONS.find( (integration) => integration.id === activeId, ); - const authenticatedIds = new Set( - (connections.data ?? []) - .filter((connection) => connection.authStatus === 'authenticated') - .map((connection) => connection.mcpId), - ); - const enabledIds = new Set( - (enablements.data ?? []) - .filter((entry) => entry.enabled) - .map((entry) => entry.mcpId), + const effectiveById = new Map( + (effectiveIntegrations.data ?? []).map((integration) => [ + integration.id, + integration, + ]), ); const getStatus = (integration: (typeof SETUP_INTEGRATIONS)[number]) => { if (statusPending || statusError) return null; - if (integration.id === 'linear') - return onboarding.data?.orgHasLinear ? 'Connected' : null; - if (authenticatedIds.has(integration.id)) - return enabledIds.has(integration.id) ? 'Connected' : 'Not enabled'; - return enabledIds.has(integration.id) ? 'Needs connection' : null; + const status = effectiveById.get(integration.id)?.status; + if (status === 'connected') return 'Connected'; + if ( + status === 'not_enabled' && + effectiveById.get(integration.id)?.authStatus === 'authenticated' + ) + return 'Not enabled'; + if (status === 'needs_connection') return 'Needs connection'; + return null; }; const authFailed = searchParams.get('mcp') === 'error' || searchParams.get('error') !== null; @@ -208,7 +195,9 @@ export function SetupIntegrationsCard({ or keep going.

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

Tool integrations are disabled by the deployment operator. You can still continue setup. @@ -228,7 +217,8 @@ export function SetupIntegrationsCard({ (entry) => entry.id === integration.id, ); const status = getStatus(integration); - const unavailable = availability.data?.enabled === false; + const effective = effectiveById.get(integration.id); + const unavailable = effective?.status === 'unavailable'; return (

  • diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 766d0eec08..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'; @@ -228,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, diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index 11a488d624..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'; @@ -1488,19 +1485,16 @@ export function Integrations({ ); const disconnectLinear = useDisconnectLinear(); - const deploymentEnablements = useDeploymentMcpEnablements(); - const integrationsAvailability = useCuratedIntegrationsAvailability(); - const oauthReadiness = useMcpOauthReadiness(); - const linearOauthStatus = oauthReadiness.data?.find( - (entry) => entry.mcpId === 'linear', - )?.status; + const effectiveIntegrations = useEffectiveMcpIntegrations(); + const linearOauthStatus = effectiveIntegrations.data?.find( + (entry) => entry.id === 'linear', + )?.oauthReadiness; const linearOauthUnavailable = linearOauthStatus === 'missing' || linearOauthStatus === 'partial'; const linearOauthSetup = useLinearOauthSetup( isAdmin && (linearOauthUnavailable || isLinearOauthSetupOpen), ); const setDeploymentEnabled = useSetDeploymentMcpEnabled(); - const userMcpConnections = useUserMcpConnections(); const connectMcp = useConnectMcp(); const disconnectMcp = useDisconnectMcp(); const saveAsanaConnection = useSaveAsanaConnection(); @@ -1513,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( @@ -1526,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 && @@ -1539,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 && @@ -1553,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), @@ -1786,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; @@ -1851,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 @@ -2254,7 +2248,7 @@ export function Integrations({ linearOauthSetup.isPending, linearOauthStatus, linearOauthUnavailable, - oauthReadiness.isPending, + effectiveIntegrations.isPending, isAdmin, isGrafanaDialogOpen, isGranolaDialogOpen, @@ -2267,7 +2261,7 @@ export function Integrations({ saveGranolaConnection.isPending, saveElevenLabsConnection.isPending, saveVercelConnection.isPending, - deploymentEnablements.data, + effectiveIntegrations.data, pathname, integrationIds, setDeploymentEnabled, @@ -2288,7 +2282,6 @@ export function Integrations({ xConnection.isPending, isXDialogOpen, highlightedIntegrationId, - userMcpConnections.data, ]); const { @@ -2909,7 +2902,11 @@ export function Integrations({ }); }; - if (integrationsAvailability.data?.enabled === false) { + if ( + effectiveIntegrations.data?.some( + (integration) => integration.status === 'unavailable', + ) + ) { return (
    diff --git a/apps/web/src/hooks/linear/useConnectLinear.ts b/apps/web/src/hooks/linear/useConnectLinear.ts index af34ee615d..14b6e0ccf1 100644 --- a/apps/web/src/hooks/linear/useConnectLinear.ts +++ b/apps/web/src/hooks/linear/useConnectLinear.ts @@ -5,6 +5,7 @@ import { } from '@tanstack/react-query'; import { useTRPC, useTRPCClient } from '@/trpc/client'; +import { invalidateMcpIntegrationStatusQueries } from '@/hooks/mcp-connections'; type UseConnectLinearOptions = Omit< UseMutationOptions, @@ -28,13 +29,10 @@ export const useConnectLinear = ( }); }, onSuccess: (data, variables, onMutateResult, context) => { + void invalidateMcpIntegrationStatusQueries(queryClient, trpc); queryClient.invalidateQueries({ queryKey: trpc.linear.installation.queryKey(), }); - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), - }); - options?.onSuccess?.(data, variables, onMutateResult, context); }, onError: options?.onError, diff --git a/apps/web/src/hooks/linear/useDisconnectLinear.ts b/apps/web/src/hooks/linear/useDisconnectLinear.ts index 49bcde905d..fc6c2542c9 100644 --- a/apps/web/src/hooks/linear/useDisconnectLinear.ts +++ b/apps/web/src/hooks/linear/useDisconnectLinear.ts @@ -5,6 +5,7 @@ import { } from '@tanstack/react-query'; import { useTRPC, useTRPCClient } from '@/trpc/client'; +import { invalidateMcpIntegrationStatusQueries } from '@/hooks/mcp-connections'; type UseDisconnectLinearOptions = Omit< UseMutationOptions, @@ -28,13 +29,10 @@ export const useDisconnectLinear = (options?: UseDisconnectLinearOptions) => { } }, onSuccess: (data, variables, onMutateResult, context) => { + void invalidateMcpIntegrationStatusQueries(queryClient, trpc); queryClient.invalidateQueries({ queryKey: trpc.linear.installation.queryKey(), }); - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), - }); - options?.onSuccess?.(data, variables, onMutateResult, context); }, onError: options?.onError, diff --git a/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts b/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts index 81d6413de5..14432f15ee 100644 --- a/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts +++ b/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useTRPC } from '@/trpc/client'; +import { invalidateMcpIntegrationStatusQueries } from '@/hooks/mcp-connections'; export function useInvalidateLinearOauthSetup() { const trpc = useTRPC(); @@ -10,18 +11,13 @@ export function useInvalidateLinearOauthSetup() { return async () => { await Promise.all([ + invalidateMcpIntegrationStatusQueries(queryClient, trpc), queryClient.invalidateQueries({ queryKey: trpc.linear.oauthSetup.queryKey(), }), - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.oauthReadiness.queryKey(), - }), queryClient.invalidateQueries({ queryKey: trpc.linear.installation.queryKey(), }), - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), - }), ]); }; } diff --git a/apps/web/src/hooks/mcp-connections/index.ts b/apps/web/src/hooks/mcp-connections/index.ts index 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 f8520e565f..a89c6985dc 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.test.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.test.ts @@ -34,6 +34,7 @@ vi.mock('next/server', () => ({ after: mocks.after })); vi.mock('@roomote/cloud-agents/server', () => ({ acquireFastAgentTurnLock: mocks.acquireTurnLock, answerFastAgentQuestion: mocks.answerQuestion, + buildFastAgentSetupAdapter: vi.fn(() => ({})), createFastAgentWebTaskLauncher: mocks.createWebTaskLauncher, FastAgentDurableRetryScheduledError: class FastAgentDurableRetryScheduledError extends Error {}, getOrCreateFastAgentSession: mocks.getOrCreateSession, @@ -169,6 +170,12 @@ describe('setup context on ordinary Fast session input', () => { setupSession: true, adapterExtensions: { resolveUserInputPreset: resolvePreset }, setupSnapshot: initialSnapshot, + setupContext: { + sessionId: 'session-1', + fastConversationId: 'session-1', + setupSnapshot: initialSnapshot, + starterTaskOptions: [], + }, }; const question = { id: 'setup-tools-documents', @@ -197,6 +204,7 @@ describe('setup context on ordinary Fast session input', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.after.mockReset(); mocks.resolveSetupContext.mockReset().mockResolvedValue(null); mocks.upsertMessage.mockReset().mockResolvedValue(undefined); mocks.findAccessibleSession.mockResolvedValue(session); @@ -247,6 +255,15 @@ describe('setup context on ordinary Fast session input', () => { adapter: { resolveUserInputPreset: resolvePreset }, }); expect(mocks.resolveSetupContext).toHaveBeenCalledWith(auth, session.id); + const { persistFastAgentInlineHumanTurn } = + await import('@roomote/sdk/server'); + expect(vi.mocked(persistFastAgentInlineHumanTurn)).toHaveBeenCalledWith({ + parent: expect.objectContaining({ sessionId: session.id }), + event: expect.objectContaining({ + setupSession: true, + setupContext: setupContext.setupContext, + }), + }); }); it('leaves ordinary non-setup replies unchanged', async () => { @@ -268,7 +285,14 @@ describe('setup context on ordinary Fast session input', () => { .mockResolvedValueOnce(setupContext) .mockImplementation(async () => { expect(mocks.upsertMessage).toHaveBeenCalledOnce(); - return { ...setupContext, setupSnapshot: freshSnapshot }; + return { + ...setupContext, + setupSnapshot: freshSnapshot, + setupContext: { + ...setupContext.setupContext, + setupSnapshot: freshSnapshot, + }, + }; }); await submitFastSessionUserInputCommand(auth, input, { setupSession: true, @@ -290,6 +314,17 @@ describe('setup context on ordinary Fast session input', () => { }), }), ); + const { persistFastAgentInlineHumanTurn } = + await import('@roomote/sdk/server'); + expect(vi.mocked(persistFastAgentInlineHumanTurn)).toHaveBeenCalledWith({ + parent: expect.objectContaining({ sessionId: session.id }), + event: expect.objectContaining({ + turnSource: 'platform_event', + platformEventKind: 'input_response', + setupSession: true, + setupContext: expect.objectContaining({ setupSnapshot: freshSnapshot }), + }), + }); }); it.each(['documents', 'communication'])( @@ -313,7 +348,14 @@ describe('setup context on ordinary Fast session input', () => { .mockResolvedValueOnce(setupContext) .mockImplementation(async () => { expect(mocks.upsertMessage).toHaveBeenCalledOnce(); - return { ...setupContext, setupSnapshot: skippedSnapshot }; + return { + ...setupContext, + setupSnapshot: skippedSnapshot, + setupContext: { + ...setupContext.setupContext, + setupSnapshot: skippedSnapshot, + }, + }; }); await submitFastSessionUserInputCommand(auth, { ...input, @@ -353,7 +395,7 @@ describe('setup context on ordinary Fast session input', () => { expect(mocks.after).not.toHaveBeenCalled(); }); - it('replays a saved setup category response with a fresh snapshot without persisting twice', async () => { + it('treats a duplicate saved setup category response as successful without scheduling twice', async () => { const saved = { eventId: 'response-event', payload: { @@ -371,13 +413,31 @@ describe('setup context on ordinary Fast session input', () => { mocks.resolveSetupContext.mockResolvedValue({ ...setupContext, setupSnapshot: freshSnapshot, + setupContext: { + ...setupContext.setupContext, + setupSnapshot: freshSnapshot, + }, }); await submitFastSessionUserInputCommand(auth, input); expect(mocks.upsertMessage).not.toHaveBeenCalled(); - expect(await runScheduled()).toMatchObject({ - setupSession: true, - setupSnapshot: freshSnapshot, + expect(mocks.after).not.toHaveBeenCalled(); + }); + + it('does not schedule when another generic response-row claimant won', async () => { + mocks.dbSelectLimit + .mockResolvedValueOnce([request]) + .mockResolvedValueOnce([]); + mocks.upsertMessage.mockResolvedValueOnce({ + initialHumanTurn: false, + inserted: false, }); + + await expect( + submitFastSessionUserInputCommand(auth, input), + ).resolves.toEqual({ success: true }); + + expect(mocks.upsertMessage).toHaveBeenCalledOnce(); + expect(mocks.after).not.toHaveBeenCalled(); }); it('routes final presets through setup-specific persistence, not ordinary response writes', async () => { diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts index f4f83740b2..eb19d8713c 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.ts @@ -6,6 +6,7 @@ import { after } from 'next/server'; import { acquireFastAgentTurnLock, answerFastAgentQuestion, + buildFastAgentSetupAdapter, createFastAgentWebTaskLauncher, FastAgentDurableRetryScheduledError, getOrCreateFastAgentSession, @@ -46,9 +47,10 @@ import { isSetupIntegrationDiscoveryQuestionId, parseAcpRequestUserInputAnswers, parseAcpRequestUserInputPayload, - parseAcpRequestUserInputResponsePayload, + normalizeAcpRequestUserInputAnswers, type AcpRequestUserInputAnswers, type AcpRequestUserInputPayload, + type FastAgentSetupTurnContext, type ReasoningEffort, } from '@roomote/types'; import type { FastAgentTurnAdapter } from '@roomote/cloud-agents/server'; @@ -159,6 +161,7 @@ type WebFastAgentTurnInput = { skipIfTurnCompleted?: { conversationId: string; turnId: string }; setupSnapshot?: string; setupSession?: boolean; + setupContext?: FastAgentSetupTurnContext; adapterExtensions?: Partial; }; @@ -209,6 +212,7 @@ async function runWebFastAgentTurn({ platformEventVisibility, setupSnapshot, setupSession, + setupContext, adapterExtensions, durableSessionId, }: WebFastAgentTurnInput): Promise { @@ -257,11 +261,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: { @@ -282,6 +285,7 @@ async function runWebFastAgentTurn({ } : {}), ...(setupSession ? { setupSession: true } : {}), + ...(setupContext ? { setupContext } : {}), }, }).catch((error) => { console.error( @@ -322,7 +326,9 @@ async function runWebFastAgentTurn({ ...(platformEventVisibility ? { platformEventVisibility } : {}), } : {}), - ...(setupSnapshot ? { setupSnapshot } : {}), + ...(setupContext?.setupSnapshot || setupSnapshot + ? { setupSnapshot: setupContext?.setupSnapshot ?? setupSnapshot } + : {}), setupSession, adapter: { resolveMcpServerConfigs: () => @@ -349,6 +355,7 @@ async function runWebFastAgentTurn({ } : {}), ...delivery.adapter, + ...(setupContext ? buildFastAgentSetupAdapter(setupContext) : {}), ...adapterExtensions, }, }); @@ -768,6 +775,7 @@ export async function submitFastSessionUserInputCommand( adapterExtensions?: Partial; setupSnapshot?: string; setupSession?: boolean; + setupContext?: FastAgentSetupTurnContext; persistSetupPresetResponse?: (input: { fastConversationId: string; request: { @@ -827,7 +835,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.'); @@ -901,21 +913,13 @@ export async function submitFastSessionUserInputCommand( ...(options.setupSnapshot ? { setupSnapshot: options.setupSnapshot } : {}), + ...(options.setupContext ? { setupContext: options.setupContext } : {}), setupSession: options.setupSession ?? false, ...freshSetupContext, }); }; if (existingResponse) { - const persistedResponse = parseAcpRequestUserInputResponsePayload( - existingResponse.payload, - ); - if (!requestPayload.preset && persistedResponse) { - await scheduleResponseTurn( - persistedResponse.answers, - persistedResponse.resolution, - ); - } return { success: true }; } @@ -938,8 +942,9 @@ export async function submitFastSessionUserInputCommand( }); return { success: true }; } - await upsertFastAgentMessage({ + const responseClaim = await upsertFastAgentMessage({ sessionId: session.id, + insertOnly: true, message: { eventId: responseEventId, turnId: request.turnId, @@ -969,7 +974,9 @@ export async function submitFastSessionUserInputCommand( }, }); - await scheduleResponseTurn(submitted, resolution); + if (responseClaim?.inserted !== false) { + await scheduleResponseTurn(submitted, resolution); + } return { success: true }; } diff --git a/apps/web/src/trpc/commands/mcp-connections/index.test.ts b/apps/web/src/trpc/commands/mcp-connections/index.test.ts index 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 index 37fb0c9c62..16945b6dfa 100644 --- a/apps/web/src/trpc/commands/setup/setup-session.test.ts +++ b/apps/web/src/trpc/commands/setup/setup-session.test.ts @@ -19,7 +19,8 @@ vi.mock('@roomote/sdk/server', () => ({ buildFastAgentArtifactCreator: vi.fn(), LINEAR_ORG_CONNECTION_ROLE: 'organization', })); -vi.mock('@roomote/cloud-agents/server', () => ({ +vi.mock('@roomote/cloud-agents/server', async (importOriginal) => ({ + ...(await importOriginal()), createFastAgentWebTaskLauncher: vi.fn(), })); vi.mock('@roomote/telemetry/server', () => ({ captureEvent: vi.fn() })); @@ -126,7 +127,7 @@ describe('optional setup integration discovery', () => { }, }); } - async function continueDiscovery() { + async function continueDiscovery(answer = 'continue') { const questions = await ( await context() ).adapterExtensions.resolveUserInputPreset!('setup_integrations'); @@ -150,7 +151,7 @@ describe('optional setup integration discovery', () => { return submitSetupSessionUserInputCommand(auth, { sessionId, requestId: 'integrations', - answers: { 'setup-integrations': { answers: ['Continue'] } }, + answers: { 'setup-integrations': { answers: [answer] } }, }); } @@ -212,7 +213,7 @@ describe('optional setup integration discovery', () => { await db.delete(users).where(eq(users.id, auth.userId)); }); - it('continues without any connector or source connection and persists completion', async () => { + it('completes zero-match discovery server-side without a browser response', async () => { mocks.getStatus.mockImplementation(async () => ({ setupNewState: await readState(), setupCompletedAt: null, @@ -223,10 +224,7 @@ describe('optional setup integration discovery', () => { const questions = await ( await context() ).adapterExtensions.resolveUserInputPreset!('setup_integrations'); - expect(questions[0]?.options?.map((option) => option.id)).toEqual([ - 'continue', - ]); - await expect(continueDiscovery()).resolves.toEqual({ success: true }); + expect(questions).toEqual([]); expect( (await readState()).setupSession?.integrationDiscoveryCompletedAt, ).toEqual(expect.any(String)); @@ -239,11 +237,7 @@ describe('optional setup integration discovery', () => { .select() .from(fastAgentMessages) .where(eq(fastAgentMessages.eventId, 'event:integrations:response')); - expect(responses).toHaveLength(1); - expect(responses[0]?.payload).toMatchObject({ - resolution: 'submitted', - answers: { 'setup-integrations': { answers: ['Continue'] } }, - }); + expect(responses).toHaveLength(0); await expect( (await context()).adapterExtensions.resolveUserInputPreset!( 'setup_integrations', @@ -251,7 +245,7 @@ describe('optional setup integration discovery', () => { ).rejects.toThrow('already complete'); }); - it('persists cancellation as an early skip while leaving final continuation optional', async () => { + it('persists cancellation as an early skip and completes an empty final match server-side', async () => { await answeredCategory('communication', [], 'cancelled'); const snapshot = JSON.parse( (await context()).setupSnapshot, @@ -262,14 +256,11 @@ describe('optional setup integration discovery', () => { matchedIntegrationIds: [], }); await reconcileSetupPlatformEvents(auth); - expect(mocks.schedule).not.toHaveBeenCalled(); + expect(mocks.schedule).toHaveBeenCalledOnce(); const questions = await ( await context() ).adapterExtensions.resolveUserInputPreset!('setup_integrations'); - expect(questions[0]?.options?.map((option) => option.id)).toEqual([ - 'continue', - ]); - await continueDiscovery(); + expect(questions).toEqual([]); expect( JSON.parse((await context()).setupSnapshot).integrationDiscovery .completed, @@ -321,6 +312,16 @@ describe('optional setup integration discovery', () => { }); }); + it('accepts the legacy continuation label for an existing setup card', async () => { + await answeredCategory('documents', ['Notion']); + await expect(continueDiscovery('Continue')).resolves.toEqual({ + success: true, + }); + expect( + (await readState()).setupSession?.integrationDiscoveryCompletedAt, + ).toEqual(expect.any(String)); + }); + it('resumes persisted category answers and exactly matches catalog options in homepage order', async () => { await answeredCategory('communication', ['Discord', 'slack']); await answeredCategory('monitoring', ['Grafana', 'Sentry', 'Datadog']); @@ -402,7 +403,7 @@ describe('optional setup integration discovery', () => { ]); }); - it('suppresses async setup events and starter choices during discovery without gating setup completion', async () => { + it('coalesces setup changes into one deterministic turn without discovery-first dropping', async () => { expect(await reconcileSetupPlatformEvents(auth)).toBe(true); expect(mocks.complete).toHaveBeenCalled(); expect( @@ -410,7 +411,15 @@ describe('optional setup integration discovery', () => { ([turn]) => JSON.parse(turn.question.replace(/<\/?platform_event>/g, '')).type, ), - ).toEqual(['session_creation']); + ).toEqual(['setup_state_changed']); + expect( + JSON.parse( + mocks.schedule.mock.calls[0]![0].question.replace( + /<\/?platform_event>/g, + '', + ), + ).changes.map((change: { type: string }) => change.type), + ).toEqual(['session_creation', 'source_connection', 'starter_request']); await answeredCategory('documents', ['Notion']); mocks.schedule.mockClear(); await reconcileSetupPlatformEvents(auth); @@ -427,14 +436,13 @@ describe('optional setup integration discovery', () => { fingerprint: 'test', payload: {}, }), - ).toEqual({ scheduled: false }); + ).toEqual({ scheduled: true }); } - expect(mocks.schedule).not.toHaveBeenCalled(); - await expect( - (await context()).adapterExtensions.resolveUserInputPreset!( - 'setup_starter_tasks', - ), - ).rejects.toThrow('optional tool discovery'); + expect(mocks.schedule).toHaveBeenCalledTimes(6); + const starterQuestions = await ( + await context() + ).adapterExtensions.resolveUserInputPreset!('setup_starter_tasks'); + expect(starterQuestions).toHaveLength(1); await continueDiscovery(); expect( mocks.schedule.mock.calls.some(([turn]) => @@ -446,6 +454,7 @@ describe('optional setup integration discovery', () => { ).adapterExtensions.resolveUserInputPreset!('setup_starter_tasks'); expect(questions[0]?.options).toEqual( SETUP_STARTER_TASKS.map((task) => ({ + id: task.id, label: task.title, description: task.description, })), diff --git a/apps/web/src/trpc/commands/setup/setup-session.ts b/apps/web/src/trpc/commands/setup/setup-session.ts index a93156a476..cf86a48fcb 100644 --- a/apps/web/src/trpc/commands/setup/setup-session.ts +++ b/apps/web/src/trpc/commands/setup/setup-session.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; -import { type FastAgentTurnAdapter } from '@roomote/cloud-agents/server'; import { buildFastAgentArtifactCreator } from '@roomote/sdk/server'; +import { buildFastAgentSetupAdapter } from '@roomote/cloud-agents/server'; import { and, db, @@ -34,6 +34,7 @@ import { type AcpRequestUserInputAnswers, type AcpRequestUserInputPayload, type AutomationRecommendationBatch, + type FastAgentSetupTurnContext, } from '@roomote/types'; import { captureEvent } from '@roomote/telemetry/server'; @@ -60,6 +61,7 @@ const SETUP_SESSION_ADVISORY_LOCK = 'setup-session'; const SETUP_SESSION_TITLE = 'Set up Roomote'; type SetupPlatformEventKind = + | 'setup_state_changed' | 'session_creation' | 'provider_selection' | 'source_connection' @@ -98,14 +100,6 @@ async function assertSetupStarterWorkReady( const setupSession = normalizeSetupNewSetupSession( status.setupNewState.setupSession, ); - if ( - setupSession?.integrationDiscoveryCompletedAt === null && - !setupSession.starterTaskSelection - ) { - throw new Error( - 'Finish or skip the optional tool discovery before choosing first work. No connections are required.', - ); - } if (options.requireStarterSelection && !setupSession?.starterTaskSelection) { throw new Error('Choose your first work before starting a task.'); } @@ -263,6 +257,22 @@ async function resolveSetupSnapshot(auth: UserAuthSuccess): Promise { }); } +function buildSetupTurnContext( + conversation: SetupSessionConversation, + setupSnapshot: string, +): FastAgentSetupTurnContext { + return { + sessionId: conversation.sessionId, + fastConversationId: conversation.fastConversationId, + setupSnapshot, + starterTaskOptions: SETUP_STARTER_TASKS.map((task) => ({ + id: task.id, + label: task.title, + description: task.description, + })), + }; +} + async function readSetupIntegrationDiscovery( auth: UserAuthSuccess, suppliedAnswers: AcpRequestUserInputAnswers = {}, @@ -424,69 +434,6 @@ function deriveSetupRailMilestones( }; } -async function buildSetupSessionAdapterExtensions( - auth: UserAuthSuccess, -): Promise> { - return { - resolveUserInputPreset: async (preset, setupIntegrationAnswers) => { - assertAdmin(auth); - if (!(await findSetupSessionConversation(auth))) - throw new Error('This request does not belong to the setup Session.'); - if (preset === 'setup_integrations') { - const discovery = await readSetupIntegrationDiscovery( - auth, - setupIntegrationAnswers, - ); - if (discovery.completed) - throw new Error('Optional tool discovery is already complete.'); - return [ - { - id: SETUP_INTEGRATIONS_QUESTION_ID, - header: 'Your tools', - question: - 'Connect any useful tools, or continue without connections.', - isOther: false, - isSecret: false, - options: [ - ...SETUP_INTEGRATIONS.filter((integration) => - discovery.matchedIntegrationIds.includes(integration.id), - ).map((integration) => ({ - id: integration.id, - label: integration.name, - description: `Connect ${integration.name} in Settings.`, - })), - SETUP_INTEGRATIONS_CONTINUE_OPTION, - ], - }, - ]; - } - if (preset !== 'setup_starter_tasks') { - throw new Error('Unsupported setup input preset.'); - } - await assertSetupStarterWorkReady(auth); - return [ - { - id: 'setup-starter-tasks', - header: 'First work', - question: 'What should Roomote work on first?', - isOther: false, - isSecret: false, - multiple: true, - options: SETUP_STARTER_TASKS.map((task) => ({ - label: task.title, - description: task.description, - })), - }, - ]; - }, - assertTaskLaunch: () => - assertSetupStarterWorkReady(auth, { - requireStarterSelection: true, - requireCompute: true, - }), - }; -} - export async function scheduleSetupPlatformEvent( auth: UserAuthSuccess, input: { @@ -511,9 +458,6 @@ async function buildSetupPlatformEventTurn( prepared?: { conversation: SetupSessionConversation; setupSnapshot: string; - integrationDiscovery: Awaited< - ReturnType - >; }, ): Promise[0] | null> { assertAdmin(auth); @@ -521,15 +465,6 @@ async function buildSetupPlatformEventTurn( prepared?.conversation ?? (await findSetupSessionConversation(auth)); if (!conversation) return null; - const integrationDiscovery = - prepared?.integrationDiscovery ?? - (await readSetupIntegrationDiscovery(auth)); - if ( - !integrationDiscovery.completed && - (input.kind !== 'session_creation' || integrationDiscovery.hasInputRequest) - ) - return null; - const currentMessageId = buildSetupEventTurnId({ sessionId: conversation.sessionId, workflowVersion: conversation.workflowVersion, @@ -568,10 +503,12 @@ async function buildSetupPlatformEventTurn( conversationId: conversation.fastConversationId, turnId: currentMessageId, }, - adapterExtensions: await buildSetupSessionAdapterExtensions(auth), setupSession: true, - setupSnapshot: + setupContext: buildSetupTurnContext( + conversation, prepared?.setupSnapshot ?? (await resolveSetupSnapshot(auth)), + ), + durableSessionId: conversation.fastConversationId, }; } @@ -786,14 +723,27 @@ export async function reconcileSetupPlatformEvents( { allowAfterSetupCompletion: true }, ); - for (const event of events) { - const turn = await buildSetupPlatformEventTurn(auth, event, { - conversation, - setupSnapshot, - integrationDiscovery, - }); - if (turn) scheduleWebFastAgentTurn(turn); - } + const changes = events.map((event) => ({ + type: event.kind, + ...event.payload, + })); + const fingerprint = createHash('sha256') + .update(JSON.stringify({ setupSnapshot, changes })) + .digest('hex') + .slice(0, 24); + const turn = await buildSetupPlatformEventTurn( + auth, + { + kind: 'setup_state_changed', + fingerprint, + payload: { + snapshot: JSON.parse(setupSnapshot), + changes, + }, + }, + { conversation, setupSnapshot }, + ); + if (turn) scheduleWebFastAgentTurn(turn); return setupCompleted; } @@ -980,8 +930,12 @@ async function persistSetupPresetResponse(input: { await assertSetupStarterWorkReady(input.auth); else if ( input.answers[SETUP_INTEGRATIONS_QUESTION_ID]?.answers.length !== 1 || - input.answers[SETUP_INTEGRATIONS_QUESTION_ID]?.answers[0] !== - SETUP_INTEGRATIONS_CONTINUE_OPTION.label + !( + [ + SETUP_INTEGRATIONS_CONTINUE_OPTION.id, + SETUP_INTEGRATIONS_CONTINUE_OPTION.label, + ] as readonly string[] + ).includes(input.answers[SETUP_INTEGRATIONS_QUESTION_ID]!.answers[0]!) ) { throw new Error('Continue with or without connecting tools.'); } @@ -1141,9 +1095,9 @@ export async function submitSetupSessionUserInputCommand( ) { throw new Error('This input request does not belong to the setup Session.'); } + const setupSnapshot = await resolveSetupSnapshot(auth); return submitFastSessionUserInputCommand(auth, input, { - adapterExtensions: await buildSetupSessionAdapterExtensions(auth), - setupSnapshot: await resolveSetupSnapshot(auth), + setupContext: buildSetupTurnContext(setupConversation, setupSnapshot), setupSession: true, persistSetupPresetResponse: async (details) => { const result = await persistSetupPresetResponse({ auth, ...details }); @@ -1166,9 +1120,12 @@ export async function resolveSetupSessionTurnContext( ) return null; assertAdmin(auth); + const setupSnapshot = await resolveSetupSnapshot(auth); + const setupContext = buildSetupTurnContext(conversation, setupSnapshot); return { - adapterExtensions: await buildSetupSessionAdapterExtensions(auth), - setupSnapshot: await resolveSetupSnapshot(auth), + adapterExtensions: buildFastAgentSetupAdapter(setupContext), + setupSnapshot, + setupContext, setupSession: true as const, }; } diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 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-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index ae42a78180..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 @@ -1165,6 +1165,49 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }, ); + it('closes a server-completed setup preset without persisting a pending request', async () => { + let toolResult: unknown; + const requestUserInput = vi.fn(); + const resolveUserInputPreset = vi.fn(async () => []); + mocks.generateText.mockImplementation( + async (_params, _session, options) => { + await options.onSessionReady('opencode-session-1'); + toolResult = await invokeTool(nativeToolNames.requestUserInput, { + preset: 'setup_integrations', + }); + return ''; + }, + ); + + await answerFastAgentQuestion({ + ...baseParams, + conversation: { + surface: 'web', + workspaceId: 'deployment-1', + conversationId: 'setup-session-1', + }, + turnSource: 'platform_event', + platformEventKind: 'setup', + platformEventVisibility: 'required', + setupSession: true, + adapter: callbacks({ requestUserInput, resolveUserInputPreset }), + }); + + expect(toolResult).toEqual({ + success: true, + completed: true, + closed: true, + }); + expect(requestUserInput).not.toHaveBeenCalled(); + expect(mocks.upsertMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.objectContaining({ + eventType: 'roomote_runtime.request_user_input', + }), + }), + ); + }); + it.each(['setup_starter_tasks', undefined])( 'rejects integration preferences outside their preset: %s', async (preset) => { diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-session.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-session.test.ts index f386887697..002bc83de2 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-session.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-session.test.ts @@ -33,7 +33,7 @@ describe('upsertFastAgentMessage', () => { await expect( upsertFastAgentMessage({ sessionId: 'session-1', message }), - ).resolves.toEqual({ initialHumanTurn: true }); + ).resolves.toMatchObject({ initialHumanTurn: true }); expect(upsertMessageMock).toHaveBeenCalledTimes(2); }); diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts index 00c8e12773..e5f596d83f 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts @@ -67,6 +67,8 @@ export type FastAgentMessageWrite = Omit< export type FastAgentMessageUpsertResult = { initialHumanTurn: boolean; + /** True only for the transaction that created this canonical event row. */ + inserted?: boolean; }; export const INTERRUPTED_INFERENCE_RETRY_MESSAGE = @@ -840,6 +842,7 @@ export interface FastAgentConversationRepository { upsertMessage(input: { conversationId: string; message: FastAgentMessageWrite; + insertOnly?: boolean; }): Promise; /** `null` forgets the native session so the next turn rebuilds it. */ setOpenCodeSession(input: { @@ -1236,7 +1239,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = }); }, - async upsertMessage({ conversationId: requestedId, message }) { + async upsertMessage({ conversationId: requestedId, message, insertOnly }) { return db.transaction(async (tx) => { const conversationId = await resolveCanonicalId(tx, requestedId); await tx.execute( @@ -1254,6 +1257,17 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = throw new Error('Fast conversation was not found.'); } + const [existingEvent] = await tx + .select({ id: fastAgentMessages.id }) + .from(fastAgentMessages) + .where( + and( + eq(fastAgentMessages.conversationId, conversationId), + eq(fastAgentMessages.eventId, message.eventId), + ), + ) + .limit(1); + const isSubstantiveHumanPrompt = message.eventType === ACP_ENVELOPE_EVENT_TYPES.UserPrompt && message.role === 'user' && @@ -1307,10 +1321,18 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = (Boolean(currentHumanPrompt) || !hasCompatibilityHumanPrompt); } - await tx + const insert = tx .insert(fastAgentMessages) - .values({ conversationId, ...message }) - .onConflictDoUpdate({ + .values({ conversationId, ...message }); + if (insertOnly) { + await insert.onConflictDoNothing({ + target: [ + fastAgentMessages.conversationId, + fastAgentMessages.eventId, + ], + }); + } else { + await insert.onConflictDoUpdate({ target: [ fastAgentMessages.conversationId, fastAgentMessages.eventId, @@ -1330,6 +1352,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = updatedAt: sql`now()`, }, }); + } await tx .update(fastAgentConversations) .set({ updatedAt: sql`now()` }) @@ -1371,7 +1394,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = } } - return { initialHumanTurn }; + return { initialHumanTurn, inserted: !existingEvent }; }); }, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts index 9f0245c228..8c7f3d61bd 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts @@ -162,6 +162,8 @@ export type FastAgentMcpServerConfig = { url: string; headers: Record; disabledTools?: string[]; + /** Opaque, non-secret revision used to invalidate process-local tool catalogs. */ + cacheRevision?: string; }; /** Structured input request issued with the Fast-native request_user_input tool. */ diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-integration-broker.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-integration-broker.ts index eee25ffca8..ba2ed12318 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-integration-broker.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-integration-broker.ts @@ -501,7 +501,7 @@ export async function listFastAgentIntegrations( ...integration, tools: ( await listCachedIntegrationTools({ - cacheKey: `${context.userId}:${integration.endpoint!.url}`, + cacheKey: `${context.userId}:${integration.endpoint!.url}:${configuredServers[integration.id]?.cacheRevision ?? ''}`, url: integration.endpoint!.url, headers: integration.endpoint!.headers, }) diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index 949528fee7..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,18 +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 is out of scope. Optional integration discovery is separate from source-control, communication, inference, and sandbox provider setup; those existing provider flows are unaffected. Use the server's eligible connector catalog, not a broader provider or authentication exclusion. Vercel's deployments connector remains eligible and is distinct from Vercel AI Gateway inference. Do not ask provider-configuration questions in this optional discovery. -- The renderer owns presentation of trusted setup controls, but some controls require an explicit tool call from you. Keep those controls separate from my side of the conversation. In user-visible prose, state only the user's goal, the capability I need, the outcome that changed, or the decision the user needs to make. Never name, locate, or instruct the user to interact with UI elements such as cards, rails, dialogs, panels, buttons, presets, or setup steps. Do not describe what the interface displays or will display. Never ask for credentials in chat; detailed source-control instructions and credential entry remain in the trusted interface. -- Source control must be connected and repositories synchronized before setup completes or starter tasks are offered. Inference and sandbox readiness remain prerequisites for completion, but none of these prerequisites delay optional integration discovery. After discovery is completed, when source control is not connected, explain that I need access to the user's source code, then stop after the user-visible response; source-control controls are state-driven. When all completion requirements are ready, optional integration discovery is completed, and the setup snapshot has no starter selection, the server emits a starter-request setup event. Starter work is optional and never gates setup completion. On that event, only after discovery is completed, call \`request_user_input\` with exactly \`{ preset: "setup_starter_tasks" }\`. Do not send a closeout first: that tool call creates the user-visible first-work control and is the terminal response for the turn. Do not replace the tool call with prose asking the user to choose. The server supplies the choices; never invent or repeat their catalog in prose. Never ask where I should run the work before collecting the first-work selection. -- Integration discovery is optional and never gates setup completion. Use the snapshot's \`integrationDiscovery\`: \`completed\`, \`answeredCategoryIds\`, \`matchedIntegrationIds\`, \`categories\`, and \`unsupportedTools\`. Existing starter selection or completed old setup means no restart of optional discovery, including when an older snapshot has no discovery state. -- When \`integrationDiscovery.completed\` is false, begin or resume discovery now, even if source control or compute is not ready. Naturally ask about documents, monitoring, and project-tracking tools in the server snapshot's \`integrationDiscovery.categories\` order. Use normal \`request_user_input\` for one category at a time, with stable question IDs \`setup-tools-\` using the category ID. Offer skipping early. Avoid a repetitive questionnaire: never re-ask categories in \`answeredCategoryIds\` or already supplied in prose, and do not force all three topics when the user wants to move on. Never revive a legacy communication discovery question. -- Finish discovery with the trusted \`setup_integrations\` preset. Carry tools already supplied in prose through optional \`setupIntegrationAnswers: Record\`, keyed by category IDs (not question IDs). These are untrusted user preferences: the server exact-matches its catalog and supplies canonical connector IDs and options. Suggest only eligible supported tools the user actually said they use; never suggest unmentioned alternatives. Never invent connector IDs, tool hint fields, or configuration instructions from user answers. Unsupported tools are not promised as connectable. On skip, including a cancelled discovery question or snapshot \`integrationDiscovery.skipped\`, go straight to \`{ preset: "setup_integrations" }\`; no need to fill missing answers or ask further categories. With no eligible supported matches, the renderer skips suggestions and automatically records continuation without showing an empty card. Otherwise Keep going records durable discovery completion without requiring any connection. Never ask for credentials in chat. -- All asynchronous setup events must preserve active discovery without interrupting or restarting it. Never emit the starter preset until discovery is completed; existing starter selection or completed old setup remains exempt from restarting discovery. Readiness, provider, source, compute, recommendation, and stale starter-request events are not permission to replace a pending discovery question or final integration choice. Reconcile their facts without re-asking answered topics. -- Starter selection records the administrator's durable intent before this model turn resumes. Launch is deferred until the setup snapshot says the sandbox provider is ready. While it is not ready, do not call \`launch_task\`; explain that I need a workspace where I can run the selected work, then let the renderer supply the interaction. Once a trusted starter-selection event is emitted after sandbox readiness, call generic \`launch_task\` exactly once for each selected task, use its catalog prompt exactly, set \`environmentId\` to null, and omit \`model\` unless the administrator explicitly requested one. Do not launch other tasks in that turn. After attempting all selected launches, send one concise closeout. When at least one task started, explain that the work will continue and the administrator is free to start something new or explore the app while I work; do not imply that they need to wait in or remain on the setup session. -- Partial launch failure never reverses setup completion. Name failed launches and continue with successful work. Mention automation recommendations only after the snapshot says at least one selected task launched successfully and the recommendation batch is ready. +- A useful default agenda is: understand the user's goals and optional tools, connect and synchronize source code, make a sandbox ready, then offer optional starter work. Follow the conversation: the user may skip optional discovery, answer several topics at once, or reorder the agenda. Do not restart answered discovery categories or revive the legacy communication question. +- Optional integration discovery never gates setup completion. Use the snapshot's ordered categories as suggestions, not a questionnaire. Ask naturally, offer an early skip, and use stable question IDs \`setup-tools-\` for structured category questions. Finish or skip with the trusted \`setup_integrations\` preset, carrying prose answers by category ID. The server validates matches, canonicalizes options, and completes an empty match set without browser input. +- Source control and a synchronized repository are required before setup completes or starter work is offered. A ready sandbox is required before selected work launches. State the missing capability plainly and let trusted setup controls handle configuration. Environment creation is out of scope. +- When the snapshot has no starter selection and the current setup state makes starter work available, use the trusted \`setup_starter_tasks\` preset. The server owns its choices and validation; do not invent or repeat the catalog in prose. Starter work is optional and never gates setup completion. +- A recorded starter selection is durable intent. When the current setup-state change includes selected starter tasks and the snapshot says the sandbox is ready, launch those catalog prompts with generic \`launch_task\`, no environment, and no model override unless the administrator requested one. Partial launch failure never reverses setup completion; name failures and continue with successful work. +- Setup state-change events are coalesced current facts, not a fixed script. Reconcile the snapshot and listed changes, preserve any pending user decision, and continue with whichever useful setup action fits the conversation. +- The renderer owns trusted controls. In prose, state only the user's goal, the capability I need, the outcome that changed, or the decision the user needs to make. Never name or locate cards, rails, dialogs, panels, buttons, presets, or setup steps. Never ask for credentials in chat. - In the setup session, always refer to Roomote in the first person: use "I", "me", and "my" in user-visible messages. Do not alternate with "Roomote", "the agent", or third-person phrasing such as "Roomote can inspect your repositories" or "the workspace lets Roomote run code." Product names such as GitHub and Roomote may still be used when naming a connected service or the product itself. - In every user-visible setup reply, use ordinary language centered on the user's action and outcome. Say "Your repositories are ready" rather than "repositories synced"; say "Choose what you'd like me to work on first" rather than "choose the first work from the setup options"; and say "I need a workspace where I can run the work you selected" rather than "configure the sandbox provider." Explain what a sandbox means once only if that context helps the user understand why I need it, without referring to the interface. -- Before \`launch_task\`, describe the work beginning in the user's terms. Do not expose repository-selection heuristics such as "most impactful repository" or narrate setup machinery. For example, say "I'm looking for flaky tests and fixing the ones causing the most trouble." +- Describe launched work in the user's terms. Do not expose repository-selection heuristics or narrate setup machinery. ` : '' } @@ -477,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" }\` only after integration discovery is completed (or existing starter selection/completed old setup exempts discovery), then stop. Otherwise preserve discovery without interrupting or restarting it. Do not replace the tool call with prose asking the user to choose. -- For a starter-tasks-selected event, launch each canonical task definition exactly once with "launch_task": use its prompt verbatim, null for environmentId, and no model unless explicitly requested. The event is emitted only after the sandbox readiness fact is true; if the trusted snapshot disagrees, do not launch and report the configuration blocker. After all launch attempts, post one concise closeout. If any selected task started, say that the started work will continue while the user starts something new or explores the app. The persisted selection is authoritative and setup is already complete; launch failures do not reverse it. -- For provider, source, compute, or recommendation events, use the supplied trusted facts and snapshot without claiming that I made configuration changes myself. + ? `- The setup-state-changed event contains the current snapshot and coalesced changes. Use those trusted facts without claiming that I made configuration changes myself. On the first useful turn, introduce myself and explain the next unmet user need in ordinary language. ` : '' } diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index f90e70e291..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 @@ -4532,6 +4532,13 @@ export async function answerFastAgentQuestion({ args.setupIntegrationAnswers, ) : await adapter.resolveUserInputPreset!(args.preset); + // Trusted setup presets may complete entirely server-side. In + // that case no pending request or browser response is needed. + if (preset && questions.length === 0) { + visibleUpdatePosted = true; + closedInstructionVersions.add(instructionVersion); + return { success: true, completed: true, closed: true }; + } for (const question of questions) { if (question.options && question.isSecret) { return { diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts index 6c9a06d24b..9f2de331d7 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts @@ -148,9 +148,11 @@ export async function appendFastAgentVisibleMessages({ export async function upsertFastAgentMessage({ sessionId, message, + insertOnly, }: { sessionId: string; message: FastAgentMessageWrite; + insertOnly?: boolean; }): Promise { let lastError: unknown; @@ -159,6 +161,7 @@ export async function upsertFastAgentMessage({ return await fastAgentConversationRepository.upsertMessage({ conversationId: sessionId, message, + insertOnly, }); } catch (error) { lastError = error; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts new file mode 100644 index 0000000000..3db4c36ed5 --- /dev/null +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-context.ts @@ -0,0 +1,157 @@ +import { db, deploymentSettings, eq, sessions, sql } from '@roomote/db/server'; +import { + normalizeSetupNewState, + normalizeSetupNewSetupSession, + SETUP_INTEGRATIONS, + SETUP_INTEGRATIONS_CONTINUE_OPTION, + SETUP_INTEGRATIONS_QUESTION_ID, + matchSetupIntegrationAnswers, + type FastAgentSetupTurnContext, +} from '@roomote/types'; + +import type { FastAgentTurnAdapter } from './fast-agent-conversation'; + +type SetupSnapshot = { + integrationDiscovery?: { + completed?: boolean; + matchedIntegrationIds?: string[]; + }; + rail?: { + compute?: string; + source?: string; + firstWork?: string; + }; +}; + +function parseSetupSnapshot(context: FastAgentSetupTurnContext): SetupSnapshot { + try { + return JSON.parse(context.setupSnapshot) as SetupSnapshot; + } catch { + throw new Error('The setup snapshot is invalid.'); + } +} + +async function completeEmptySetupIntegrationDiscovery( + context: FastAgentSetupTurnContext, +): Promise { + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext('setup-session'))`, + ); + const [settings] = await tx + .select({ setupNewState: deploymentSettings.setupNewState }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .limit(1); + const state = normalizeSetupNewState(settings?.setupNewState ?? {}); + const setupSession = normalizeSetupNewSetupSession(state.setupSession); + const [session] = setupSession + ? await tx + .select({ fastConversationId: sessions.fastConversationId }) + .from(sessions) + .where(eq(sessions.id, setupSession.sessionId)) + .limit(1) + : []; + if ( + !setupSession || + setupSession.sessionId !== context.sessionId || + session?.fastConversationId !== context.fastConversationId + ) { + throw new Error('This request does not belong to the setup Session.'); + } + // Missing means a legacy session that predates discovery and is already complete. + if (setupSession.integrationDiscoveryCompletedAt !== null) return; + await tx + .update(deploymentSettings) + .set({ + setupNewState: { + ...state, + setupSession: { + ...setupSession, + integrationDiscoveryCompletedAt: new Date().toISOString(), + }, + }, + updatedAt: new Date(), + }) + .where(eq(deploymentSettings.id, 'default')); + }); +} + +/** Rebuild trusted setup-only adapter behavior from durable, serializable data. */ +export function buildFastAgentSetupAdapter( + context: FastAgentSetupTurnContext, +): Pick { + return { + resolveUserInputPreset: async (preset, setupIntegrationAnswers) => { + const snapshot = parseSetupSnapshot(context); + if (preset === 'setup_integrations') { + if (snapshot.integrationDiscovery?.completed) { + throw new Error('Optional tool discovery is already complete.'); + } + const suppliedMatches = matchSetupIntegrationAnswers( + setupIntegrationAnswers ?? {}, + ).matchedIntegrationIds; + const matchedIds = new Set([ + ...(snapshot.integrationDiscovery?.matchedIntegrationIds ?? []), + ...suppliedMatches, + ]); + const options = SETUP_INTEGRATIONS.filter((integration) => + matchedIds.has(integration.id), + ).map((integration) => ({ + id: integration.id, + label: integration.name, + description: `Connect ${integration.name} in Settings.`, + })); + if (options.length === 0) { + await completeEmptySetupIntegrationDiscovery(context); + return []; + } + return [ + { + id: SETUP_INTEGRATIONS_QUESTION_ID, + header: 'Your tools', + question: + 'Connect any useful tools, or continue without connections.', + isOther: false, + isSecret: false, + options: [...options, SETUP_INTEGRATIONS_CONTINUE_OPTION], + }, + ]; + } + if (preset !== 'setup_starter_tasks') { + throw new Error('Unsupported setup input preset.'); + } + const rail = snapshot.rail; + if (rail?.source !== 'ready') { + throw new Error( + 'Connect source control and sync at least one repository before choosing or starting work.', + ); + } + return [ + { + id: 'setup-starter-tasks', + header: 'First work', + question: 'What should Roomote work on first?', + isOther: false, + isSecret: false, + multiple: true, + options: context.starterTaskOptions, + }, + ]; + }, + assertTaskLaunch: async () => { + const rail = parseSetupSnapshot(context).rail; + if (rail?.source !== 'ready') { + throw new Error( + 'Connect source control and sync at least one repository before choosing or starting work.', + ); + } + if (rail.firstWork !== 'ready') { + throw new Error('Choose your first work before starting a task.'); + } + if (rail.compute !== 'ready') { + throw new Error('Set up a sandbox before starting work.'); + } + }, + }; +} diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-setup-tools.test.ts index cab70a011a..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,33 +108,20 @@ describe('setup prompt guidance and snapshot injection', () => { expect(prompt).not.toContain('update_plan'); }); - it('keeps discovery optional, ordered, resumable, and server-resolved', () => { + it('keeps discovery optional, resumable, reorderable, and server-resolved', () => { const prompt = buildFastAgentSystemPrompt({ ...baseInput, setupSession: true, }); for (const rule of [ - 'Integration discovery is optional and never gates setup completion', - 'documents, monitoring, and project-tracking', - 'those existing provider flows are unaffected', - 'Do not ask provider-configuration questions in this optional discovery', - 'integrationDiscovery.categories', + 'Optional integration discovery never gates setup completion', + 'ordered categories as suggestions, not a questionnaire', + 'reorder the agenda', 'setup-tools-', - 'Offer skipping early', - 'already supplied in prose', - 'setupIntegrationAnswers', - 'keyed by category IDs (not question IDs)', - 'server exact-matches its catalog', - 'Keep going records durable discovery completion', - 'Suggest only eligible supported tools the user actually said they use', - 'without showing an empty card', - 'no need to fill missing answers', - 'All asynchronous setup events must preserve active discovery', - 'Never emit the starter preset until discovery is completed', - 'Existing starter selection or completed old setup means no restart', - 'answeredCategoryIds', - 'matchedIntegrationIds', - 'unsupportedTools', + 'carrying prose answers by category ID', + 'completes an empty match set without browser input', + 'Do not restart answered discovery categories', + 'Setup state-change events are coalesced current facts', ]) expect(prompt).toContain(rule); expect(prompt).not.toContain('Naturally ask about communication'); @@ -179,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.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..d1da477acc 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, @@ -2462,6 +2463,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 +2526,9 @@ export async function deliverFastAgentParentEventWithLock( createArtifact: buildFastAgentArtifactCreator(params.parent.sessionId), ...parentTurn.adapter, launchTask: parentTurn.adapter.launchTask, + ...(humanFollowUp?.setupContext + ? buildFastAgentSetupAdapter(humanFollowUp.setupContext) + : {}), ...(wakeupGuard ? { postReply: wakeupGuard.guardPostReply( diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index 7f8828dee1..28b7259f19 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -210,6 +210,7 @@ function buildJoinedConnectionRow({ return { enabledMcpId: mcpId, disabledTools, + enablementUpdatedAt: new Date('2026-03-13T00:00:00.000Z'), connection: { id, userId, @@ -217,6 +218,7 @@ function buildJoinedConnectionRow({ enabled: true, authConfig: resolvedAuthConfig, createdAt: new Date('2026-03-12T00:00:00.000Z'), + updatedAt: new Date('2026-03-12T00:00:00.000Z'), }, }; } @@ -224,6 +226,7 @@ function buildJoinedConnectionRow({ function buildEnabledOnlyRow(mcpId: string) { return { enabledMcpId: mcpId, + enablementUpdatedAt: new Date('2026-03-13T00:00:00.000Z'), connection: null, }; } @@ -282,6 +285,15 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { expect(result.servers.notion?.disabledTools).toEqual(['search']); }); + it('includes a non-secret cache revision for Fast server resolution', async () => { + const result = await resolveUserMcpServerConfigs({ + userId: 'owner-user', + apiBaseUrl: 'https://api.preview.roomote.run', + }); + + expect(result.notion?.cacheRevision).toBe('1773360000000:1773273600000'); + }); + it('delivers the Brain when an explicit Brain provider key is configured', async () => { mockEnv.R_GBRAIN_URL = 'http://gbrain:8931'; mockIsBrainEnabled.mockResolvedValue(true); diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts index 2de03b3855..82b40a2aa7 100644 --- a/packages/sdk/src/server/routers/mcp-connections.ts +++ b/packages/sdk/src/server/routers/mcp-connections.ts @@ -68,6 +68,7 @@ type ResolvedMcpServerConfig = { url: string; headers: Record; disabledTools?: string[]; + cacheRevision?: string; }; type ResolvedMcpServerConfigs = Record; @@ -95,6 +96,7 @@ async function resolveMcpServerConfigs(options: { auth: Parameters[0]; requestOrigin: string | null; includeRoomoteMemberTools?: boolean; + includeCacheRevision?: boolean; quiet?: boolean; }): Promise { const logInfo: InfoLogger = options.quiet ? () => {} : console.info; @@ -136,6 +138,12 @@ async function resolveMcpServerConfigs(options: { }; } + if (!options.includeCacheRevision) { + for (const server of Object.values(servers)) { + delete server.cacheRevision; + } + } + logInfo('[getMcpServerConfigs] Final resolved server keys:', [ ...Object.keys(servers), ]); @@ -152,6 +160,7 @@ export async function resolveUserMcpServerConfigs(options: { auth: { userId: options.userId }, requestOrigin: getRequestOrigin({ url: options.apiBaseUrl }), includeRoomoteMemberTools: options.includeRoomoteMemberTools, + includeCacheRevision: true, // This runs on every Fast turn; the per-connection info stream is worker // config-fetch debugging noise at that frequency. quiet: true, @@ -323,13 +332,14 @@ async function buildCustomMcpServerConfigs( continue; } + let connectionUpdatedAt: Date | undefined; if (row.authType === 'oauth') { const connection = await db.query.mcpConnections.findFirst({ where: and( eq(mcpConnections.mcpId, customMcpConnectionId(row.id)), isNull(mcpConnections.userId), ), - columns: { authStatus: true }, + columns: { authStatus: true, updatedAt: true }, }); if (connection?.authStatus !== 'authenticated') { @@ -338,6 +348,7 @@ async function buildCustomMcpServerConfigs( ); continue; } + connectionUpdatedAt = connection.updatedAt; } const proxyPath = `${CUSTOM_MCP_PROXY_PATH_PREFIX}${row.id}`; @@ -345,6 +356,7 @@ async function buildCustomMcpServerConfigs( servers[row.name] = { url: requestOrigin ? `${requestOrigin}${proxyPath}` : proxyPath, headers: { 'X-MCP-Client': PRODUCT_NAME }, + cacheRevision: `${row.updatedAt?.getTime() ?? 0}:${connectionUpdatedAt?.getTime() ?? ''}`, }; } @@ -387,6 +399,7 @@ async function buildCuratedMcpServerConfigs(ctx: { .select({ enabledMcpId: deploymentMcpEnablements.mcpId, disabledTools: deploymentMcpEnablements.disabledTools, + enablementUpdatedAt: deploymentMcpEnablements.updatedAt, connection: mcpConnections, }) .from(deploymentMcpEnablements) @@ -421,6 +434,12 @@ async function buildCuratedMcpServerConfigs(ctx: { }); const servers: ResolvedMcpServerConfigs = {}; + const revisionByMcpId = new Map( + enabledConnections.map((entry) => [ + entry.enabledMcpId, + `${entry.enablementUpdatedAt.getTime()}:${entry.connection?.updatedAt.getTime() ?? ''}`, + ]), + ); const requestOrigin = ctx.requestOrigin; for (const connection of connections) { @@ -605,5 +624,9 @@ async function buildCuratedMcpServerConfigs(ctx: { } } + for (const [mcpId, server] of Object.entries(servers)) { + server.cacheRevision = revisionByMcpId.get(mcpId); + } + return servers; } diff --git a/packages/types/src/acp-request-user-input.test.ts b/packages/types/src/acp-request-user-input.test.ts index d4d3efb40a..44b504bd3c 100644 --- a/packages/types/src/acp-request-user-input.test.ts +++ b/packages/types/src/acp-request-user-input.test.ts @@ -1,10 +1,12 @@ import { getAcpRequestUserInputValidationError, + normalizeAcpRequestUserInputAnswers, parseAcpRequestUserInputAnswers, parseAcpRequestUserInputPayload, parseAcpRequestUserInputQuestion, parseAcpRequestUserInputRequestParams, parseAcpRequestUserInputResponsePayload, + resolveAcpRequestUserInputAnswer, } from './acp'; const singleQuestion = { @@ -125,6 +127,41 @@ describe('request_user_input multi-select payloads', () => { ).toBeUndefined(); }); + it('canonicalizes trusted option IDs while accepting legacy labels', () => { + const question = { + ...singleQuestion, + options: [ + { id: 'fast', label: 'Fast', description: 'Run fast' }, + { + id: 'thorough', + label: 'Thorough', + description: 'Run thoroughly', + }, + ], + }; + expect( + getAcpRequestUserInputValidationError([question], { + mode: { answers: ['fast'] }, + }), + ).toBeNull(); + expect( + normalizeAcpRequestUserInputAnswers([question], { + mode: { answers: ['Fast'] }, + }), + ).toEqual({ mode: { answers: ['fast'] } }); + expect(resolveAcpRequestUserInputAnswer(question, 'Fast')).toBe('fast'); + expect(resolveAcpRequestUserInputAnswer(question, '2')).toBe('thorough'); + }); + + it('preserves labels for legacy options without IDs', () => { + expect( + normalizeAcpRequestUserInputAnswers([singleQuestion], { + mode: { answers: ['Fast'] }, + }), + ).toEqual({ mode: { answers: ['Fast'] } }); + expect(resolveAcpRequestUserInputAnswer(singleQuestion, '1')).toBe('Fast'); + }); + it('parses answers and response payloads without multi-select changes', () => { const answers = parseAcpRequestUserInputAnswers({ mode: { answers: ['Fast'] }, diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts index f6965ba98e..851c2a1d9f 100644 --- a/packages/types/src/acp.ts +++ b/packages/types/src/acp.ts @@ -211,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.'; @@ -225,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; @@ -493,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, }; } @@ -502,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) => @@ -517,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) { 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/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 = { From 7fb914bb485c5499037d52ea84742c8577ebb510 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 16:06:40 +0000 Subject: [PATCH 30/30] fix: keep guided interaction responses reliable --- .../FastSessionTranscript.client.test.tsx | 72 ++++++++++ .../[sessionId]/FastSessionTranscript.tsx | 40 ++++-- .../trpc/commands/fast-sessions/index.test.ts | 94 +++++++++++-- .../src/trpc/commands/fast-sessions/index.ts | 102 +++++++++----- .../types/src/acp-request-user-input.test.ts | 125 ++++++++++++++++++ packages/types/src/acp.ts | 7 +- 6 files changed, 381 insertions(+), 59 deletions(-) diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 8d299bf6b5..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 @@ -738,6 +738,78 @@ describe('FastSessionTranscript', () => { expect(screen.queryByText('Choose a path')).toBeNull(); }); + it.each([ + ['failed', 'Failed to Ask for'], + ['completed', 'Asked for'], + ] as const)( + 'keeps a %s request_user_input tool row when no interaction card was persisted', + (status, actionLabel) => { + render( + , + ); + + expect(screen.getByText(actionLabel)).toBeInTheDocument(); + expect(screen.getByText('human guidance')).toBeInTheDocument(); + if (status === 'failed') { + expect(screen.getByText('Failed')).toBeInTheDocument(); + } else { + expect(screen.getByText('Completed')).toBeInTheDocument(); + } + expect(screen.queryByText('Structured input request')).toBeNull(); + }, + ); + it('places a pending interaction at its chronological position', () => { const request = { ...textMessage({ diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index fabb8512c8..ea9581a36f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -105,7 +105,10 @@ function getTranscriptMessageText(message: TranscriptMessage) { : text; } -function isRequestUserInputToolMessage(message: TranscriptMessage) { +function shouldSuppressRequestUserInputToolMessage( + message: TranscriptMessage, + requestTurnIds: ReadonlySet, +) { if ( message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCall && message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCallUpdate && @@ -117,10 +120,15 @@ function isRequestUserInputToolMessage(message: TranscriptMessage) { const payload = message.payload as { toolName?: unknown; title?: unknown; + status?: unknown; } | null; - return ( + const isRequestUserInput = payload?.toolName === 'request_user_input' || - payload?.title === 'request_user_input' + payload?.title === 'request_user_input'; + return ( + isRequestUserInput && + payload?.status !== 'failed' && + requestTurnIds.has(message.turnId) ); } @@ -631,19 +639,26 @@ export function FastSessionTranscript({ }) ?? null ); }, [messages, pendingInputRequest]); - const requestUserInputById = useMemo(() => { + const { requestUserInputById, requestUserInputTurnIds } = useMemo(() => { const requests = new Map< string, NonNullable> >(); + const turnIds = new Set(); for (const message of messages) { if (message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) { continue; } const request = parseAcpRequestUserInputPayload(message.payload); - if (request) requests.set(request.requestId, request); + if (request) { + requests.set(request.requestId, request); + turnIds.add(request.turnId); + } } - return requests; + return { + requestUserInputById: requests, + requestUserInputTurnIds: turnIds, + }; }, [messages]); const { persistedBeforeInput, persistedAfterInput } = useMemo(() => { const before: AcpUiMessage[] = []; @@ -655,7 +670,10 @@ export function FastSessionTranscript({ (message.payload as { taskNavigation?: unknown } | null) ?.taskNavigation === true) || message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInput || - isRequestUserInputToolMessage(message) + shouldSuppressRequestUserInputToolMessage( + message, + requestUserInputTurnIds, + ) ) { continue; } @@ -737,7 +755,13 @@ export function FastSessionTranscript({ persistedBeforeInput: before, persistedAfterInput: after, }; - }, [messages, owner, pendingInputRequestOrder, requestUserInputById]); + }, [ + messages, + owner, + pendingInputRequestOrder, + requestUserInputById, + requestUserInputTurnIds, + ]); const hasVisibleAssistantMessage = useMemo( () => messages.some( diff --git a/apps/web/src/trpc/commands/fast-sessions/index.test.ts b/apps/web/src/trpc/commands/fast-sessions/index.test.ts index a89c6985dc..d4d62fd8b4 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.test.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.test.ts @@ -395,7 +395,7 @@ describe('setup context on ordinary Fast session input', () => { expect(mocks.after).not.toHaveBeenCalled(); }); - it('treats a duplicate saved setup category response as successful without scheduling twice', async () => { + it('recovers a saved response when the original process died before scheduling', async () => { const saved = { eventId: 'response-event', payload: { @@ -409,7 +409,7 @@ describe('setup context on ordinary Fast session input', () => { }; mocks.dbSelectLimit .mockResolvedValueOnce([request]) - .mockResolvedValueOnce([saved]); + .mockResolvedValueOnce([]); mocks.resolveSetupContext.mockResolvedValue({ ...setupContext, setupSnapshot: freshSnapshot, @@ -418,26 +418,92 @@ describe('setup context on ordinary Fast session input', () => { setupSnapshot: freshSnapshot, }, }); + const scheduled: Array<() => Promise> = []; + mocks.after.mockImplementation((callback) => { + scheduled.push(callback); + }); + + // The first request persists the response, then its process dies before + // the registered callback gets a chance to admit or run the turn. await submitFastSessionUserInputCommand(auth, input); - expect(mocks.upsertMessage).not.toHaveBeenCalled(); - expect(mocks.after).not.toHaveBeenCalled(); + expect(mocks.upsertMessage).toHaveBeenCalledOnce(); + expect(scheduled).toHaveLength(1); + + mocks.dbSelectLimit + .mockResolvedValueOnce([request]) + .mockResolvedValueOnce([saved]); + await submitFastSessionUserInputCommand(auth, { + ...input, + answers: { + 'setup-tools-documents': { answers: ['Different retry value'] }, + }, + }); + expect(mocks.upsertMessage).toHaveBeenCalledOnce(); + expect(scheduled).toHaveLength(2); + + mocks.dbSelectLimit.mockResolvedValueOnce([]); + await scheduled[1]?.(); + + expect(mocks.answerQuestion).toHaveBeenCalledOnce(); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + question: `${JSON.stringify({ requestId: input.requestId, answers: input.answers })}`, + currentMessageId: `input-response:${input.requestId}`, + setupSnapshot: freshSnapshot, + }), + ); }); - it('does not schedule when another generic response-row claimant won', async () => { + it('collapses contending response claimants to one completed turn', async () => { mocks.dbSelectLimit .mockResolvedValueOnce([request]) - .mockResolvedValueOnce([]); - mocks.upsertMessage.mockResolvedValueOnce({ - initialHumanTurn: false, - inserted: false, + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([request]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + payload: { + requestId: input.requestId, + sessionId: session.id, + turnId: request.turnId, + callId: input.requestId, + answers: input.answers, + resolution: 'submitted', + }, + }, + ]); + mocks.upsertMessage + .mockResolvedValueOnce({ initialHumanTurn: false, inserted: true }) + .mockResolvedValueOnce({ initialHumanTurn: false, inserted: false }); + const scheduled: Array<() => Promise> = []; + mocks.after.mockImplementation((callback) => { + scheduled.push(callback); }); - await expect( - submitFastSessionUserInputCommand(auth, input), - ).resolves.toEqual({ success: true }); + // Model two requests that both completed their pre-insert read before the + // database selected one response-row winner. Neither callback runs yet. + await submitFastSessionUserInputCommand(auth, input); + await submitFastSessionUserInputCommand(auth, { + ...input, + answers: { + 'setup-tools-documents': { answers: ['Losing response'] }, + }, + }); - expect(mocks.upsertMessage).toHaveBeenCalledOnce(); - expect(mocks.after).not.toHaveBeenCalled(); + expect(mocks.upsertMessage).toHaveBeenCalledTimes(2); + expect(scheduled).toHaveLength(2); + + mocks.dbSelectLimit.mockResolvedValueOnce([]); + await scheduled[0]?.(); + mocks.dbSelectLimit.mockResolvedValueOnce([{ id: 'terminal-response' }]); + await scheduled[1]?.(); + + expect(mocks.answerQuestion).toHaveBeenCalledOnce(); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + question: `${JSON.stringify({ requestId: input.requestId, answers: input.answers })}`, + }), + ); }); it('routes final presets through setup-specific persistence, not ordinary response writes', async () => { diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts index eb19d8713c..b10ba2d5a0 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.ts @@ -47,6 +47,7 @@ import { isSetupIntegrationDiscoveryQuestionId, parseAcpRequestUserInputAnswers, parseAcpRequestUserInputPayload, + parseAcpRequestUserInputResponsePayload, normalizeAcpRequestUserInputAnswers, type AcpRequestUserInputAnswers, type AcpRequestUserInputPayload, @@ -852,6 +853,17 @@ export async function submitFastSessionUserInputCommand( if (validationError) { throw new Error(validationError); } + if (requestPayload.preset && existingResponse) { + return { success: true }; + } + const savedResponse = existingResponse + ? parseAcpRequestUserInputResponsePayload(existingResponse.payload) + : null; + if (existingResponse && !savedResponse) { + throw new Error('This input response is no longer valid.'); + } + let responseAnswers = savedResponse?.answers ?? submitted; + let responseResolution = savedResponse?.resolution ?? resolution; const scheduleResponseTurn = async ( answers: AcpRequestUserInputAnswers, @@ -919,10 +931,6 @@ export async function submitFastSessionUserInputCommand( }); }; - if (existingResponse) { - return { success: true }; - } - const responseEventId = `${request.eventId}:response`; if (requestPayload.preset) { if (setupContext && !options.persistSetupPresetResponse) { @@ -942,41 +950,63 @@ export async function submitFastSessionUserInputCommand( }); return { success: true }; } - const responseClaim = await upsertFastAgentMessage({ - sessionId: session.id, - insertOnly: true, - message: { - eventId: responseEventId, - turnId: request.turnId, - turnSeq: 2_000_000_000, - ts: Date.now(), - eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse, - role: 'user', - contentBlocks: [ - { - type: 'text' as const, - text: formatRequestUserInputResponseText(requestPayload, { - answers: submitted, - resolution, - }), - }, - ], - metadata: { visibleInTranscript: true }, - payload: { - requestId: input.requestId, - sessionId: session.id, + if (!existingResponse) { + const responseClaim = await upsertFastAgentMessage({ + sessionId: session.id, + insertOnly: true, + message: { + eventId: responseEventId, turnId: request.turnId, - callId: input.requestId, - answers: submitted, - resolution, + turnSeq: 2_000_000_000, + ts: Date.now(), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse, + role: 'user', + contentBlocks: [ + { + type: 'text' as const, + text: formatRequestUserInputResponseText(requestPayload, { + answers: responseAnswers, + resolution: responseResolution, + }), + }, + ], + metadata: { visibleInTranscript: true }, + payload: { + requestId: input.requestId, + sessionId: session.id, + turnId: request.turnId, + callId: input.requestId, + answers: responseAnswers, + resolution: responseResolution, + }, + source: 'web', }, - source: 'web', - }, - }); - - if (responseClaim?.inserted !== false) { - await scheduleResponseTurn(submitted, resolution); + }); + if (responseClaim?.inserted === false) { + const [winningResponse] = await db + .select({ payload: fastAgentMessages.payload }) + .from(fastAgentMessages) + .where( + and( + eq(fastAgentMessages.conversationId, session.id), + eq(fastAgentMessages.eventId, responseEventId), + ), + ) + .limit(1); + const winningPayload = parseAcpRequestUserInputResponsePayload( + winningResponse?.payload ?? null, + ); + if (!winningPayload) { + throw new Error('This input response is no longer valid.'); + } + responseAnswers = winningPayload.answers; + responseResolution = winningPayload.resolution; + } } + // A retry may be the first process that survives long enough to register + // `after()`. Re-admit the deterministic turn on every accepted submission; + // the durable event key and terminal-output check collapse contenders. + await scheduleResponseTurn(responseAnswers, responseResolution); return { success: true }; } diff --git a/packages/types/src/acp-request-user-input.test.ts b/packages/types/src/acp-request-user-input.test.ts index 44b504bd3c..a896637513 100644 --- a/packages/types/src/acp-request-user-input.test.ts +++ b/packages/types/src/acp-request-user-input.test.ts @@ -1,4 +1,5 @@ import { + formatRequestUserInputResponseText, getAcpRequestUserInputValidationError, normalizeAcpRequestUserInputAnswers, parseAcpRequestUserInputAnswers, @@ -189,3 +190,127 @@ describe('request_user_input multi-select payloads', () => { ).toBeNull(); }); }); + +describe('request_user_input response transcript formatting', () => { + const request = { + requestId: 'r', + sessionId: 's', + turnId: 't', + callId: 'c', + status: 'pending' as const, + questions: [ + { + ...singleQuestion, + isOther: true, + options: [ + { id: 'fast', label: 'Fast', description: 'Run fast' }, + { + id: 'thorough', + label: 'Thorough', + description: 'Run thoroughly', + }, + ], + }, + ], + }; + + it('renders a known option ID as its label without changing the response', () => { + const response = { + resolution: 'submitted' as const, + answers: { mode: { answers: ['fast'] } }, + }; + + expect(formatRequestUserInputResponseText(request, response)).toBe('Fast'); + expect(response.answers.mode.answers).toEqual(['fast']); + }); + + it('preserves unknown custom text and legacy label or index values', () => { + expect( + formatRequestUserInputResponseText(request, { + resolution: 'submitted', + answers: { mode: { answers: ['Use balanced mode'] } }, + }), + ).toBe('Use balanced mode'); + expect( + formatRequestUserInputResponseText(request, { + resolution: 'submitted', + answers: { mode: { answers: ['Fast'] } }, + }), + ).toBe('Fast'); + expect( + formatRequestUserInputResponseText(request, { + resolution: 'submitted', + answers: { mode: { answers: ['1'] } }, + }), + ).toBe('1'); + }); + + it('renders the setup continuation option as Continue', () => { + expect( + formatRequestUserInputResponseText( + { + ...request, + questions: [ + { + ...singleQuestion, + options: [ + { + id: 'continue', + label: 'Continue', + description: 'Continue setup.', + }, + ], + }, + ], + }, + { + resolution: 'submitted', + answers: { mode: { answers: ['continue'] } }, + }, + ), + ).toBe('Continue'); + }); + + it('renders multi-select option IDs as a comma-separated label list', () => { + expect( + formatRequestUserInputResponseText( + { + ...request, + questions: [ + { + ...singleQuestion, + multiple: true, + options: [ + { id: 'slack', label: 'Slack', description: 'Connect Slack' }, + { + id: 'notion', + label: 'Notion', + description: 'Connect Notion', + }, + ], + }, + ], + }, + { + resolution: 'submitted', + answers: { mode: { answers: ['slack', 'notion'] } }, + }, + ), + ).toBe('Slack, Notion'); + }); + + it('continues to mask secret answers before resolving option labels', () => { + expect( + formatRequestUserInputResponseText( + { + ...request, + questions: [{ ...request.questions[0]!, isSecret: true }], + }, + { + resolution: 'submitted', + answers: { mode: { answers: ['fast'] } }, + }, + ), + ).toBe('[hidden]'); + }); +}); diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts index 851c2a1d9f..45ec26f47f 100644 --- a/packages/types/src/acp.ts +++ b/packages/types/src/acp.ts @@ -2506,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(', ');