diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index b2bf59970..b1eea1085 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -35,11 +35,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: modelSchema.optional(), environmentId: z.string().uuid(), targetProvider: z.enum(['slack', 'discord', 'teams', 'telegram']).optional(), targetChannelId: z.string().trim().min(1).max(160).optional(), @@ -51,6 +59,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: modelSchema.nullable().optional(), environmentId: z.string().uuid().optional(), targetProvider: z .enum(['slack', 'discord', 'teams', 'telegram']) @@ -132,6 +141,8 @@ const VALIDATION_ERROR_PATTERNS: RegExp[] = [ /^Cron expression must be at most \d+ characters\.$/, /^Cron expression must be between 1 and \d+ characters\.$/, /^Use a standard five-field cron expression\.$/, + /^Model must be at most \d+ characters\.$/, + /^Model must use provider\/model format\.$/, /^Environment is required\.$/, /^Selected environment was not found\.$/, /^Custom automation was not found\.$/, @@ -313,6 +324,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), @@ -373,6 +385,11 @@ 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..a9cfde43f 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' ? { @@ -437,6 +444,53 @@ export function CustomAutomationsSection() { + {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} +
@@ -746,6 +769,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..54f4f55d1 100644 --- a/apps/web/src/components/tasks/ModelSelect.tsx +++ b/apps/web/src/components/tasks/ModelSelect.tsx @@ -20,8 +20,19 @@ 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; + /** 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 +// round-trips through this sentinel. +const EMPTY_OPTION_VALUE = '__model-select-empty__'; + function modelOptionLabel(model: { displayName: string; isDefault?: boolean; @@ -35,6 +46,8 @@ export function ModelSelect({ disabled = false, className, ariaLabel = 'Model', + emptyOptionLabel, + size = 'sm', }: ModelSelectProps) { const { data, isPending } = useLaunchTaskModels(); const modelGroups = useMemo(() => { @@ -59,14 +72,19 @@ export function ModelSelect({ return (