diff --git a/apps/desktop/src/app/cron/cron-job-model.test.ts b/apps/desktop/src/app/cron/cron-job-model.test.ts index 97af7ac7b9..839a722cfb 100644 --- a/apps/desktop/src/app/cron/cron-job-model.test.ts +++ b/apps/desktop/src/app/cron/cron-job-model.test.ts @@ -2,14 +2,27 @@ import { describe, expect, it } from 'vitest' import { cronEditorUpdates, + cronModelChoiceValue, jobIsScriptOnly, lastErrorSummary, parseCronDeliveryTargets, + parseCronModelChoiceValue, toggleCronDeliveryTarget, validateCronEditor } from './cron-job-model' import { nextRunOverdueMs } from './job-state' +describe('cron model choice values', () => { + it('round-trips provider and model colons without ambiguous pairs', () => { + const customProvider = cronModelChoiceValue('custom:internlm', 'intern-latest') + const colonModel = cronModelChoiceValue('custom', 'internlm:intern-latest') + + expect(customProvider).not.toBe(colonModel) + expect(parseCronModelChoiceValue(customProvider)).toEqual({ provider: 'custom:internlm', model: 'intern-latest' }) + expect(parseCronModelChoiceValue(colonModel)).toEqual({ provider: 'custom', model: 'internlm:intern-latest' }) + }) +}) + describe('jobIsScriptOnly', () => { it('is true when no_agent is set and a script is present', () => { expect(jobIsScriptOnly({ no_agent: true, script: 'echo hi' })).toBe(true) diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts index cbbe43e29d..bf94470b64 100644 --- a/apps/desktop/src/app/cron/cron-job-model.ts +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -45,6 +45,35 @@ export interface CronEditorSaveValues { schedule: string } +export interface CronModelChoice { + model: string + provider: string +} + +/** Encode the provider/model pair as an opaque Select value without delimiter ambiguity. */ +export function cronModelChoiceValue(provider: string, model: string): string { + return JSON.stringify([provider, model]) +} + +export function parseCronModelChoiceValue(value: string): CronModelChoice | null { + try { + const parsed: unknown = JSON.parse(value) + + if ( + !Array.isArray(parsed) || + parsed.length !== 2 || + typeof parsed[0] !== 'string' || + typeof parsed[1] !== 'string' + ) { + return null + } + + return { provider: parsed[0], model: parsed[1] } + } catch { + return null + } +} + export function parseCronDeliveryTargets(value: string): string[] { const targets = value .split(',') diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index cfd9f9138c..41d6435d61 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -77,9 +77,11 @@ import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, init import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions' import { cronEditorUpdates, + cronModelChoiceValue, jobIsScriptOnly, lastErrorSummary, parseCronDeliveryTargets, + parseCronModelChoiceValue, toggleCronDeliveryTarget, validateCronEditor } from './cron-job-model' @@ -1050,8 +1052,8 @@ function CronEditorDialog({ const [schedule, setSchedule] = useState('') const [schedulePreset, setSchedulePreset] = useState('daily') const [deliver, setDeliver] = useState(DEFAULT_DELIVER) - // Per-job model override, encoded as `${providerSlug}:${model}` (split on the - // first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default. + // Per-job model override encoded as an opaque provider/model pair. + // MODEL_DEFAULT_VALUE = follow the global default. const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE) // Blueprint fills typed slots (time/enum/weekdays/text) instead of the raw // cron fields; the backend renders the prompt + schedule from them. @@ -1106,7 +1108,9 @@ function CronEditorDialog({ setSchedule(initial ? jobScheduleExpr(initial) : (SCHEDULE_OPTIONS[0].expr ?? '')) setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily') setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER) - setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE) + setModelChoice( + initial && jobModel(initial) ? cronModelChoiceValue(jobProvider(initial), jobModel(initial)) : MODEL_DEFAULT_VALUE + ) setSlotValues({}) setTemplateChoice(editor.mode === 'create' ? (editor.blueprintKey ?? CUSTOM_TEMPLATE) : CUSTOM_TEMPLATE) setError(null) @@ -1149,7 +1153,9 @@ function CronEditorDialog({ // stored pin visible and re-selectable rather than silently dropping it. const modelChoiceKnown = modelChoice === MODEL_DEFAULT_VALUE || - modelProviders.some(provider => (provider.models ?? []).some(model => `${provider.slug}:${model}` === modelChoice)) + modelProviders.some(provider => + (provider.models ?? []).some(model => cronModelChoiceValue(provider.slug, model) === modelChoice) + ) async function handleSubmit(event: React.FormEvent) { event.preventDefault() @@ -1172,11 +1178,7 @@ function CronEditorDialog({ return } - // Decode `${providerSlug}:${model}` — the model half may itself contain - // ':' (e.g. openrouter 'anthropic/claude-sonnet-4:beta'), so split once. - const overrideIndex = modelChoice === MODEL_DEFAULT_VALUE ? -1 : modelChoice.indexOf(':') - const overrideProvider = overrideIndex >= 0 ? modelChoice.slice(0, overrideIndex) : '' - const overrideModel = overrideIndex >= 0 ? modelChoice.slice(overrideIndex + 1) : '' + const override = parseCronModelChoiceValue(modelChoice) setSaving(true) setError(null) @@ -1184,10 +1186,10 @@ function CronEditorDialog({ try { await onSave({ deliver, - model: overrideModel, + model: override?.model ?? '', name: name.trim(), prompt: prompt.trim(), - provider: overrideProvider, + provider: override?.provider ?? '', schedule: schedule.trim() }) } catch (err) { @@ -1357,21 +1359,21 @@ function CronEditorDialog({ {c.modelDefault} {!modelChoiceKnown && ( - {modelChoice.slice(modelChoice.indexOf(':') + 1)} + {parseCronModelChoiceValue(modelChoice)?.model ?? modelChoice} )} {modelProviders.map(provider => ( {provider.name} - {(provider.models ?? []).map(model => ( - - {model} - - ))} + {(provider.models ?? []).map(model => { + const value = cronModelChoiceValue(provider.slug, model) + + return ( + + {model} + + ) + })} ))}