diff --git a/apps/mobile/src/components/home-composer.tsx b/apps/mobile/src/components/home-composer.tsx index 5e20b5a3..0d7a6a62 100644 --- a/apps/mobile/src/components/home-composer.tsx +++ b/apps/mobile/src/components/home-composer.tsx @@ -22,7 +22,6 @@ import { type ModelOptionDescriptor, type ModelOptionsMap, } from '@nuncio/core/model-options'; -import { modelSupportsFast } from '@nuncio/core/model-effort-options'; import { ProviderIcon, brandForModel, type Brand } from './provider-icon'; import { fetchBranches, @@ -35,6 +34,7 @@ import { import { createSubmitLock } from '../lib/submit-lock'; import { fetchDirectories, type DirListing } from '../lib/fs-api'; import { basename } from '../lib/home-sections'; +import { composerModelOptionDescriptors } from '../lib/model-option-descriptors'; import { Button } from './ui/button'; import { Card } from './ui/card'; import { Text } from './ui/text'; @@ -148,16 +148,10 @@ export function HomeComposer({ onCreated, onAdvanced }: HomeComposerProps) { }, [modelId, models]); const selectableBranchList = useMemo(() => selectableBranches(branches), [branches]); const sheetCopy = sheetMode ? SHEET_COPY[sheetMode] : null; - const modelOptionDescriptors = useMemo(() => { - const base = selectedModel?.options ?? []; - // Some Codex/Cursor models expose `fast` (Priority) only as a variant, not - // as an options descriptor — surface it as a boolean toggle so the picker - // can set it, matching the web (which derives fast from variants). - if (selectedModel && !base.some((d) => d.id === 'fast') && modelSupportsFast(selectedModel)) { - return [...base, { id: 'fast', label: 'Priority', type: 'boolean', defaultValue: false }]; - } - return base; - }, [selectedModel]); + const modelOptionDescriptors = useMemo( + () => composerModelOptionDescriptors(selectedModel), + [selectedModel], + ); const optionsSummary = optionSummaryLabel(modelOptionDescriptors, modelOptions); const canSend = Boolean(prompt.trim() && selectedModel && !busy); diff --git a/apps/mobile/src/lib/model-option-descriptors.spec.ts b/apps/mobile/src/lib/model-option-descriptors.spec.ts new file mode 100644 index 00000000..7eea4c1b --- /dev/null +++ b/apps/mobile/src/lib/model-option-descriptors.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { ModelOptionDescriptor } from '@nuncio/core/model-options'; +import { composerModelOptionDescriptors } from './model-option-descriptors'; + +const reasoning: ModelOptionDescriptor = { + id: 'reasoningEffort', + label: 'Reasoning', + type: 'select', + defaultValue: 'medium', + options: [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium', isDefault: true }, + ], +}; + +const priority: ModelOptionDescriptor = { + id: 'fast', + label: 'Priority', + type: 'boolean', + defaultValue: false, +}; + +describe('composerModelOptionDescriptors', () => { + it('returns an empty list when no model is selected', () => { + expect(composerModelOptionDescriptors(undefined)).toEqual([]); + expect(composerModelOptionDescriptors(null)).toEqual([]); + }); + + it('passes through catalog options unchanged when fast is already declared', () => { + const options = [reasoning, priority]; + expect( + composerModelOptionDescriptors({ + options, + variants: [{ params: [{ id: 'fast', value: 'true' }] }], + }), + ).toEqual(options); + }); + + it('synthesizes a Priority toggle when fast exists only as a fast-only variant', () => { + // Regression for #146 — without this, the Options sheet hid Priority for + // Codex/Cursor models that only advertise fast via variants. + expect( + composerModelOptionDescriptors({ + options: [reasoning], + variants: [{ params: [{ id: 'fast', value: 'true' }] }], + }), + ).toEqual([reasoning, priority]); + }); + + it('synthesizes Priority for a bare fast-only variant model with no options', () => { + expect( + composerModelOptionDescriptors({ + variants: [{ params: [{ id: 'fast', value: 'TRUE' }] }], + }), + ).toEqual([priority]); + }); + + it('does not invent Priority when variants are mixed or absent', () => { + expect( + composerModelOptionDescriptors({ + options: [reasoning], + variants: [ + { params: [{ id: 'fast', value: 'true' }] }, + { params: [{ id: 'reasoningEffort', value: 'high' }] }, + ], + }), + ).toEqual([reasoning]); + + expect(composerModelOptionDescriptors({ options: [reasoning] })).toEqual([reasoning]); + }); +}); diff --git a/apps/mobile/src/lib/model-option-descriptors.ts b/apps/mobile/src/lib/model-option-descriptors.ts new file mode 100644 index 00000000..e09ab92c --- /dev/null +++ b/apps/mobile/src/lib/model-option-descriptors.ts @@ -0,0 +1,20 @@ +import type { ModelOptionDescriptor } from '@nuncio/core/model-options'; +import { modelSupportsFast } from '@nuncio/core/model-effort-options'; + +/** + * Descriptors shown in the home-composer Options sheet. + * + * Some Codex/Cursor models expose `fast` (Priority) only as a variant, not as + * an options descriptor — synthesize a boolean toggle so the picker can set it, + * matching the web (which derives fast from variants). + */ +export function composerModelOptionDescriptors(model: { + options?: ModelOptionDescriptor[]; + variants?: Array<{ params: Array<{ id: string; value: string }> }>; +} | null | undefined): ModelOptionDescriptor[] { + const base = model?.options ?? []; + if (model && !base.some((d) => d.id === 'fast') && modelSupportsFast(model)) { + return [...base, { id: 'fast', label: 'Priority', type: 'boolean', defaultValue: false }]; + } + return base; +} diff --git a/apps/server/test/unit/agents/claude-agent.provider.spec.ts b/apps/server/test/unit/agents/claude-agent.provider.spec.ts index c204914e..4bf5befe 100644 --- a/apps/server/test/unit/agents/claude-agent.provider.spec.ts +++ b/apps/server/test/unit/agents/claude-agent.provider.spec.ts @@ -1399,6 +1399,29 @@ describe('ClaudeAgentProvider', () => { expect(applied).toEqual([{ effortLevel: 'xhigh' }]); }); + it("skips mid-session applyFlagSettings for Codex 'ultra' (not a Claude effortLevel)", async () => { + // Claude Settings.effortLevel has no 'ultra'; Codex-sub catalog rows can + // still carry it historically — mid-session must no-op, not send an invalid flag. + const applied: Array> = []; + provider.queryFactory = () => ({ + async interrupt() {}, + async setModel() {}, + async applyFlagSettings(settings: Record) { + applied.push(settings); + }, + async *[Symbol.asyncIterator](): AsyncIterator { + yield { type: 'system', subtype: 'init', session_id: 't1' }; + yield { type: 'result', subtype: 'success', result: 'ok' }; + }, + }); + const created = sessions.create({ prompt: 'hi', provider: 'claude', model: 'claude:sonnet' }); + await provider.run(created.id, 'hi', { cwd: '/tmp/ws', model: 'claude:sonnet' }); + await provider.setModel(created.id, 'claude:sonnet', { effort: 'ultra' }); + expect(applied).toEqual([]); + await provider.setModel(created.id, 'claude:sonnet', { reasoningEffort: 'ultra' }); + expect(applied).toEqual([]); + }); + it('does not throw on setModel when the query has no applyFlagSettings', async () => { const created = sessions.create({ prompt: 'hi', provider: 'claude', model: 'claude:haiku' }); await provider.run(created.id, 'hi', { cwd: '/tmp/ws', model: 'claude:haiku' }); diff --git a/apps/server/test/unit/agents/codex-model-options.spec.ts b/apps/server/test/unit/agents/codex-model-options.spec.ts index ca0433d6..b31cdf56 100644 --- a/apps/server/test/unit/agents/codex-model-options.spec.ts +++ b/apps/server/test/unit/agents/codex-model-options.spec.ts @@ -44,6 +44,20 @@ describe('codex-model-options', () => { ); expect(option.options?.filter((o) => o.isDefault)).toHaveLength(1); }); + + it('drops ultra when excludeUltra is set (Claude Codex-sub catalog rows)', () => { + // Native Codex CLI can run Multi-agent ultra; Claude-routed Codex-sub cannot. + const option = defaultCodexReasoningEffortOption({ excludeUltra: true }); + expect(option.options?.map((o) => o.id)).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]); + expect(option.options?.some((o) => o.id === 'ultra')).toBe(false); + expect(option.defaultValue).toBe('medium'); + expect(option.options?.find((o) => o.id === 'medium')?.isDefault).toBe(true); + }); }); describe('codexFastOption / defaultCodexModelOptions', () => {