Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/desktop/src/app/cron/cron-job-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'

import {
cronEditorUpdates,
cronModelChoiceLabel,
jobIsScriptOnly,
jobModelRouting,
MODEL_FLEET_VALUE,
parseCronDeliveryTargets,
toggleCronDeliveryTarget,
validateCronEditor
Expand Down Expand Up @@ -59,6 +62,43 @@ describe('cron delivery targets', () => {
})
})

describe('jobModelRouting', () => {
it('reports pinned jobs with provider and model', () => {
expect(jobModelRouting({ model: 'hermes-4', provider: 'nous' }, null)).toEqual({
kind: 'pinned',
label: 'nous · hermes-4'
})
expect(jobModelRouting({ model: 'hermes-4', provider: '' }, null)).toEqual({ kind: 'pinned', label: 'hermes-4' })
})

it('reports the cron-fleet default when the job is unpinned and a fleet model is configured', () => {
expect(jobModelRouting({ model: '', provider: '' }, { model: 'deepseek-v4-flash', provider: 'opencode-go' })).toEqual(
{ kind: 'fleet', label: 'opencode-go · deepseek-v4-flash' }
)
})

it('reports global default when unpinned and no fleet model is configured', () => {
expect(jobModelRouting({ model: '', provider: '' }, null)).toEqual({ kind: 'global', label: 'Global default' })
expect(jobModelRouting({ model: '', provider: '' }, { model: '', provider: '' })).toEqual({
kind: 'global',
label: 'Global default'
})
})
})

describe('cronModelChoiceLabel', () => {
it('labels the fleet sentinel with the configured cron.model', () => {
expect(cronModelChoiceLabel(MODEL_FLEET_VALUE, { model: 'deepseek-v4-flash', provider: 'opencode-go' })).toBe(
'Fleet default (cron.model): opencode-go · deepseek-v4-flash'
)
})

it('falls back to the plain global default label for the empty slot', () => {
expect(cronModelChoiceLabel(MODEL_FLEET_VALUE, null)).toBe('Fleet default (cron.model)')
expect(cronModelChoiceLabel('anything-else', null)).toBe('Default (global model)')
})
})

describe('cronEditorUpdates', () => {
it('omits prompt when saving a script-only job with an empty prompt', () => {
expect(
Expand Down
52 changes: 51 additions & 1 deletion apps/desktop/src/app/cron/cron-job-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function validateCronEditor(input: CronEditorValidationInput): CronEditor

export interface CronEditorSaveValues {
deliver: string
/** Per-job model override ('' = follow the global default at fire time). */
/** Per-job model override ('' = follow the cron/global default at fire time). */
model: string
name: string
prompt: string
Expand All @@ -45,6 +45,56 @@ export interface CronEditorSaveValues {
schedule: string
}

/**
* Routing status for an agent job's inference model, mirroring the backend's
* fire-time resolution (per-job pin > cron.model > global model.default).
* Drift-guard visibility (#89513): a job with no explicit pin may resolve to
* the cron-fleet default (cron.model) rather than the global chat model.
*/
export type CronModelRoutingKind = 'pinned' | 'fleet' | 'global'

export interface CronFleetConfig {
model: string
provider: string
}

export function jobModelRouting(
job: Pick<CronJob, 'model' | 'provider'>,
fleet: CronFleetConfig | null
): { kind: CronModelRoutingKind; label: string } {
const model = String(job.model ?? '').trim()
const provider = String(job.provider ?? '').trim()

if (model) {
return { kind: 'pinned', label: provider ? `${provider} · ${model}` : model }
}

const fleetModel = fleet?.model?.trim()

if (fleetModel) {
const fleetProvider = fleet?.provider?.trim()

return { kind: 'fleet', label: fleetProvider ? `${fleetProvider} · ${fleetModel}` : fleetModel }
}

return { kind: 'global', label: 'Global default' }
}

/** Sentinel for the cron-fleet default (cron.model) in the model picker. */
export const MODEL_FLEET_VALUE = '__fleet__'

export function cronModelChoiceLabel(choice: string, fleet: CronFleetConfig | null): string {
if (choice === MODEL_FLEET_VALUE) {
const fleetLabel = fleet?.model?.trim() || ''
const fleetProvider = fleet?.provider?.trim()
const rendered = fleetProvider && fleetLabel ? `${fleetProvider} · ${fleetLabel}` : fleetLabel

return rendered ? `Fleet default (cron.model): ${rendered}` : 'Fleet default (cron.model)'
}

return 'Default (global model)'
}

export function parseCronDeliveryTargets(value: string): string[] {
const targets = value
.split(',')
Expand Down
67 changes: 62 additions & 5 deletions apps/desktop/src/app/cron/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { $changeEventsAvailable, $cronChangeTick } from '@/store/live-sync'
import { notify, notifyError } from '@/store/notifications'
import { $profileScope, ALL_PROFILES } from '@/store/profile'

import { useHermesConfigRecord } from '../hooks/use-config-record'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import {
Panel,
Expand All @@ -76,7 +77,11 @@ import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, init
import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions'
import {
cronEditorUpdates,
type CronFleetConfig,
cronModelChoiceLabel,
jobIsScriptOnly,
jobModelRouting,
MODEL_FLEET_VALUE,
parseCronDeliveryTargets,
toggleCronDeliveryTarget,
validateCronEditor
Expand Down Expand Up @@ -799,7 +804,20 @@ function CronJobDetail({
const isPaused = state === 'paused'
const deliver = jobDeliver(job)
const prompt = jobPrompt(job)
const modelOverride = jobModel(job)

// Routing badge: show whether a job's inference model follows its own pin,
// the cron-fleet default (cron.model), or the global default (#89513).
const { data: cronConfig } = useHermesConfigRecord()

const fleetConfig = useMemo<CronFleetConfig | null>(() => {
const cronBlock = (cronConfig ?? {}) as { cron?: { model?: unknown; model_provider?: unknown } }
const model = String(cronBlock.cron?.model ?? '').trim()
const provider = String(cronBlock.cron?.model_provider ?? '').trim()

return model ? { model, provider } : null
}, [cronConfig])

const routing = jobModelRouting(job, fleetConfig)

return (
<PanelDetail>
Expand All @@ -808,6 +826,9 @@ function CronJobDetail({
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{jobTitle(job)}</h3>
<PanelPill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</PanelPill>
<PanelPill tone={routing.kind === 'pinned' ? 'good' : routing.kind === 'fleet' ? 'warn' : 'muted'}>
{routing.kind === 'pinned' ? 'Pinned' : routing.kind === 'fleet' ? 'Fleet default' : 'Global default'}
</PanelPill>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
Expand All @@ -825,7 +846,7 @@ function CronJobDetail({
{ label: c.last.replace(/:$/, ''), value: formatTime(job.last_run_at) },
{ label: c.next.replace(/:$/, ''), value: formatTime(job.next_run_at) },
{ label: c.deliverLabel, value: c.deliveryLabels[deliver] ?? deliver },
...(modelOverride ? [{ label: c.modelLabel, value: modelOverride }] : [])
{ label: c.modelLabel, value: routing.label }
]}
/>

Expand Down Expand Up @@ -1035,6 +1056,19 @@ function CronEditorDialog({
const initial = isEdit ? editor.job : null
const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false

// The cron-fleet default (cron.model / cron.model_provider) routes unpinned
// jobs at fire time (#89513). Read it so the model picker can surface it as
// a selectable routing choice instead of hiding it behind "Default".
const { data: cronConfig } = useHermesConfigRecord()

const fleetConfig = useMemo<CronFleetConfig | null>(() => {
const cronBlock = (cronConfig ?? {}) as { cron?: { model?: unknown; model_provider?: unknown } }
const model = String(cronBlock.cron?.model ?? '').trim()
const provider = String(cronBlock.cron?.model_provider ?? '').trim()

return model ? { model, provider } : null
}, [cronConfig])

const [name, setName] = useState('')
const [prompt, setPrompt] = useState('')
const [schedule, setSchedule] = useState('')
Expand Down Expand Up @@ -1096,12 +1130,21 @@ 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)
// A pinned job shows its pin. Unpinned jobs surface the cron-fleet default
// as the routing choice when one is configured (#89513); otherwise the
// plain global-default sentinel.
setModelChoice(
initial && jobModel(initial)
? `${jobProvider(initial)}:${jobModel(initial)}`
: fleetConfig
? MODEL_FLEET_VALUE
: MODEL_DEFAULT_VALUE
)
setSlotValues({})
setTemplateChoice(editor.mode === 'create' ? (editor.blueprintKey ?? CUSTOM_TEMPLATE) : CUSTOM_TEMPLATE)
setError(null)
setSaving(false)
}, [editor, initial, open])
}, [editor, fleetConfig, initial, open])

// Seed the typed slots with the blueprint's defaults whenever a blueprint is
// picked from "Start from" (and reset them when switching back to Custom).
Expand Down Expand Up @@ -1139,6 +1182,7 @@ function CronEditorDialog({
// stored pin visible and re-selectable rather than silently dropping it.
const modelChoiceKnown =
modelChoice === MODEL_DEFAULT_VALUE ||
modelChoice === MODEL_FLEET_VALUE ||
modelProviders.some(provider => (provider.models ?? []).some(model => `${provider.slug}:${model}` === modelChoice))

async function handleSubmit(event: React.FormEvent) {
Expand All @@ -1164,7 +1208,11 @@ function CronEditorDialog({

// 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(':')
// MODEL_DEFAULT_VALUE / MODEL_FLEET_VALUE both mean "no per-job override";
// the fleet sentinel just labels the routing, it does not persist a pin.
const overrideIndex =
modelChoice === MODEL_DEFAULT_VALUE || modelChoice === MODEL_FLEET_VALUE ? -1 : modelChoice.indexOf(':')

const overrideProvider = overrideIndex >= 0 ? modelChoice.slice(0, overrideIndex) : ''
const overrideModel = overrideIndex >= 0 ? modelChoice.slice(overrideIndex + 1) : ''

Expand Down Expand Up @@ -1344,6 +1392,15 @@ function CronEditorDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* The no-per-job-override rows. Both "no override" — the
job simply isn't pinned — but labeled by which default
actually routes it at fire time: cron.model (fleet) if
configured, otherwise the global model. */}
{fleetConfig && (
<SelectItem value={MODEL_FLEET_VALUE}>
{cronModelChoiceLabel(MODEL_FLEET_VALUE, fleetConfig)}
</SelectItem>
)}
<SelectItem value={MODEL_DEFAULT_VALUE}>{c.modelDefault}</SelectItem>
{!modelChoiceKnown && (
<SelectItem className="font-mono" value={modelChoice}>
Expand Down
88 changes: 86 additions & 2 deletions apps/desktop/src/app/settings/model-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ describe('ModelSettings', () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())

const fastSwitch = await screen.findByRole('switch')
const fastSwitch = await screen.findByRole('switch', { name: 'Fast' })
fireEvent.click(fastSwitch)

await waitFor(() =>
Expand All @@ -291,7 +291,9 @@ describe('ModelSettings', () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())

expect(screen.queryByRole('switch')).toBeNull()
// Fast/speed defaults hidden when the model reports no capabilities; the
// always-present cron drift-guard switch must not be mistaken for it.
expect(screen.queryByRole('switch', { name: 'Fast' })).toBeNull()
})

it('renders the auxiliary task rows', async () => {
Expand Down Expand Up @@ -386,6 +388,88 @@ describe('ModelSettings', () => {
})
})

describe('ModelSettings cron defaults', () => {
it('renders the cron section with the drift guard on by default and saves a toggle', async () => {
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
await renderModelSettings()

expect(await screen.findByText('Cron jobs')).toBeTruthy()

const drift = screen.getByRole('switch', { name: 'Cron drift guard' })
expect(drift).toBeTruthy()

fireEvent.click(drift)

await waitFor(() => expect(saveHermesConfig).toHaveBeenCalled())
const saved = saveHermesConfig.mock.calls[0][0] as Record<string, unknown>
expect(saved).toMatchObject({ cron: { model_drift_guard: false } })
})

it('shows the fleet provider/model dropdowns and saves them via the Save button', async () => {
getGlobalModelOptions.mockResolvedValue({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
{
name: 'OpenRouter',
slug: 'openrouter',
models: ['anthropic/claude-opus-4.8'],
authenticated: true
}
]
})
getHermesConfigRecord.mockResolvedValue({
agent: { reasoning_effort: 'medium', service_tier: 'normal' },
cron: { model: 'hermes-4', model_provider: 'nous', model_drift_guard: false }
})
await renderModelSettings()

expect(await screen.findByText('Cron jobs')).toBeTruthy()

// Provider dropdown shows the saved provider's model pre-populated.
const providerSelect = screen.getByRole('combobox', { name: 'Cron fleet provider' })
expect(providerSelect).toBeTruthy()

fireEvent.click(providerSelect)
const openRouterOption = await screen.findByRole('option', { name: 'OpenRouter' })
fireEvent.click(openRouterOption)

const modelSelect = screen.getByRole('combobox', { name: 'Cron fleet model' })
fireEvent.click(modelSelect)
const claudeOption = await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' })
fireEvent.click(claudeOption)

fireEvent.click(screen.getByRole('button', { name: 'Save' }))

await waitFor(() => expect(saveHermesConfig).toHaveBeenCalled())
const saved = saveHermesConfig.mock.calls.at(-1)![0] as Record<string, unknown>
expect(saved).toMatchObject({
cron: { model: 'anthropic/claude-opus-4.8', model_provider: 'openrouter' }
})
})

it('clears the fleet model/provider via the Clear button', async () => {
getHermesConfigRecord.mockResolvedValue({
agent: { reasoning_effort: 'medium', service_tier: 'normal' },
cron: { model: 'hermes-4', model_provider: 'nous', model_drift_guard: false }
})
await renderModelSettings()

expect(await screen.findByText('Cron jobs')).toBeTruthy()

fireEvent.click(screen.getByRole('button', { name: 'Clear' }))

await waitFor(() => expect(saveHermesConfig).toHaveBeenCalled())
const saved = saveHermesConfig.mock.calls.at(-1)![0] as Record<string, unknown>
expect(saved).toMatchObject({ cron: { model: '', model_provider: '' } })
})
})

describe('ModelSettings MoA preset editor', () => {
const moaConfig = () => ({
default_preset: 'default',
Expand Down
Loading
Loading