Skip to content
Draft
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
16 changes: 5 additions & 11 deletions apps/mobile/src/components/home-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<ModelOptionDescriptor[]>(() => {
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<ModelOptionDescriptor[]>(
() => composerModelOptionDescriptors(selectedModel),
[selectedModel],
);
const optionsSummary = optionSummaryLabel(modelOptionDescriptors, modelOptions);
const canSend = Boolean(prompt.trim() && selectedModel && !busy);

Expand Down
71 changes: 71 additions & 0 deletions apps/mobile/src/lib/model-option-descriptors.spec.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
20 changes: 20 additions & 0 deletions apps/mobile/src/lib/model-option-descriptors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 23 additions & 0 deletions apps/server/test/unit/agents/claude-agent.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
provider.queryFactory = () => ({
async interrupt() {},
async setModel() {},
async applyFlagSettings(settings: Record<string, unknown>) {
applied.push(settings);
},
async *[Symbol.asyncIterator](): AsyncIterator<ClaudeSdkMessage> {
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' });
Expand Down
14 changes: 14 additions & 0 deletions apps/server/test/unit/agents/codex-model-options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading