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}
+
+ )
+ })}
))}