From 23766e62df6ea8054cc9724ea44bf4408bc7f16b Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 19 Sep 2026 10:59:55 -0400
Subject: [PATCH 1/3] fix(schedules): fall back to the config default model
when an override is gone
---
backend/test/services/schedules.test.ts | 52 ++++++
.../src/components/schedules/JobDetailTab.tsx | 7 +-
.../ScheduleJobDialog.model.test.tsx | 157 ++++++++++++++++++
.../schedules/ScheduleJobDialog.tsx | 52 +++---
frontend/src/components/ui/combobox.tsx | 4 +-
frontend/src/hooks/useScheduleModels.ts | 22 +++
.../src/lib/schedules/schedule-model.test.ts | 84 ++++++++++
frontend/src/lib/schedules/schedule-model.ts | 39 +++++
8 files changed, 388 insertions(+), 29 deletions(-)
create mode 100644 frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx
create mode 100644 frontend/src/hooks/useScheduleModels.ts
create mode 100644 frontend/src/lib/schedules/schedule-model.test.ts
create mode 100644 frontend/src/lib/schedules/schedule-model.ts
diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts
index e5252165..4db023c3 100644
--- a/backend/test/services/schedules.test.ts
+++ b/backend/test/services/schedules.test.ts
@@ -261,6 +261,58 @@ describe('ScheduleService', () => {
)
})
+ it('submits the resolved fallback model when the stored job model is gone', async () => {
+ const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never)
+ const runWithSession: ScheduleRun = {
+ ...baseRun,
+ sessionId: 'ses-fallback-model',
+ sessionTitle: 'Scheduled: Weekly engineering summary',
+ logText: 'Run started. Waiting for assistant response...',
+ }
+ mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession)
+ mocks.getScheduleRunById.mockReturnValue(runWithSession)
+ mocks.getScheduleJobById.mockReturnValue({ ...job, model: 'openai/retired' })
+ mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', modelID: 'gpt-5' })
+
+ let capturedPromptBody: string | undefined
+ routeForward(({ path, method, body }) => {
+ if (path === '/session' && method === 'POST') {
+ return Promise.resolve(jsonResponse({ id: 'ses-fallback-model' }))
+ }
+
+ if (path === '/session/ses-fallback-model/prompt_async' && method === 'POST') {
+ capturedPromptBody = body
+ return Promise.resolve(new Response(null, { status: 204 }))
+ }
+
+ if (path === '/session/ses-fallback-model/message' && method === 'GET') {
+ return Promise.resolve(jsonResponse([
+ {
+ info: { role: 'assistant', sessionID: 'ses-fallback-model', time: { completed: Date.now() } },
+ parts: [{ type: 'text', text: 'Done.' }],
+ },
+ ]))
+ }
+
+ throw new Error(`Unexpected proxy request: ${method} ${path}`)
+ })
+
+ await service.runJob(42, 7, 'manual')
+
+ await vi.waitFor(() => {
+ expect(capturedPromptBody).toBeDefined()
+ })
+
+ expect(mocks.resolveOpenCodeModel).toHaveBeenCalledWith(
+ expect.anything(),
+ repo.fullPath,
+ { preferredModel: 'openai/retired' },
+ )
+ expect(JSON.parse(capturedPromptBody!)).toMatchObject({
+ model: { providerID: 'openai', modelID: 'gpt-5' },
+ })
+ })
+
it('sends session and prompt_async JSON POSTs with Content-Type: application/json', async () => {
const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never)
const runWithSession: ScheduleRun = {
diff --git a/frontend/src/components/schedules/JobDetailTab.tsx b/frontend/src/components/schedules/JobDetailTab.tsx
index 53ae80a7..07bf7dc9 100644
--- a/frontend/src/components/schedules/JobDetailTab.tsx
+++ b/frontend/src/components/schedules/JobDetailTab.tsx
@@ -9,6 +9,8 @@ import {
hasSkillMetadata,
} from '@/components/schedules/schedule-utils'
import { Bot, CalendarClock, Clock3, History, Loader2, Pencil, Play, Sparkles, Trash2 } from 'lucide-react'
+import { useScheduleModels } from '@/hooks/useScheduleModels'
+import { resolveScheduleModel } from '@/lib/schedules/schedule-model'
interface JobDetailTabProps {
selectedJob: ScheduleJob | undefined
@@ -33,6 +35,9 @@ export function JobDetailTab({
runningRun,
isJobFetching,
}: JobDetailTabProps) {
+ const { availableModelKeys, configDefaultModel } = useScheduleModels(Boolean(selectedJob))
+ const resolvedModel = resolveScheduleModel(selectedJob?.model, availableModelKeys, configDefaultModel)
+
if (!selectedJob) {
return (
@@ -124,7 +129,7 @@ export function JobDetailTab({
Model
-
{selectedJob.model ?? 'Workspace default'}
+
{resolvedModel ?? 'Workspace default'}
Created
diff --git a/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx
new file mode 100644
index 00000000..129c073b
--- /dev/null
+++ b/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { ScheduleJobDialog } from './ScheduleJobDialog'
+import { makeOpenCodeConfigFile } from '@/test/fixtures/opencode-config'
+import type { ProviderWithModels } from '@/api/providers'
+import type { ScheduleJob } from '@opencode-manager/shared/types'
+
+Element.prototype.scrollIntoView = vi.fn()
+
+const { mockGetProvidersWithModels, mockGetOpenCodeConfig } = vi.hoisted(() => ({
+ mockGetProvidersWithModels: vi.fn(),
+ mockGetOpenCodeConfig: vi.fn(),
+}))
+
+vi.mock('@/hooks/usePromptTemplates', () => ({
+ usePromptTemplates: () => ({ data: [], isLoading: false }),
+ useCreatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
+ useUpdatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
+ useDeletePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
+}))
+
+vi.mock('@/api/providers', () => ({
+ getProvidersWithModels: mockGetProvidersWithModels,
+}))
+
+vi.mock('@/api/opencode', () => ({
+ createOpenCodeClient: () => ({
+ listAgents: () => Promise.resolve([]),
+ getConfig: () => Promise.resolve(null),
+ }),
+}))
+
+vi.mock('@/api/settings', () => ({
+ settingsApi: {
+ getOpenCodeConfig: mockGetOpenCodeConfig,
+ listManagedSkills: () => Promise.resolve([]),
+ },
+}))
+
+vi.mock('@/api/repos', () => ({
+ listRepos: () => Promise.resolve([]),
+ listBranches: () => Promise.resolve({ branches: [], status: { ahead: 0, behind: 0 } }),
+}))
+
+const providers: ProviderWithModels[] = [
+ {
+ id: 'openai',
+ name: 'OpenAI',
+ env: [],
+ models: [{ id: 'gpt-5', key: 'gpt-5', name: 'GPT-5' }],
+ source: 'configured',
+ isConnected: true,
+ },
+]
+
+const config = makeOpenCodeConfigFile({ content: { model: 'openai/gpt-5' } })
+
+function getJob(overrides: Partial
= {}): ScheduleJob {
+ return {
+ id: 1,
+ repoId: 1,
+ name: 'Test Job',
+ description: null,
+ enabled: true,
+ scheduleMode: 'interval',
+ intervalMinutes: 60,
+ cronExpression: null,
+ timezone: null,
+ agentSlug: null,
+ prompt: 'Test prompt',
+ model: null,
+ skillMetadata: null,
+ permissionConfig: null,
+ branch: null,
+ createdAt: 1,
+ updatedAt: 1,
+ lastRunAt: null,
+ nextRunAt: null,
+ ...overrides,
+ }
+}
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ )
+}
+
+describe('ScheduleJobDialog — model fallback', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetProvidersWithModels.mockResolvedValue(providers)
+ mockGetOpenCodeConfig.mockResolvedValue(config)
+ })
+
+ it('prefills the config default when the stored model no longer exists', async () => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ await waitFor(() => expect(screen.getByDisplayValue('GPT-5')).toBeInTheDocument())
+ })
+
+ it('saves the resolved config default for a stale model', async () => {
+ const user = userEvent.setup()
+ const onSubmit = vi.fn()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ await waitFor(() => expect(screen.getByDisplayValue('GPT-5')).toBeInTheDocument())
+ await user.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ model: 'openai/gpt-5' }))
+ })
+
+ it('keeps a stored model that is still available', async () => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ await waitFor(() => expect(screen.getByDisplayValue('GPT-5')).toBeInTheDocument())
+ })
+})
diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx
index 746ad06f..c7dc9708 100644
--- a/frontend/src/components/schedules/ScheduleJobDialog.tsx
+++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import type { CreateScheduleJobRequest, PromptTemplate, ScheduleJob } from '@opencode-manager/shared/types'
-import { useProvidersWithModels } from '@/hooks/useProvidersWithModels'
+import { useScheduleModels } from '@/hooks/useScheduleModels'
+import { resolveScheduleModel } from '@/lib/schedules/schedule-model'
import { createOpenCodeClient } from '@/api/opencode'
import { settingsApi } from '@/api/settings'
import { listRepos, listBranches } from '@/api/repos'
@@ -57,6 +58,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
const [timezone, setTimezone] = useState(getLocalTimeZone())
const [agentSlug, setAgentSlug] = useState('')
const [model, setModel] = useState('')
+ const [modelDirty, setModelDirty] = useState(false)
const [prompt, setPrompt] = useState('')
const [selectedPromptTemplateId, setSelectedPromptTemplateId] = useState(null)
const [skillSlugs, setSkillSlugs] = useState([])
@@ -74,10 +76,12 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
const { data: templates = EMPTY_TEMPLATES } = usePromptTemplates()
const deleteTemplateMutation = useDeletePromptTemplate()
- const { data: providerModels } = useProvidersWithModels({
- enabled: open,
- keyParts: ['schedule-dialog'],
- })
+ const { providerModels, availableModelKeys, configModelCandidates, configDefaultModel } = useScheduleModels(open)
+
+ const resolvedModel = useMemo(
+ () => (modelDirty ? (model.trim() || null) : resolveScheduleModel(model, availableModelKeys, configDefaultModel)),
+ [model, modelDirty, availableModelKeys, configDefaultModel],
+ )
const { data: agents = [] } = useQuery({
queryKey: ['opencode-agents', 'schedule-dialog'],
@@ -96,16 +100,6 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
staleTime: 5 * 60 * 1000,
})
- const { data: openCodeConfig } = useQuery({
- queryKey: ['opencode-config', 'schedule-dialog'],
- queryFn: async () => {
- const client = createOpenCodeClient(OPENCODE_API_ENDPOINT)
- return await client.getConfig()
- },
- enabled: open,
- staleTime: 5 * 60 * 1000,
- })
-
const { data: repos = [] } = useQuery({
queryKey: ['repos'],
queryFn: listRepos,
@@ -156,13 +150,13 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
const configuredModels: ComboboxOption[] = []
const configuredValues = new Set()
- for (const configModel of [openCodeConfig?.model, openCodeConfig?.small_model]) {
- if (!configModel || configuredValues.has(configModel)) continue
+ for (const configModel of configModelCandidates) {
+ if (configuredValues.has(configModel)) continue
configuredValues.add(configModel)
const [providerId, ...modelParts] = configModel.split('/')
const modelId = modelParts.join('/')
const provider = providerModels.find((p) => p.id === providerId)
- const providerModel = provider?.models.find((m) => m.id === modelId)
+ const providerModel = provider?.models.find((m) => (m.key ?? m.id) === modelId)
configuredModels.push({
value: configModel,
label: providerModel?.name || modelId,
@@ -173,17 +167,17 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
const allModels = providerModels.flatMap((provider) =>
provider.models
- .filter((providerModel) => !configuredValues.has(`${provider.id}/${providerModel.id}`))
+ .filter((providerModel) => !configuredValues.has(`${provider.id}/${providerModel.key ?? providerModel.id}`))
.map((providerModel) => ({
- value: `${provider.id}/${providerModel.id}`,
- label: providerModel.name || providerModel.id,
- description: `${provider.id}/${providerModel.id}`,
+ value: `${provider.id}/${providerModel.key ?? providerModel.id}`,
+ label: providerModel.name || providerModel.key || providerModel.id,
+ description: `${provider.id}/${providerModel.key ?? providerModel.id}`,
group: provider.name,
})),
)
return [...configuredModels, ...allModels]
- }, [providerModels, openCodeConfig])
+ }, [providerModels, configModelCandidates])
const agentOptions = useMemo(() => {
return agents.map((agent) => ({
@@ -212,6 +206,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
setTimezone(scheduleDefaults.timezone)
setAgentSlug(job?.agentSlug ?? '')
setModel(job?.model ?? '')
+ setModelDirty(false)
setPrompt(job?.prompt ?? '')
const initialSkillSlugs = job?.skillMetadata?.skillSlugs ?? []
const initialSkillNotes = job?.skillMetadata?.notes ?? ''
@@ -240,6 +235,11 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
setPrompt(template.prompt)
}
+ const handleModelChange = (value: string) => {
+ setModel(value)
+ setModelDirty(true)
+ }
+
const handleSubmit = () => {
const parsedInterval = Number.parseInt(intervalMinutes, 10)
const resolvedCronExpression = buildCronExpressionFromPreset({
@@ -260,7 +260,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
description: description.trim() || undefined,
enabled,
agentSlug: agentSlug.trim() || undefined,
- model: model.trim() || undefined,
+ model: resolvedModel ?? undefined,
prompt: prompt.trim(),
branch: branch.trim() || null,
permissionConfig: {
@@ -332,8 +332,8 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
agentSlug={agentSlug}
onAgentSlugChange={setAgentSlug}
agentOptions={agentOptions}
- model={model}
- onModelChange={setModel}
+ model={resolvedModel ?? ''}
+ onModelChange={handleModelChange}
modelOptions={modelOptions}
enabled={enabled}
onEnabledChange={setEnabled}
diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx
index 6963c36b..b0b16dd9 100644
--- a/frontend/src/components/ui/combobox.tsx
+++ b/frontend/src/components/ui/combobox.tsx
@@ -227,7 +227,7 @@ export function Combobox({
{Object.entries(groupedOptions).map(([group, groupOptions]) => (
{group && (
-
+
{group}
)}
@@ -243,7 +243,7 @@ export function Combobox({
type="button"
onClick={() => handleSelect(option.value)}
className={cn(
- 'w-full px-3 py-2 text-left text-sm transition-colors',
+ 'w-full scroll-mt-7 px-3 py-2 text-left text-sm transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
)}
>
diff --git a/frontend/src/hooks/useScheduleModels.ts b/frontend/src/hooks/useScheduleModels.ts
new file mode 100644
index 00000000..db1e0911
--- /dev/null
+++ b/frontend/src/hooks/useScheduleModels.ts
@@ -0,0 +1,22 @@
+import { useMemo } from 'react'
+import { useProvidersWithModels } from '@/hooks/useProvidersWithModels'
+import { useOpenCodeConfigFile } from '@/hooks/useOpenCodeConfigFile'
+import { buildAvailableModelKeys, getConfigModelCandidates } from '@/lib/schedules/schedule-model'
+
+export function useScheduleModels(enabled: boolean) {
+ const { data: providerModels, isLoading: providersLoading } = useProvidersWithModels({
+ enabled,
+ keyParts: ['schedule-models'],
+ })
+ const { data: configFile, isLoading: configLoading } = useOpenCodeConfigFile(enabled)
+ const availableModelKeys = useMemo(() => buildAvailableModelKeys(providerModels), [providerModels])
+ const configModelCandidates = useMemo(() => getConfigModelCandidates(configFile), [configFile])
+
+ return {
+ providerModels,
+ availableModelKeys,
+ configModelCandidates,
+ configDefaultModel: configModelCandidates[0] ?? null,
+ isLoading: providersLoading || configLoading,
+ }
+}
diff --git a/frontend/src/lib/schedules/schedule-model.test.ts b/frontend/src/lib/schedules/schedule-model.test.ts
new file mode 100644
index 00000000..f3849e58
--- /dev/null
+++ b/frontend/src/lib/schedules/schedule-model.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from 'vitest'
+import {
+ buildAvailableModelKeys,
+ getConfigModelCandidates,
+ resolveScheduleModel,
+} from './schedule-model'
+import type { ProviderWithModels } from '@/api/providers'
+import type { OpenCodeConfigFile } from '@/api/types/settings'
+
+function makeProvider(id: string, models: Array<{ id: string; key?: string }>): ProviderWithModels {
+ return {
+ id,
+ name: id,
+ env: [],
+ models: models.map((model) => ({ ...model, name: model.key ?? model.id })),
+ source: 'builtin',
+ isConnected: true,
+ }
+}
+
+function makeConfigFile(content: Record
): OpenCodeConfigFile {
+ return {
+ path: '/workspace/.config/opencode/opencode.json',
+ content,
+ rawContent: JSON.stringify(content),
+ isValid: true,
+ updatedAt: 0,
+ }
+}
+
+describe('buildAvailableModelKeys', () => {
+ it('collects provider/model keys and backing model ids', () => {
+ const keys = buildAvailableModelKeys([
+ makeProvider('openai', [{ id: 'backing-gpt-5', key: 'gpt-5' }]),
+ makeProvider('anthropic', [{ id: 'claude-sonnet-4' }]),
+ ])
+
+ expect(keys).toEqual(new Set([
+ 'openai/gpt-5',
+ 'openai/backing-gpt-5',
+ 'anthropic/claude-sonnet-4',
+ ]))
+ })
+})
+
+describe('getConfigModelCandidates', () => {
+ it('returns model then small_model, deduped and trimmed', () => {
+ expect(getConfigModelCandidates(makeConfigFile({
+ model: ' openai/gpt-5 ',
+ small_model: 'openai/gpt-5-mini',
+ }))).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
+ })
+
+ it('returns an empty list when config is missing or unset', () => {
+ expect(getConfigModelCandidates(undefined)).toEqual([])
+ expect(getConfigModelCandidates(makeConfigFile({}))).toEqual([])
+ expect(getConfigModelCandidates(makeConfigFile({ model: 42 }))).toEqual([])
+ })
+})
+
+describe('resolveScheduleModel', () => {
+ const available = new Set(['openai/gpt-5', 'openai/gpt-5-mini'])
+
+ it('keeps a stored model that is still available', () => {
+ expect(resolveScheduleModel('openai/gpt-5-mini', available, 'openai/gpt-5')).toBe('openai/gpt-5-mini')
+ })
+
+ it('falls back to the config default when the stored model is gone', () => {
+ expect(resolveScheduleModel('openai/retired', available, 'openai/gpt-5')).toBe('openai/gpt-5')
+ })
+
+ it('returns null when neither the stored model nor the config default is available', () => {
+ expect(resolveScheduleModel('openai/retired', available, 'openai/also-retired')).toBeNull()
+ })
+
+ it('treats an empty stored model as the workspace default', () => {
+ expect(resolveScheduleModel('', available, 'openai/gpt-5')).toBeNull()
+ expect(resolveScheduleModel(null, available, 'openai/gpt-5')).toBeNull()
+ })
+
+ it('keeps the stored model while availability is unknown', () => {
+ expect(resolveScheduleModel('openai/retired', new Set(), 'openai/gpt-5')).toBe('openai/retired')
+ })
+})
diff --git a/frontend/src/lib/schedules/schedule-model.ts b/frontend/src/lib/schedules/schedule-model.ts
new file mode 100644
index 00000000..ecb73e55
--- /dev/null
+++ b/frontend/src/lib/schedules/schedule-model.ts
@@ -0,0 +1,39 @@
+import type { ProviderWithModels } from '@/api/providers'
+import type { OpenCodeConfigFile } from '@/api/types/settings'
+
+function normalizeModel(model: unknown): string | null {
+ if (typeof model !== 'string') return null
+ const trimmed = model.trim()
+ return trimmed ? trimmed : null
+}
+
+export function buildAvailableModelKeys(providers: ProviderWithModels[]): Set {
+ const keys = new Set()
+ for (const provider of providers) {
+ for (const model of provider.models) {
+ keys.add(`${provider.id}/${model.key ?? model.id}`)
+ keys.add(`${provider.id}/${model.id}`)
+ }
+ }
+ return keys
+}
+
+export function getConfigModelCandidates(configFile: OpenCodeConfigFile | undefined): string[] {
+ const content = configFile?.content
+ const candidates = [normalizeModel(content?.model), normalizeModel(content?.small_model)]
+ return [...new Set(candidates.filter((candidate): candidate is string => candidate !== null))]
+}
+
+export function resolveScheduleModel(
+ storedModel: string | null | undefined,
+ availableModelKeys: ReadonlySet,
+ configDefaultModel: string | null | undefined,
+): string | null {
+ const stored = normalizeModel(storedModel)
+ if (!stored) return null
+ if (availableModelKeys.size === 0) return stored
+ if (availableModelKeys.has(stored)) return stored
+ const configDefault = normalizeModel(configDefaultModel)
+ if (configDefault && availableModelKeys.has(configDefault)) return configDefault
+ return null
+}
From 99748c6ee513260bc4343e04a6a0ecf12f829812 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 19 Sep 2026 12:23:35 -0400
Subject: [PATCH 2/3] fix(schedules): never fall back to the configured small
model
---
backend/src/services/opencode-models.ts | 7 +-
backend/test/services/opencode-models.test.ts | 97 +++----------------
.../schedules/ScheduleJobDialog.tsx | 15 ++-
frontend/src/hooks/useScheduleModels.ts | 7 +-
.../src/lib/schedules/schedule-model.test.ts | 18 ++--
frontend/src/lib/schedules/schedule-model.ts | 6 +-
6 files changed, 37 insertions(+), 113 deletions(-)
diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts
index 9438dd33..e2c9c32b 100644
--- a/backend/src/services/opencode-models.ts
+++ b/backend/src/services/opencode-models.ts
@@ -2,7 +2,6 @@ import type { OpenCodeClient } from './opencode/client'
interface OpenCodeConfigResponse {
model?: string
- small_model?: string
}
interface OpenCodeProviderResponse {
@@ -76,7 +75,6 @@ export async function resolveOpenCodeModel(
directory: string | undefined,
options?: {
preferredModel?: string | null
- preferSmallModel?: boolean
},
): Promise {
const [config, providersResponse] = await Promise.all([
@@ -86,10 +84,7 @@ export async function resolveOpenCodeModel(
const availableModels = buildAvailableModels(providersResponse)
const defaultModels = providersResponse.default ?? {}
- const configCandidates = options?.preferSmallModel
- ? [config.small_model, config.model]
- : [config.model, config.small_model]
- const candidates = uniqueCandidates([options?.preferredModel, ...configCandidates])
+ const candidates = uniqueCandidates([options?.preferredModel, config.model])
for (const candidate of candidates) {
if (availableModels.has(candidate)) {
diff --git a/backend/test/services/opencode-models.test.ts b/backend/test/services/opencode-models.test.ts
index 14802346..0ac61a1c 100644
--- a/backend/test/services/opencode-models.test.ts
+++ b/backend/test/services/opencode-models.test.ts
@@ -36,75 +36,11 @@ describe('resolveOpenCodeModel', () => {
})
})
- it('falls back to the provider default when the preferred model is unavailable', async () => {
+ it('falls back to the configured model when the preferred model is unavailable', async () => {
const mockClient = {
getJson: vi.fn().mockImplementation((path: string) => {
if (path === '/config') {
- return Promise.resolve({ model: 'openai/gpt-5.4' })
- }
- if (path === '/config/providers') {
- return Promise.resolve({
- providers: [
- { id: 'openai', models: { 'gpt-5.3-codex-spark': {}, 'gpt-5-mini': {} } },
- ],
- default: { openai: 'gpt-5.3-codex-spark' },
- })
- }
- throw new Error(`Unexpected path: ${path}`)
- }),
- } as unknown as OpenCodeClient
-
- const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
- preferredModel: 'openai/gpt-5.4',
- })
-
- expect(result).toEqual({
- providerID: 'openai',
- modelID: 'gpt-5.3-codex-spark',
- model: 'openai/gpt-5.3-codex-spark',
- })
- })
-
- it('prefers the configured small model when requested', async () => {
- const mockClient = {
- getJson: vi.fn().mockImplementation((path: string) => {
- if (path === '/config') {
- return Promise.resolve({
- model: 'openai/gpt-5',
- small_model: 'openai/gpt-5-mini',
- })
- }
- if (path === '/config/providers') {
- return Promise.resolve({
- providers: [
- { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } },
- ],
- default: { openai: 'gpt-5' },
- })
- }
- throw new Error(`Unexpected path: ${path}`)
- }),
- } as unknown as OpenCodeClient
-
- const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
- preferSmallModel: true,
- })
-
- expect(result).toEqual({
- providerID: 'openai',
- modelID: 'gpt-5-mini',
- model: 'openai/gpt-5-mini',
- })
- })
-
- it('falls back to config.model when small_model is unavailable', async () => {
- const mockClient = {
- getJson: vi.fn().mockImplementation((path: string) => {
- if (path === '/config') {
- return Promise.resolve({
- model: 'openai/gpt-5',
- small_model: 'openai/gpt-5-unavailable',
- })
+ return Promise.resolve({ model: 'openai/gpt-5' })
}
if (path === '/config/providers') {
return Promise.resolve({
@@ -119,7 +55,7 @@ describe('resolveOpenCodeModel', () => {
} as unknown as OpenCodeClient
const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
- preferSmallModel: true,
+ preferredModel: 'openai/retired',
})
expect(result).toEqual({
@@ -129,19 +65,16 @@ describe('resolveOpenCodeModel', () => {
})
})
- it('falls back to provider default only after all configured candidates fail', async () => {
+ it('falls back to the provider default when the configured model is unavailable', async () => {
const mockClient = {
getJson: vi.fn().mockImplementation((path: string) => {
if (path === '/config') {
- return Promise.resolve({
- model: 'openai/gpt-5-configured',
- small_model: 'openai/gpt-5-small-unavailable',
- })
+ return Promise.resolve({ model: 'openai/gpt-5-unavailable' })
}
if (path === '/config/providers') {
return Promise.resolve({
providers: [
- { id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {}, 'gpt-5-configured': {} } },
+ { id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {} } },
],
default: { openai: 'gpt-5-mini' },
})
@@ -151,23 +84,23 @@ describe('resolveOpenCodeModel', () => {
} as unknown as OpenCodeClient
const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
- preferSmallModel: true,
+ preferredModel: 'openai/retired',
})
expect(result).toEqual({
providerID: 'openai',
- modelID: 'gpt-5-configured',
- model: 'openai/gpt-5-configured',
+ modelID: 'gpt-5-mini',
+ model: 'openai/gpt-5-mini',
})
})
- it('falls back to provider default when both small_model and model are unavailable', async () => {
+ it('never selects the configured small_model', async () => {
const mockClient = {
getJson: vi.fn().mockImplementation((path: string) => {
if (path === '/config') {
return Promise.resolve({
model: 'openai/gpt-5-unavailable',
- small_model: 'openai/gpt-5-also-unavailable',
+ small_model: 'openai/gpt-5-mini',
})
}
if (path === '/config/providers') {
@@ -175,7 +108,7 @@ describe('resolveOpenCodeModel', () => {
providers: [
{ id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {} } },
],
- default: { openai: 'gpt-5-mini' },
+ default: { openai: 'gpt-5-turbo' },
})
}
throw new Error(`Unexpected path: ${path}`)
@@ -183,13 +116,13 @@ describe('resolveOpenCodeModel', () => {
} as unknown as OpenCodeClient
const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
- preferSmallModel: true,
+ preferredModel: 'openai/retired',
})
expect(result).toEqual({
providerID: 'openai',
- modelID: 'gpt-5-mini',
- model: 'openai/gpt-5-mini',
+ modelID: 'gpt-5-turbo',
+ model: 'openai/gpt-5-turbo',
})
})
diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx
index c7dc9708..c50b1077 100644
--- a/frontend/src/components/schedules/ScheduleJobDialog.tsx
+++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx
@@ -76,7 +76,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
const { data: templates = EMPTY_TEMPLATES } = usePromptTemplates()
const deleteTemplateMutation = useDeletePromptTemplate()
- const { providerModels, availableModelKeys, configModelCandidates, configDefaultModel } = useScheduleModels(open)
+ const { providerModels, availableModelKeys, configDefaultModel } = useScheduleModels(open)
const resolvedModel = useMemo(
() => (modelDirty ? (model.trim() || null) : resolveScheduleModel(model, availableModelKeys, configDefaultModel)),
@@ -150,17 +150,16 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
const configuredModels: ComboboxOption[] = []
const configuredValues = new Set()
- for (const configModel of configModelCandidates) {
- if (configuredValues.has(configModel)) continue
- configuredValues.add(configModel)
- const [providerId, ...modelParts] = configModel.split('/')
+ if (configDefaultModel) {
+ configuredValues.add(configDefaultModel)
+ const [providerId, ...modelParts] = configDefaultModel.split('/')
const modelId = modelParts.join('/')
const provider = providerModels.find((p) => p.id === providerId)
const providerModel = provider?.models.find((m) => (m.key ?? m.id) === modelId)
configuredModels.push({
- value: configModel,
+ value: configDefaultModel,
label: providerModel?.name || modelId,
- description: configModel,
+ description: configDefaultModel,
group: 'Configured',
})
}
@@ -177,7 +176,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
)
return [...configuredModels, ...allModels]
- }, [providerModels, configModelCandidates])
+ }, [providerModels, configDefaultModel])
const agentOptions = useMemo(() => {
return agents.map((agent) => ({
diff --git a/frontend/src/hooks/useScheduleModels.ts b/frontend/src/hooks/useScheduleModels.ts
index db1e0911..ec6f8b86 100644
--- a/frontend/src/hooks/useScheduleModels.ts
+++ b/frontend/src/hooks/useScheduleModels.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { useProvidersWithModels } from '@/hooks/useProvidersWithModels'
import { useOpenCodeConfigFile } from '@/hooks/useOpenCodeConfigFile'
-import { buildAvailableModelKeys, getConfigModelCandidates } from '@/lib/schedules/schedule-model'
+import { buildAvailableModelKeys, getConfigDefaultModel } from '@/lib/schedules/schedule-model'
export function useScheduleModels(enabled: boolean) {
const { data: providerModels, isLoading: providersLoading } = useProvidersWithModels({
@@ -10,13 +10,12 @@ export function useScheduleModels(enabled: boolean) {
})
const { data: configFile, isLoading: configLoading } = useOpenCodeConfigFile(enabled)
const availableModelKeys = useMemo(() => buildAvailableModelKeys(providerModels), [providerModels])
- const configModelCandidates = useMemo(() => getConfigModelCandidates(configFile), [configFile])
+ const configDefaultModel = useMemo(() => getConfigDefaultModel(configFile), [configFile])
return {
providerModels,
availableModelKeys,
- configModelCandidates,
- configDefaultModel: configModelCandidates[0] ?? null,
+ configDefaultModel,
isLoading: providersLoading || configLoading,
}
}
diff --git a/frontend/src/lib/schedules/schedule-model.test.ts b/frontend/src/lib/schedules/schedule-model.test.ts
index f3849e58..c4dcc813 100644
--- a/frontend/src/lib/schedules/schedule-model.test.ts
+++ b/frontend/src/lib/schedules/schedule-model.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
buildAvailableModelKeys,
- getConfigModelCandidates,
+ getConfigDefaultModel,
resolveScheduleModel,
} from './schedule-model'
import type { ProviderWithModels } from '@/api/providers'
@@ -43,18 +43,18 @@ describe('buildAvailableModelKeys', () => {
})
})
-describe('getConfigModelCandidates', () => {
- it('returns model then small_model, deduped and trimmed', () => {
- expect(getConfigModelCandidates(makeConfigFile({
+describe('getConfigDefaultModel', () => {
+ it('returns the configured model, trimmed, and ignores small_model', () => {
+ expect(getConfigDefaultModel(makeConfigFile({
model: ' openai/gpt-5 ',
small_model: 'openai/gpt-5-mini',
- }))).toEqual(['openai/gpt-5', 'openai/gpt-5-mini'])
+ }))).toBe('openai/gpt-5')
})
- it('returns an empty list when config is missing or unset', () => {
- expect(getConfigModelCandidates(undefined)).toEqual([])
- expect(getConfigModelCandidates(makeConfigFile({}))).toEqual([])
- expect(getConfigModelCandidates(makeConfigFile({ model: 42 }))).toEqual([])
+ it('returns null when the model is missing or unset', () => {
+ expect(getConfigDefaultModel(undefined)).toBeNull()
+ expect(getConfigDefaultModel(makeConfigFile({}))).toBeNull()
+ expect(getConfigDefaultModel(makeConfigFile({ model: 42 }))).toBeNull()
})
})
diff --git a/frontend/src/lib/schedules/schedule-model.ts b/frontend/src/lib/schedules/schedule-model.ts
index ecb73e55..95a2f80c 100644
--- a/frontend/src/lib/schedules/schedule-model.ts
+++ b/frontend/src/lib/schedules/schedule-model.ts
@@ -18,10 +18,8 @@ export function buildAvailableModelKeys(providers: ProviderWithModels[]): Set candidate !== null))]
+export function getConfigDefaultModel(configFile: OpenCodeConfigFile | undefined): string | null {
+ return normalizeModel(configFile?.content?.model)
}
export function resolveScheduleModel(
From 7cc89af310d19c675f0123fbc78882dcd53da335 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 19 Sep 2026 12:27:28 -0400
Subject: [PATCH 3/3] fix(schedules): distinguish unknown availability and
match configured models by key and id
---
.../ScheduleJobDialog.model.test.tsx | 53 +++++++++++++++++++
.../schedules/ScheduleJobDialog.tsx | 8 ++-
frontend/src/hooks/useScheduleModels.ts | 10 ++--
.../src/lib/schedules/schedule-model.test.ts | 6 ++-
frontend/src/lib/schedules/schedule-model.ts | 4 +-
5 files changed, 73 insertions(+), 8 deletions(-)
diff --git a/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx
index 129c073b..48f9a942 100644
--- a/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx
+++ b/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx
@@ -154,4 +154,57 @@ describe('ScheduleJobDialog — model fallback', () => {
await waitFor(() => expect(screen.getByDisplayValue('GPT-5')).toBeInTheDocument())
})
+
+ it('does not duplicate a configured model referenced by its backing id', async () => {
+ const user = userEvent.setup()
+ mockGetProvidersWithModels.mockResolvedValue([
+ {
+ id: 'openai',
+ name: 'OpenAI',
+ env: [],
+ models: [{ id: 'gpt-5-2025-08-07', key: 'gpt-5', name: 'GPT-5' }],
+ source: 'configured',
+ isConnected: true,
+ },
+ ])
+ mockGetOpenCodeConfig.mockResolvedValue(makeOpenCodeConfigFile({
+ content: { model: 'openai/gpt-5-2025-08-07' },
+ }))
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const modelInput = await screen.findByDisplayValue('GPT-5')
+ await user.click(modelInput)
+
+ await waitFor(() => expect(screen.getAllByText('GPT-5')).toHaveLength(1))
+ })
+
+ it('clears a stale model when availability is confirmed empty', async () => {
+ mockGetProvidersWithModels.mockResolvedValue([])
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ await waitFor(() => expect(screen.queryByDisplayValue('openai/retired')).not.toBeInTheDocument())
+ expect(screen.getByPlaceholderText('Workspace default')).toBeInTheDocument()
+ })
})
diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx
index c50b1077..0b2eeb3d 100644
--- a/frontend/src/components/schedules/ScheduleJobDialog.tsx
+++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx
@@ -151,11 +151,15 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
const configuredValues = new Set()
if (configDefaultModel) {
- configuredValues.add(configDefaultModel)
const [providerId, ...modelParts] = configDefaultModel.split('/')
const modelId = modelParts.join('/')
const provider = providerModels.find((p) => p.id === providerId)
- const providerModel = provider?.models.find((m) => (m.key ?? m.id) === modelId)
+ const providerModel = provider?.models.find((m) => m.key === modelId || m.id === modelId)
+ configuredValues.add(configDefaultModel)
+ if (providerModel) {
+ configuredValues.add(`${providerId}/${providerModel.key ?? providerModel.id}`)
+ configuredValues.add(`${providerId}/${providerModel.id}`)
+ }
configuredModels.push({
value: configDefaultModel,
label: providerModel?.name || modelId,
diff --git a/frontend/src/hooks/useScheduleModels.ts b/frontend/src/hooks/useScheduleModels.ts
index ec6f8b86..3a760e82 100644
--- a/frontend/src/hooks/useScheduleModels.ts
+++ b/frontend/src/hooks/useScheduleModels.ts
@@ -4,18 +4,22 @@ import { useOpenCodeConfigFile } from '@/hooks/useOpenCodeConfigFile'
import { buildAvailableModelKeys, getConfigDefaultModel } from '@/lib/schedules/schedule-model'
export function useScheduleModels(enabled: boolean) {
- const { data: providerModels, isLoading: providersLoading } = useProvidersWithModels({
+ const providersQuery = useProvidersWithModels({
enabled,
keyParts: ['schedule-models'],
})
const { data: configFile, isLoading: configLoading } = useOpenCodeConfigFile(enabled)
- const availableModelKeys = useMemo(() => buildAvailableModelKeys(providerModels), [providerModels])
+ const providerModels = providersQuery.data
+ const availableModelKeys = useMemo(
+ () => (providersQuery.isSuccess ? buildAvailableModelKeys(providerModels) : null),
+ [providersQuery.isSuccess, providerModels],
+ )
const configDefaultModel = useMemo(() => getConfigDefaultModel(configFile), [configFile])
return {
providerModels,
availableModelKeys,
configDefaultModel,
- isLoading: providersLoading || configLoading,
+ isLoading: providersQuery.isLoading || configLoading,
}
}
diff --git a/frontend/src/lib/schedules/schedule-model.test.ts b/frontend/src/lib/schedules/schedule-model.test.ts
index c4dcc813..8db8183c 100644
--- a/frontend/src/lib/schedules/schedule-model.test.ts
+++ b/frontend/src/lib/schedules/schedule-model.test.ts
@@ -79,6 +79,10 @@ describe('resolveScheduleModel', () => {
})
it('keeps the stored model while availability is unknown', () => {
- expect(resolveScheduleModel('openai/retired', new Set(), 'openai/gpt-5')).toBe('openai/retired')
+ expect(resolveScheduleModel('openai/retired', null, 'openai/gpt-5')).toBe('openai/retired')
+ })
+
+ it('drops the stored model when availability is confirmed empty', () => {
+ expect(resolveScheduleModel('openai/retired', new Set(), 'openai/gpt-5')).toBeNull()
})
})
diff --git a/frontend/src/lib/schedules/schedule-model.ts b/frontend/src/lib/schedules/schedule-model.ts
index 95a2f80c..ae79b90b 100644
--- a/frontend/src/lib/schedules/schedule-model.ts
+++ b/frontend/src/lib/schedules/schedule-model.ts
@@ -24,12 +24,12 @@ export function getConfigDefaultModel(configFile: OpenCodeConfigFile | undefined
export function resolveScheduleModel(
storedModel: string | null | undefined,
- availableModelKeys: ReadonlySet,
+ availableModelKeys: ReadonlySet | null,
configDefaultModel: string | null | undefined,
): string | null {
const stored = normalizeModel(storedModel)
if (!stored) return null
- if (availableModelKeys.size === 0) return stored
+ if (availableModelKeys === null) return stored
if (availableModelKeys.has(stored)) return stored
const configDefault = normalizeModel(configDefaultModel)
if (configDefault && availableModelKeys.has(configDefault)) return configDefault