diff --git a/.env.production.example b/.env.production.example index 5a352cad1..0c271c189 100644 --- a/.env.production.example +++ b/.env.production.example @@ -154,6 +154,9 @@ DEFAULT_COMPUTE_PROVIDER=docker # Set to true to prevent curated integrations from being configured or used. # Existing connections remain stored and become available again once unset. # R_CURATED_INTEGRATIONS_DISABLED=true +# Set to true to let users default supported communications messages to fast mode. +# Currently applies to Slack messages only. +# R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED=true # SLACK_APP_ID= # R_SLACK_SIGNING_SECRET= # R_TELEGRAM_BOT_TOKEN= diff --git a/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts b/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts index 04f5e01b7..c31141c74 100644 --- a/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts @@ -1,6 +1,7 @@ import { extractFastQuestion, isFastCommandInvocation, + resolveFastAgentEntryMode, stripLeadingFastCommandMention, } from '../events/fast-agent'; @@ -26,4 +27,44 @@ describe('Slack fast-agent helpers', () => { expect(extractFastQuestion(' ', true)).toBeNull(); expect(extractFastQuestion('Good, tired')).toBeNull(); }); + + it('keeps ordinary messages on standard routing when the deployment flag is disabled', () => { + expect( + resolveFastAgentEntryMode({ + explicitInvocation: false, + deploymentSettingEnabled: false, + userDefaultEnabled: true, + }), + ).toBeNull(); + }); + + it('keeps ordinary messages on standard routing when the user setting is off', () => { + expect( + resolveFastAgentEntryMode({ + explicitInvocation: false, + deploymentSettingEnabled: true, + userDefaultEnabled: false, + }), + ).toBeNull(); + }); + + it('defaults ordinary messages to fast mode when both settings are enabled', () => { + expect( + resolveFastAgentEntryMode({ + explicitInvocation: false, + deploymentSettingEnabled: true, + userDefaultEnabled: true, + }), + ).toBe('default'); + }); + + it('preserves explicit !fast routing regardless of the user setting', () => { + expect( + resolveFastAgentEntryMode({ + explicitInvocation: true, + deploymentSettingEnabled: true, + userDefaultEnabled: true, + }), + ).toBe('explicit'); + }); }); diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts index 4b72eb055..d823fdcb0 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -const { showConnectAccountMock } = vi.hoisted(() => ({ +const { + fastAgentMessageMock, + showConnectAccountMock, + startTaskMock, + userMappingRowsMock, +} = vi.hoisted(() => ({ + fastAgentMessageMock: vi.fn(), showConnectAccountMock: vi.fn(), + startTaskMock: vi.fn(), + userMappingRowsMock: vi.fn(), })); const { enrichSlackMessageEventMock, isRoomoteAuthoredSlackEventMock } = @@ -11,7 +19,11 @@ const { enrichSlackMessageEventMock, isRoomoteAuthoredSlackEventMock } = })); vi.mock('@roomote/env', () => ({ - Env: { TRPC_URL: null, R_APP_URL: 'http://localhost:3000' }, + Env: { + TRPC_URL: null, + R_APP_URL: 'http://localhost:3000', + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: true, + }, })); vi.mock('@roomote/cloud-agents/server', () => ({ @@ -31,7 +43,17 @@ vi.mock('../helpers/event-normalization.js', () => ({ vi.mock('@roomote/slack', async (importOriginal) => ({ ...(await importOriginal()), + resolveSlackReactionNames: vi.fn().mockResolvedValue({ + ackEmoji: 'eyes', + completionEmoji: 'white_check_mark', + }), showConnectAccount: showConnectAccountMock, + startAutoRoutedSlackTask: startTaskMock, +})); + +vi.mock('./fast-agent.js', async (importOriginal) => ({ + ...(await importOriginal()), + processFastAgentMessage: fastAgentMessageMock, })); const redisClientMock = { @@ -60,7 +82,7 @@ const userMappingSelectChain = () => ({ from: vi.fn(() => ({ leftJoin: vi.fn(() => ({ where: vi.fn(() => ({ - limit: vi.fn().mockResolvedValue([]), + limit: userMappingRowsMock, })), })), })), @@ -78,6 +100,8 @@ describe('channel auto-start unlinked author', () => { beforeEach(() => { vi.clearAllMocks(); showConnectAccountMock.mockResolvedValue(undefined); + fastAgentMessageMock.mockResolvedValue(undefined); + userMappingRowsMock.mockResolvedValue([]); }); it('prompts an unlinked human author to connect their account instead of silently skipping', async () => { @@ -114,4 +138,51 @@ describe('channel auto-start unlinked author', () => { expect.anything(), ); }, 30000); + + it('routes an opted-in linked author to fast mode before channel auto-start', async () => { + userMappingRowsMock.mockResolvedValue([ + { + id: 'mapping-1', + slackUserId: 'U456', + slackTeamId: 'T123', + userId: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + matchedUserId: 'user-1', + userDeletedAt: null, + userMetadata: { communications_fast_mode_default: true }, + }, + ]); + const { handleMessageOrAppMentionEvent } = + await import('./message-entry.js'); + + await handleMessageOrAppMentionEvent({ + event: { + type: 'message', + channel: 'C123', + user: 'U456', + text: 'please look into this', + ts: '112.000', + channel_type: 'channel', + } as never, + context: { + slackInstallation: { teamId: 'T123', botUserId: 'UBOT' } as never, + slack: {} as never, + teamId: 'T123', + } as never, + }); + + expect(fastAgentMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + continuation: true, + event: expect.objectContaining({ + text: 'please look into this', + user: 'U456', + }), + teamId: 'T123', + userId: 'user-1', + }), + ); + expect(startTaskMock).not.toHaveBeenCalled(); + }, 30000); }); diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index 1024d477b..8e9c88ff0 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -27,6 +27,22 @@ export function isBareFastCommandInvocation(text: string): boolean { return /^!fast(?:\s|$)/i.test(text.trimStart()); } +type FastAgentEntryMode = 'explicit' | 'default'; + +export function resolveFastAgentEntryMode(params: { + explicitInvocation: boolean; + deploymentSettingEnabled: boolean; + userDefaultEnabled: boolean; +}): FastAgentEntryMode | null { + if (params.explicitInvocation) { + return 'explicit'; + } + + return params.deploymentSettingEnabled && params.userDefaultEnabled + ? 'default' + : null; +} + export function extractFastQuestion( mentionStrippedText: string, continuation = false, diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index b9e2848f2..2308f4827 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -57,6 +57,7 @@ import { isBareFastCommandInvocation, isFastCommandInvocation, processFastAgentMessage, + resolveFastAgentEntryMode, } from './fast-agent.js'; import { createFastAgentTaskLauncher } from './fast-agent-task-launcher.js'; import { processSnapshotResume } from './snapshot-resume.js'; @@ -1223,11 +1224,21 @@ async function maybeHandleChannelAutoStart(params: { const { ackEmoji } = await resolveSlackReactionNames(); - if ( - userMapping && - typeof channelAutoStartEvent.user === 'string' && - isBareFastCommandInvocation(channelAutoStartEvent.text) - ) { + const fastAgentEntryMode = + userMapping && typeof channelAutoStartEvent.user === 'string' + ? resolveFastAgentEntryMode({ + explicitInvocation: isBareFastCommandInvocation( + channelAutoStartEvent.text, + ), + deploymentSettingEnabled: + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true, + userDefaultEnabled: + userMapping.communicationsFastModeDefault && + !isRemovedEvalCommandInvocation(channelAutoStartEvent.text), + }) + : null; + + if (fastAgentEntryMode && userMapping) { startFastAgentResponse({ event: { ...channelAutoStartEvent, user: channelAutoStartEvent.user }, slackInstallation: context.slackInstallation, @@ -1236,6 +1247,7 @@ async function maybeHandleChannelAutoStart(params: { userId: userMapping.userId, teamId: context.teamId, usageText: 'Use `!fast ` in this channel.', + continuation: fastAgentEntryMode === 'default', processingReactionName: ackEmoji, errorLogPrefix: `❌ Background fast-agent response failed for auto-start thread ${channelAutoStartEvent.ts}:`, }); @@ -1687,7 +1699,16 @@ async function handleSlackEntryEvent(params: { activeTaskId: activeRun?.taskId, }); - if (isFastCommandInvocation(event.text)) { + const fastAgentEntryMode = resolveFastAgentEntryMode({ + explicitInvocation: isFastCommandInvocation(event.text), + deploymentSettingEnabled: + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true, + userDefaultEnabled: + userMapping.communicationsFastModeDefault && + !isRemovedEvalCommandInvocation(event.text), + }); + + if (fastAgentEntryMode) { startFastAgentResponse({ event, slackInstallation, @@ -1696,6 +1717,7 @@ async function handleSlackEntryEvent(params: { userId: userMapping.userId, teamId, activeTaskId: activeRun?.taskId ?? null, + continuation: fastAgentEntryMode === 'default', processingReactionName: ackEmoji, errorLogPrefix: `❌ Background fast-agent response failed for thread ${threadId}:`, }); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts index b66f5fb43..f339fbb5b 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts @@ -24,6 +24,7 @@ vi.mock('@roomote/db/server', () => ({ users: { id: 'users.id', deletedAt: 'users.deletedAt', + metadata: 'users.metadata', }, })); @@ -51,6 +52,7 @@ describe('lookupSlackUserMapping', () => { updatedAt, matchedUserId: 'user-1', userDeletedAt: null, + userMetadata: { communications_fast_mode_default: true }, }, ]); @@ -66,6 +68,7 @@ describe('lookupSlackUserMapping', () => { userId: 'user-1', createdAt, updatedAt, + communicationsFastModeDefault: true, }, hasInactiveMapping: false, }); @@ -82,6 +85,7 @@ describe('lookupSlackUserMapping', () => { updatedAt: new Date('2024-01-02T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: new Date('2024-02-01T00:00:00.000Z'), + userMetadata: {}, }, ]); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.ts b/apps/api/src/handlers/slack/helpers/user-mapping.ts index 4aa738842..5537804bc 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.ts @@ -8,7 +8,9 @@ import { } from '@roomote/db/server'; type SlackUserMappingLookup = { - activeMapping: SlackUserMapping | null; + activeMapping: + | (SlackUserMapping & { communicationsFastModeDefault: boolean }) + | null; hasInactiveMapping: boolean; }; @@ -26,6 +28,7 @@ export async function lookupSlackUserMapping(params: { updatedAt: slackUserMappings.updatedAt, matchedUserId: users.id, userDeletedAt: users.deletedAt, + userMetadata: users.metadata, }) .from(slackUserMappings) .leftJoin(users, eq(users.id, slackUserMappings.userId)) @@ -59,6 +62,12 @@ export async function lookupSlackUserMapping(params: { userId: row.userId, createdAt: row.createdAt, updatedAt: row.updatedAt, + communicationsFastModeDefault: + typeof row.userMetadata === 'object' && + row.userMetadata !== null && + !Array.isArray(row.userMetadata) && + (row.userMetadata as Record) + .communications_fast_mode_default === true, }, hasInactiveMapping: false, }; diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index eadbe990d..c5c461950 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -127,6 +127,7 @@ as per-task auth tokens or workspace paths. | `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. | | `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | | `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | +| `R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED` | Optional | Set to `true` and restart Roomote to expose a personal setting that defaults a user's supported communications messages to fast mode. The setting currently applies to Slack messages, where it removes the need for `!fast`; it is hidden and unavailable when this flag is unset. | | `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. | | `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. | | `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. | diff --git a/apps/web/src/components/settings/UserPreferencesSection.test.tsx b/apps/web/src/components/settings/UserPreferencesSection.test.tsx index ef268a3c7..66cf1c648 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.test.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.test.tsx @@ -3,28 +3,37 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; type PersonalColorTheme = 'light' | 'dark' | 'system'; -const { colorThemeState, mindReaderModeState, narrationModeState } = vi.hoisted( - () => ({ - colorThemeState: { - colorTheme: 'system' as PersonalColorTheme, - isLoading: false, - isUpdating: false, - setColorTheme: vi.fn(), - }, - mindReaderModeState: { - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }, - narrationModeState: { - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }, - }), -); +const { + colorThemeState, + mindReaderModeState, + narrationModeState, + personalPreferencesState, +} = vi.hoisted(() => ({ + colorThemeState: { + colorTheme: 'system' as PersonalColorTheme, + isLoading: false, + isUpdating: false, + setColorTheme: vi.fn(), + }, + mindReaderModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + narrationModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + personalPreferencesState: { + preferences: { communicationsFastModeDefault: false }, + isLoading: false, + isUpdating: false, + setPreferences: vi.fn(), + }, +})); vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => colorThemeState, @@ -38,6 +47,10 @@ vi.mock('@/hooks/useMindReaderMode', () => ({ useMindReaderMode: () => mindReaderModeState, })); +vi.mock('@/hooks/usePersonalPreferences', () => ({ + usePersonalPreferences: () => personalPreferencesState, +})); + vi.mock('@/components/system', () => ({ Label: ({ children, @@ -123,6 +136,9 @@ describe('UserPreferencesSection', () => { narrationModeState.enabled = false; narrationModeState.isLoading = false; narrationModeState.isUpdating = false; + personalPreferencesState.preferences.communicationsFastModeDefault = false; + personalPreferencesState.isLoading = false; + personalPreferencesState.isUpdating = false; }); it('renders user preference controls with the current state', () => { @@ -204,4 +220,31 @@ describe('UserPreferencesSection', () => { 'system', ); }); + + it('hides the communications fast mode default when it is unavailable', () => { + render(); + + expect( + screen.queryByLabelText('Toggle communications fast mode default'), + ).not.toBeInTheDocument(); + }); + + it('updates the communications fast mode default when it is available', () => { + personalPreferencesState.preferences.communicationsFastModeDefault = true; + + render( + , + ); + + const toggle = screen.getByLabelText( + 'Toggle communications fast mode default', + ); + expect(toggle).toBeChecked(); + + fireEvent.click(toggle); + + expect(personalPreferencesState.setPreferences).toHaveBeenCalledWith({ + communicationsFastModeDefault: false, + }); + }); }); diff --git a/apps/web/src/components/settings/UserPreferencesSection.tsx b/apps/web/src/components/settings/UserPreferencesSection.tsx index 1f54c82fb..59afd3417 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.tsx @@ -3,6 +3,7 @@ import { useColorTheme } from '@/hooks/useColorTheme'; import { useMindReaderMode } from '@/hooks/useMindReaderMode'; import { useNarrationMode } from '@/hooks/useNarrationMode'; +import { usePersonalPreferences } from '@/hooks/usePersonalPreferences'; import type { PersonalColorTheme } from '@/types/preferences'; import { @@ -27,7 +28,11 @@ const COLOR_THEME_OPTIONS: ReadonlyArray<{ { label: 'Auto', value: 'system' }, ]; -export function UserPreferencesSection() { +export function UserPreferencesSection({ + communicationsFastModeDefaultAvailable = false, +}: { + communicationsFastModeDefaultAvailable?: boolean; +}) { const { colorTheme, isLoading: isThemeLoading, @@ -46,6 +51,15 @@ export function UserPreferencesSection() { isUpdating: isNarrationModeUpdating, setEnabled: setNarrationModeEnabled, } = useNarrationMode(); + const { + preferences, + isLoading: isCommunicationsFastModeDefaultLoading, + isUpdating: isCommunicationsFastModeDefaultUpdating, + setPreferences, + } = usePersonalPreferences({ + enabled: communicationsFastModeDefaultAvailable, + errorMessage: 'Failed to update the communications fast mode default.', + }); const isThemeDisabled = isThemeLoading || isThemeUpdating; return ( @@ -113,6 +127,30 @@ export function UserPreferencesSection() {

+ + {communicationsFastModeDefaultAvailable ? ( +
+ + setPreferences({ communicationsFastModeDefault: enabled }) + } + /> +
+

+ Default messages to fast mode +

+

+ Use fast mode by default for supported communications messages. +

+
+
+ ) : null} ); diff --git a/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx b/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx index d7bb3aa31..a04ea9d8a 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx @@ -53,6 +53,7 @@ describe('PersonalSettingsPage', () => { , ); @@ -70,6 +71,7 @@ describe('PersonalSettingsPage', () => { , ); diff --git a/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx b/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx index a7dbe23f4..5df7083b5 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx @@ -13,10 +13,12 @@ export function PersonalSettingsPage({ profile, canChangePassword, canSetPassword, + communicationsFastModeDefaultAvailable, }: { profile: UserProfileSectionProfile; canChangePassword: boolean; canSetPassword: boolean; + communicationsFastModeDefaultAvailable: boolean; }) { return ( @@ -27,7 +29,11 @@ export function PersonalSettingsPage({ {canChangePassword || canSetPassword ? ( ) : null} - + ); diff --git a/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx b/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx index 28dbad673..48898e8a1 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx @@ -1,10 +1,15 @@ import { render } from '@testing-library/react'; let accountCapabilities: - | { canChangePassword: boolean; canSetPassword: boolean } + | { + canChangePassword: boolean; + canSetPassword: boolean; + communicationsFastModeDefaultAvailable: boolean; + } | undefined = { canChangePassword: true, canSetPassword: false, + communicationsFastModeDefaultAvailable: true, }; const { personalSettingsPageMock } = vi.hoisted(() => ({ @@ -41,7 +46,11 @@ import { PersonalSettingsRoute } from './PersonalSettingsRoute'; describe('PersonalSettingsRoute', () => { beforeEach(() => { - accountCapabilities = { canChangePassword: true, canSetPassword: false }; + accountCapabilities = { + canChangePassword: true, + canSetPassword: false, + communicationsFastModeDefaultAvailable: true, + }; personalSettingsPageMock.mockClear(); }); @@ -52,6 +61,7 @@ describe('PersonalSettingsRoute', () => { { canChangePassword: true, canSetPassword: false, + communicationsFastModeDefaultAvailable: true, profile: { email: 'ada@example.com', imageUrl: 'https://example.com/ada.png', diff --git a/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx b/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx index 58487d136..a161f223c 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx @@ -18,6 +18,10 @@ export function PersonalSettingsRoute() { { colorTheme: 'system', mindReaderMode: false, narrationMode: false, + communicationsFastModeDefault: false, }); }); diff --git a/apps/web/src/hooks/usePersonalPreferences.ts b/apps/web/src/hooks/usePersonalPreferences.ts index 32a812ee1..b559d46cf 100644 --- a/apps/web/src/hooks/usePersonalPreferences.ts +++ b/apps/web/src/hooks/usePersonalPreferences.ts @@ -51,6 +51,10 @@ function mergeResultForUpdatedFields( updates.narrationMode === undefined ? mergedPreferences.narrationMode : result.narrationMode, + communicationsFastModeDefault: + updates.communicationsFastModeDefault === undefined + ? mergedPreferences.communicationsFastModeDefault + : result.communicationsFastModeDefault, }; } @@ -81,6 +85,12 @@ function rollbackUpdatedFields( mergedPreferences.narrationMode === optimisticPreferences.narrationMode ? previousPreferences.narrationMode : mergedPreferences.narrationMode, + communicationsFastModeDefault: + updates.communicationsFastModeDefault !== undefined && + mergedPreferences.communicationsFastModeDefault === + optimisticPreferences.communicationsFastModeDefault + ? previousPreferences.communicationsFastModeDefault + : mergedPreferences.communicationsFastModeDefault, }; } diff --git a/apps/web/src/trpc/commands/preferences/index.test.ts b/apps/web/src/trpc/commands/preferences/index.test.ts index 20c2973af..dbeff15a5 100644 --- a/apps/web/src/trpc/commands/preferences/index.test.ts +++ b/apps/web/src/trpc/commands/preferences/index.test.ts @@ -41,6 +41,7 @@ describe('personal account capabilities', () => { await expect(getPersonalAccountCapabilitiesCommand(auth)).resolves.toEqual({ canChangePassword: false, canSetPassword: true, + communicationsFastModeDefaultAvailable: false, }); }); @@ -50,6 +51,7 @@ describe('personal account capabilities', () => { await expect(getPersonalAccountCapabilitiesCommand(auth)).resolves.toEqual({ canChangePassword: true, canSetPassword: false, + communicationsFastModeDefaultAvailable: false, }); }); diff --git a/apps/web/src/trpc/commands/preferences/index.ts b/apps/web/src/trpc/commands/preferences/index.ts index 226187af2..9b7fd12d9 100644 --- a/apps/web/src/trpc/commands/preferences/index.ts +++ b/apps/web/src/trpc/commands/preferences/index.ts @@ -3,6 +3,7 @@ import { headers } from 'next/headers'; import type { UserAuthSuccess } from '@/types'; import { getAuth } from '@/lib/server/auth'; +import { Env } from '@/lib/server/env'; import { userHasCredentialAccount } from '@/lib/server/user-management'; import { DEFAULT_PERSONAL_PREFERENCES, @@ -36,6 +37,9 @@ function normalizePersonalPreferences( typeof metadata.narration_mode === 'boolean' ? metadata.narration_mode : DEFAULT_PERSONAL_PREFERENCES.narrationMode, + communicationsFastModeDefault: + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true && + metadata.communications_fast_mode_default === true, }; } @@ -62,6 +66,8 @@ export async function getPersonalAccountCapabilitiesCommand( return { canChangePassword: hasCredentialAccount, canSetPassword: !hasCredentialAccount, + communicationsFastModeDefaultAvailable: + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true, }; } @@ -113,6 +119,15 @@ export async function updatePersonalPreferencesCommand( ): Promise { const nextMetadataRecord: UserMetadataRecord = {}; + if ( + input.communicationsFastModeDefault !== undefined && + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED !== true + ) { + throw new Error( + 'The communications fast mode default setting is not enabled for this deployment.', + ); + } + if (input.colorTheme !== undefined) { nextMetadataRecord.color_theme = input.colorTheme; } @@ -125,6 +140,11 @@ export async function updatePersonalPreferencesCommand( nextMetadataRecord.narration_mode = input.narrationMode; } + if (input.communicationsFastModeDefault !== undefined) { + nextMetadataRecord.communications_fast_mode_default = + input.communicationsFastModeDefault; + } + if (Object.keys(nextMetadataRecord).length === 0) { return getPersonalPreferencesCommand(auth); } diff --git a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts index ce37848b8..fe6c9b24d 100644 --- a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts +++ b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts @@ -2,6 +2,12 @@ import { db, eq, userFactory, users } from '@roomote/db/server'; import type { UserAuthSuccess } from '@/types'; +const { mockEnv } = vi.hoisted(() => ({ + mockEnv: { R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: false }, +})); + +vi.mock('@/lib/server/env', () => ({ Env: mockEnv })); + import { getPersonalPreferencesCommand, updatePersonalPreferencesCommand, @@ -12,6 +18,10 @@ function buildAuth(userId: string) { } describe('personal preferences', () => { + beforeEach(() => { + mockEnv.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED = false; + }); + it('defaults mind reader mode to disabled', async () => { const user = await userFactory.create(); @@ -60,4 +70,50 @@ describe('personal preferences', () => { }), ); }); + + it('rejects communications fast mode updates when the deployment setting is disabled', async () => { + const user = await userFactory.create(); + + await expect( + updatePersonalPreferencesCommand(buildAuth(user.id), { + communicationsFastModeDefault: true, + }), + ).rejects.toThrow( + 'The communications fast mode default setting is not enabled for this deployment.', + ); + }); + + it('does not expose a stored communications fast mode default when the deployment setting is disabled', async () => { + const user = await userFactory.create({ + metadata: { communications_fast_mode_default: true }, + }); + + await expect( + getPersonalPreferencesCommand(buildAuth(user.id)), + ).resolves.toEqual( + expect.objectContaining({ communicationsFastModeDefault: false }), + ); + }); + + it('persists the communications fast mode default when the deployment setting is enabled', async () => { + mockEnv.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED = true; + const user = await userFactory.create(); + + await expect( + updatePersonalPreferencesCommand(buildAuth(user.id), { + communicationsFastModeDefault: true, + }), + ).resolves.toEqual( + expect.objectContaining({ communicationsFastModeDefault: true }), + ); + + const storedUser = await db.query.users.findFirst({ + where: eq(users.id, user.id), + columns: { metadata: true }, + }); + + expect(storedUser?.metadata).toEqual( + expect.objectContaining({ communications_fast_mode_default: true }), + ); + }); }); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 39a09010a..ecd12d954 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -1432,12 +1432,14 @@ export const appRouter = createRouter({ colorTheme: z.enum(PERSONAL_COLOR_THEMES).optional(), mindReaderMode: z.boolean().optional(), narrationMode: z.boolean().optional(), + communicationsFastModeDefault: z.boolean().optional(), }) .refine( (input) => input.colorTheme !== undefined || input.mindReaderMode !== undefined || - input.narrationMode !== undefined, + input.narrationMode !== undefined || + input.communicationsFastModeDefault !== undefined, { message: 'Expected at least one personal preference to update.', }, diff --git a/apps/web/src/types/preferences.ts b/apps/web/src/types/preferences.ts index dbcf508e0..60dd3a4f0 100644 --- a/apps/web/src/types/preferences.ts +++ b/apps/web/src/types/preferences.ts @@ -16,6 +16,7 @@ export interface PersonalPreferences { colorTheme: PersonalColorTheme; mindReaderMode: boolean; narrationMode: boolean; + communicationsFastModeDefault: boolean; } export type PersonalPreferencesUpdate = Partial; @@ -24,4 +25,5 @@ export const DEFAULT_PERSONAL_PREFERENCES: PersonalPreferences = { colorTheme: 'system', mindReaderMode: false, narrationMode: false, + communicationsFastModeDefault: false, }; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index bdf31ef35..1e4d678e0 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -23,6 +23,7 @@ x-roomote-production-env: &roomote-production-env R_PUBLIC_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_INSTANCE_ID: ${R_INSTANCE_ID:-} R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false} + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: ${R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED:-false} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 59691603d..5325eb7eb 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -22,6 +22,7 @@ x-roomote-env: &roomote-env R_APP_URL: ${R_PUBLIC_URL:-http://localhost:13000} R_INSTANCE_ID: ${R_INSTANCE_ID:-} R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false} + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: ${R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED:-false} # The Brain: supplying this key gives the deployment shared memory that # agents consult. It powers the brain service's embeddings and is the # single activation signal — there is no Settings UI for it. diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts index 1a36413f4..b1bacc8b0 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -226,6 +226,22 @@ describe('Env', () => { expect(areCuratedIntegrationsDisabled('0')).toBe(false); }); + it('keeps the communications fast mode setting opt-in', () => { + const runtimeEnv = { ...process.env }; + delete runtimeEnv.SKIP_ENV_VALIDATION; + delete runtimeEnv.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED; + + expect( + createRoomoteEnv(runtimeEnv).R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED, + ).toBe(false); + expect( + createRoomoteEnv({ + ...runtimeEnv, + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: 'true', + }).R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED, + ).toBe(true); + }); + it('accepts valid Ping instance IDs and rejects invalid ones', () => { const runtimeEnv = { ...process.env }; delete runtimeEnv.SKIP_ENV_VALIDATION; diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 824bd3c8a..febb8520b 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -124,6 +124,8 @@ const serverSchema = { // Roomote Cloud-only analytics and support integrations. These values are // intentionally not used by self-hosted deployments. R_CLOUD_ENABLED: optInBoolean(), + // Exposes the per-user setting that defaults communications messages to fast mode. + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: optInBoolean(), // Operator policy for the curated Settings > Integrations catalog. Enabled // by default; operators opt out explicitly. Existing connections remain // stored but cannot be configured or used while disabled.