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