From c1f455ccc5a9d6d9ee10445c2132edcb8eda6a28 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:04:03 -0400 Subject: [PATCH 1/4] feat: allow choosing a model for custom automations --- .../src/handlers/custom-automations/index.ts | 6 + apps/docs/automations.mdx | 2 + ...AutomationsSettings.render.client.test.tsx | 27 + .../automations/CustomAutomationsSection.tsx | 22 + apps/web/src/components/tasks/ModelSelect.tsx | 19 +- .../automations/custom-automations.ts | 6 + apps/web/src/trpc/routers/_app.ts | 2 + .../roomote-mcp-server/custom-automations.ts | 2 + .../src/mcp/roomote-mcp-server/index.ts | 7 + packages/db/drizzle/0026_sour_master_mold.sql | 1 + packages/db/drizzle/meta/0026_snapshot.json | 9921 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../lib/__tests__/custom-automations.test.ts | 46 + packages/db/src/lib/custom-automations.ts | 24 +- packages/db/src/schema.ts | 5 + .../__tests__/custom-automations.test.ts | 36 + .../server/automations/custom-automations.ts | 18 + packages/types/src/background-agents.ts | 1 + 18 files changed, 10147 insertions(+), 5 deletions(-) create mode 100644 packages/db/drizzle/0026_sour_master_mold.sql create mode 100644 packages/db/drizzle/meta/0026_snapshot.json diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index 4eb15bf18..f767df0d4 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -39,6 +39,7 @@ const writeSchema = z.object({ prompt: z.string().trim().min(1).max(8_000), enabled: z.boolean().default(true), schedule: z.string().trim().min(1).max(500), + model: z.string().trim().min(1).max(200).optional(), environmentId: z.string().uuid(), targetProvider: z.enum(['slack', 'discord', 'teams', 'telegram']).optional(), targetChannelId: z.string().trim().min(1).max(160).optional(), @@ -50,6 +51,7 @@ const updateSchema = z.object({ prompt: z.string().trim().min(1).max(8_000).optional(), enabled: z.boolean().optional(), schedule: z.string().trim().min(1).max(500).optional(), + model: z.string().trim().min(1).max(200).nullable().optional(), environmentId: z.string().uuid().optional(), targetProvider: z .enum(['slack', 'discord', 'teams', 'telegram']) @@ -197,6 +199,7 @@ customAutomationsRouter.post('/', async (c) => { enabled: parsed.data.enabled, scheduleMode: schedule.scheduleMode, cronExpression: schedule.cronExpression, + model: parsed.data.model ?? null, environmentId: parsed.data.environmentId, target: buildTarget(parsed.data), createdByUserId: adminId(c), @@ -251,6 +254,9 @@ customAutomationsRouter.patch('/:id', async (c) => { enabled: parsed.data.enabled ?? existing.enabled, scheduleMode: schedule.scheduleMode, cronExpression: schedule.cronExpression, + // Explicit null clears the override; omitted keeps the existing value. + model: + parsed.data.model === null ? null : (parsed.data.model ?? existing.model), environmentId: parsed.data.environmentId ?? existing.environmentId ?? '', target: clearTarget ? {} diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 9a0071d57..946a99a0a 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -70,6 +70,8 @@ Create arbitrary scheduled agent runs with: - the **prompt** Roomote should run - a **cadence** (`every hour`, `every 6 hours`, `daily`, or `weekly`) - one required **environment** +- an optional **model** override for the runs; the default follows the + deployment task model - an optional **report destination** channel (Slack, Discord, Teams, or Telegram) diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 75bd2baef..8f88901c5 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -274,6 +274,26 @@ vi.mock('@tanstack/react-query', () => ({ return { isPending: false, data: [] }; } + if (queryOptions.queryKey?.[0] === 'taskModels') { + return { + isPending: false, + data: { + defaultModelId: 'anthropic/claude-sonnet-5', + chatgptConnected: false, + openaiConnected: false, + xaiSubscriptionConnected: false, + xaiConnected: false, + models: [ + { + id: 'anthropic/claude-sonnet-5', + displayName: 'Claude Sonnet 5', + isDefault: true, + }, + ], + }, + }; + } + if (queryOptions.queryKey?.[0] === 'miscSettings') { return { isPending: false, @@ -429,6 +449,13 @@ vi.mock('@/trpc/client', () => ({ queryKey: () => ['miscSettings', 'get'], }, }, + taskModels: { + launchOptions: { + queryOptions: () => ({ + queryKey: ['taskModels', 'launchOptions'], + }), + }, + }, }), })); diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index 62018dcb2..441ae6b4b 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -33,6 +33,8 @@ import { Trash2, } from '@/components/system'; +import { ModelSelect } from '@/components/tasks/ModelSelect'; + import { SlackChannelSelect } from './SlackChannelSelect'; type CustomAutomationFormState = { @@ -42,6 +44,8 @@ type CustomAutomationFormState = { scheduleMode: CustomAutomationScheduleMode; environmentId: string; cronExpression: string; + /** Provider/model launch override; empty string means deployment default. */ + model: string; targetProvider: 'none' | 'slack' | 'discord' | 'teams' | 'telegram'; targetChannelId: string; targetServiceUrl: string; @@ -54,6 +58,7 @@ const EMPTY_FORM: CustomAutomationFormState = { scheduleMode: 'daily', environmentId: '', cronExpression: '', + model: '', targetProvider: 'slack', targetChannelId: '', targetServiceUrl: '', @@ -112,6 +117,7 @@ function formFromRow(row: CustomAutomationListItem): CustomAutomationFormState { scheduleMode: row.scheduleMode, environmentId: row.environmentId ?? '', cronExpression: row.cronExpression ?? '', + model: row.model ?? '', targetProvider: target.provider, targetChannelId: target.channelId, targetServiceUrl: target.serviceUrl, @@ -349,6 +355,7 @@ export function CustomAutomationsSection() { scheduleMode: form.scheduleMode, cronExpression: form.scheduleMode === 'cron' ? effectiveResolvedCron : null, + model: form.model || null, environmentId: form.environmentId, ...(form.targetProvider !== 'none' ? { @@ -509,6 +516,20 @@ export function CustomAutomationsSection() { ) : null} +
+ + + setForm((current) => ({ ...current, model: value })) + } + /> +
+
@@ -746,6 +767,7 @@ export function CustomAutomationsSection() { ? destinationLabel : `${target.provider}:${destinationLabel}`}{' '} · {statusLine(row)} + {row.model ? ` · ${row.model}` : null} {row.createdByName ? ` · by ${row.createdByName}` : null}

diff --git a/apps/web/src/components/tasks/ModelSelect.tsx b/apps/web/src/components/tasks/ModelSelect.tsx index 067d64b58..5558cd47d 100644 --- a/apps/web/src/components/tasks/ModelSelect.tsx +++ b/apps/web/src/components/tasks/ModelSelect.tsx @@ -20,8 +20,17 @@ type ModelSelectProps = { disabled?: boolean; className?: string; ariaLabel?: string; + /** + * When set, renders this label as a leading option that maps to the empty + * string value, for pickers where "no override" is a valid choice. + */ + emptyOptionLabel?: string; }; +// Radix Select items cannot use an empty-string value, so the empty option +// round-trips through this sentinel. +const EMPTY_OPTION_VALUE = '__model-select-empty__'; + function modelOptionLabel(model: { displayName: string; isDefault?: boolean; @@ -35,6 +44,7 @@ export function ModelSelect({ disabled = false, className, ariaLabel = 'Model', + emptyOptionLabel, }: ModelSelectProps) { const { data, isPending } = useLaunchTaskModels(); const modelGroups = useMemo(() => { @@ -59,14 +69,19 @@ export function ModelSelect({ return (
+ {form.scheduleMode === 'cron' ? ( +
+ + { + setResolvedCron(null); + setScheduleSummary(null); + setForm((current) => ({ + ...current, + cronExpression: event.target.value, + })); + }} + onBlur={() => { + const alreadyResolvingThisInput = + resolveScheduleMutation.isPending && + resolveScheduleMutation.variables?.schedule === + form.cronExpression; + if ( + !clientParsedCron && + !resolvedCron && + form.cronExpression.trim() && + !alreadyResolvingThisInput + ) { + resolveScheduleMutation.mutate({ + schedule: form.cronExpression, + }); + } + }} + /> + {resolveScheduleMutation.isPending ? ( +

+ Interpreting schedule... +

+ ) : effectiveScheduleSummary ? ( +

+ {effectiveScheduleSummary} +

+ ) : null} +
+ ) : null} + + +
-
- {form.scheduleMode === 'cron' ? (
- - Model + { - setResolvedCron(null); - setScheduleSummary(null); - setForm((current) => ({ - ...current, - cronExpression: event.target.value, - })); - }} - onBlur={() => { - const alreadyResolvingThisInput = - resolveScheduleMutation.isPending && - resolveScheduleMutation.variables?.schedule === - form.cronExpression; - if ( - !clientParsedCron && - !resolvedCron && - form.cronExpression.trim() && - !alreadyResolvingThisInput - ) { - resolveScheduleMutation.mutate({ - schedule: form.cronExpression, - }); - } - }} + onValueChange={(value) => + setForm((current) => ({ ...current, model: value })) + } /> - {resolveScheduleMutation.isPending ? ( -

- Interpreting schedule... -

- ) : effectiveScheduleSummary ? ( -

- {effectiveScheduleSummary} -

- ) : null}
- ) : null} - -
- - - setForm((current) => ({ ...current, model: value })) - } - />
diff --git a/apps/web/src/components/tasks/ModelSelect.tsx b/apps/web/src/components/tasks/ModelSelect.tsx index 5558cd47d..54f4f55d1 100644 --- a/apps/web/src/components/tasks/ModelSelect.tsx +++ b/apps/web/src/components/tasks/ModelSelect.tsx @@ -25,6 +25,8 @@ type ModelSelectProps = { * string value, for pickers where "no override" is a valid choice. */ emptyOptionLabel?: string; + /** Trigger size; use 'default' to line up with default-size form controls. */ + size?: 'sm' | 'default'; }; // Radix Select items cannot use an empty-string value, so the empty option @@ -45,6 +47,7 @@ export function ModelSelect({ className, ariaLabel = 'Model', emptyOptionLabel, + size = 'sm', }: ModelSelectProps) { const { data, isPending } = useLaunchTaskModels(); const modelGroups = useMemo(() => { @@ -75,7 +78,7 @@ export function ModelSelect({ } disabled={disabled || isPending || !data} > - + From d49e5700dbb262cab6505b928f5ed390ca9f732f Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:12:38 -0400 Subject: [PATCH 3/4] improve: rename model fallback option to default coding model --- .../settings/automations/CustomAutomationsSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index 3cd887502..a9cfde43f 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -523,7 +523,7 @@ export function CustomAutomationsSection() { size="default" ariaLabel="Automation model" value={form.model} - emptyOptionLabel="Deployment default" + emptyOptionLabel="Default coding model" disabled={busy} onValueChange={(value) => setForm((current) => ({ ...current, model: value })) From 81e57c38f4d27d1f54c24c06836cdf45938b8cf8 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:40:58 -0400 Subject: [PATCH 4/4] fix: validate model format at the custom automation schema boundary --- .../src/handlers/custom-automations/index.ts | 11 +++++++++-- apps/web/src/trpc/routers/_app.ts | 18 ++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index f767df0d4..d711af567 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -34,12 +34,19 @@ type CustomAutomationVariables = Variables & { customAutomationAdminId: string; }; +const modelSchema = z + .string() + .trim() + .min(1) + .max(200) + .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.'); + const writeSchema = z.object({ name: z.string().trim().min(1).max(100), prompt: z.string().trim().min(1).max(8_000), enabled: z.boolean().default(true), schedule: z.string().trim().min(1).max(500), - model: z.string().trim().min(1).max(200).optional(), + model: modelSchema.optional(), environmentId: z.string().uuid(), targetProvider: z.enum(['slack', 'discord', 'teams', 'telegram']).optional(), targetChannelId: z.string().trim().min(1).max(160).optional(), @@ -51,7 +58,7 @@ const updateSchema = z.object({ prompt: z.string().trim().min(1).max(8_000).optional(), enabled: z.boolean().optional(), schedule: z.string().trim().min(1).max(500).optional(), - model: z.string().trim().min(1).max(200).nullable().optional(), + model: modelSchema.nullable().optional(), environmentId: z.string().uuid().optional(), targetProvider: z .enum(['slack', 'discord', 'teams', 'telegram']) diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index a7a6c2221..eb62728c3 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -665,7 +665,14 @@ const automationsRouter = createRouter({ 'cron', ]), cronExpression: z.string().trim().max(200).nullable().optional(), - model: z.string().trim().min(1).max(200).nullable().optional(), + model: z + .string() + .trim() + .min(1) + .max(200) + .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.') + .nullable() + .optional(), environmentId: z.string().uuid(), targetProvider: z .enum(['slack', 'discord', 'teams', 'telegram']) @@ -700,7 +707,14 @@ const automationsRouter = createRouter({ 'cron', ]), cronExpression: z.string().trim().max(200).nullable().optional(), - model: z.string().trim().min(1).max(200).nullable().optional(), + model: z + .string() + .trim() + .min(1) + .max(200) + .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.') + .nullable() + .optional(), environmentId: z.string().uuid(), targetProvider: z .enum(['slack', 'discord', 'teams', 'telegram'])