Files
hapi/web/src/lib/cursorModelOptions.test.ts
T
3c83fe58c9 fix(web+cli): Cursor model picker empty on bare ACP ids + nested variant drill-down (#947)
* feat(web): in-place cursor variant drill-down (closes #48)

Rebased onto upstream/main: iOS-style nested picker keeps overlay open on
multi-variant base pick, applies default variant immediately, dismisses on
variant selection; preserves upstream Pi model panels and Codex Fast mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web+cli): accept bare Cursor ACP model ids in picker catalog

Current Cursor ACP returns bare bases (composer-2.5, …) with empty
cliModelSkus. The bracket-only wire gate emptied the catalog so the
picker showed only Default. Treat bare non-default ACP ids as catalog
rows, keep CLI effort/speed SKUs as variants, and widen SKU enrichment
the same way. Closes #1129.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): ignore stale selectedModelVariant during Cursor base drill-down

Only highlight a session variant when it is still among the visible
rows, so a multi-variant base switch uses the new default until parent
state catches up (Codex Minor on #947).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli+shared): do not attach CLI variant SKUs to bare ACP catalogs

Bare ACP bases cannot express effort/speed (apply is model+fast on
parameterized wires). Drop suffixed SKUs unless a base has bracket
wires, and refuse matchCliSkuToAcpWireId collapse onto bare-only rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): serialize Cursor model applies across base/variant picks

Drill-down default apply and a quick variant click could race setModel
RPCs; last-finisher wins. Queue Cursor applies in SessionChat so the
explicit variant cannot be overwritten by a late default.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): align cursor picker auto-row label with upstream Auto

Rebase onto main picked up Default→Auto rename; keep #1129 coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
2026-08-04 10:59:06 +08:00

201 lines
9.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildCursorEffortPickerOptions,
buildCursorModelCatalog,
buildFlatCursorModelPickerOptions,
CURSOR_AUTO_MODEL_LABEL,
cursorCatalogHasMultiVariantBases,
shouldUseCursorDualPickers,
cursorEffortPickerLabel,
cursorModelBaseId,
cursorModelDedupeKey,
cursorModelVariantId,
cursorVariantDisambiguationSuffix,
cursorVariantLabel,
cursorVaryingWireParamKeys,
filterCursorModelOptionsForCompactView,
formatCursorModelPickerLabel,
parseCursorWireParams,
resolveCursorBaseKey,
resolveCursorVariantOptions
} from './cursorModelOptions'
const acpModels = [
{ modelId: 'default[]', name: 'Auto' },
{ modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5 Fast' },
{ modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' },
{ modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]', name: 'Claude Opus 4.8' },
{ modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=low,fast=false]', name: 'Claude Opus 4.8' },
] as const
describe('raw Cursor ACP model splitting', () => {
it('splits wire ids into raw base and variant parts', () => {
expect(cursorModelBaseId('composer-2.5[fast=true]')).toBe('composer-2.5')
expect(cursorModelDedupeKey('composer-2.5-fast')).toBe('composer-2.5-fast')
expect(cursorModelVariantId('composer-2.5[fast=true]')).toBe('fast=true')
expect(cursorVariantLabel('claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]')).toBe(
'thinking=true,context=300k,effort=high,fast=false'
)
})
it('parses raw comma-separated wire parameters without changing labels', () => {
expect(
parseCursorWireParams('claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]')
).toEqual({
thinking: 'true',
context: '300k',
effort: 'high',
fast: 'false',
})
expect(cursorVaryingWireParamKeys([
'composer-2.5[fast=true]',
'composer-2.5[fast=false]',
])).toEqual(['fast'])
})
})
describe('buildCursorModelCatalog', () => {
it('groups exact ACP wire variants by raw base id and sorts model bases', () => {
const catalog = buildCursorModelCatalog([...acpModels])
expect(catalog.baseOptions.map((o) => o.label)).toEqual([
CURSOR_AUTO_MODEL_LABEL,
'claude-opus-4-8',
'composer-2.5',
])
expect(resolveCursorVariantOptions('composer-2.5', catalog).map((v) => v.label)).toEqual([
'fast=true',
'fast=false',
])
})
it('does not merge legacy sku aliases into ACP bases', () => {
const catalog = buildCursorModelCatalog([
{ modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' },
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
])
expect(catalog.baseOptions.map((o) => o.value)).toEqual([
null,
'composer-2.5',
'composer-2.5-fast',
])
})
it('resolves current wire to base and variant list', () => {
const catalog = buildCursorModelCatalog([...acpModels], {
currentModel: 'composer-2.5[fast=true]',
})
expect(resolveCursorBaseKey('composer-2.5[fast=true]', catalog)).toBe('composer-2.5')
expect(resolveCursorVariantOptions('composer-2.5', catalog)).toHaveLength(2)
})
it('ignores Cursor-provided display names for picker labels', () => {
const catalog = buildCursorModelCatalog([
{ modelId: 'gpt-5.3-codex[reasoning=medium,fast=false]', name: 'Codex 5.3' },
], { defaultValue: 'auto' })
expect(catalog.baseOptions.find((entry) => entry.value === 'gpt-5.3-codex')?.label).toBe('gpt-5.3-codex')
})
})
describe('picker labels and modes', () => {
it('formats flat labels as raw base when a base has one variant', () => {
const catalog = buildCursorModelCatalog([
{ modelId: 'claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]', name: 'Claude Opus 4.7' },
{ modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' },
], { defaultValue: 'auto' })
expect(cursorCatalogHasMultiVariantBases(catalog)).toBe(false)
expect(buildFlatCursorModelPickerOptions(catalog, { defaultValue: 'auto' })).toEqual([
{ value: 'auto', label: CURSOR_AUTO_MODEL_LABEL },
{
value: 'claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]',
label: 'claude-opus-4-7 · thinking=true,context=300k,effort=xhigh,fast=false'
},
{ value: 'composer-2.5[fast=true]', label: 'composer-2.5 · fast=true' }
])
})
it('lists a single variant row when a base has one wire', () => {
const catalog = buildCursorModelCatalog([
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' },
], { defaultValue: 'auto' })
expect(buildCursorEffortPickerOptions(resolveCursorVariantOptions('gpt-5.5', catalog))).toEqual([
{
value: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
label: 'context=272k,reasoning=medium,fast=false'
}
])
})
it('shows raw variant labels when a base has multiple variants', () => {
const catalog = buildCursorModelCatalog([
{ modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' },
{ modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' },
], { defaultValue: 'auto' })
expect(buildFlatCursorModelPickerOptions(catalog, { defaultValue: 'auto' })).toEqual([
{ value: 'auto', label: CURSOR_AUTO_MODEL_LABEL },
{ value: 'composer-2.5[fast=true]', label: 'fast=true' },
{ value: 'composer-2.5[fast=false]', label: 'fast=false' },
])
expect(buildCursorEffortPickerOptions(resolveCursorVariantOptions('composer-2.5', catalog))).toEqual([
{ value: 'composer-2.5[fast=true]', label: 'fast=true' },
{ value: 'composer-2.5[fast=false]', label: 'fast=false' },
])
})
it('enables dual pickers when a base has multiple exact wire ids', () => {
const catalog = buildCursorModelCatalog([
{ modelId: 'claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]', name: 'Claude Opus 4.7' },
{ modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' },
{ modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' },
], { defaultValue: 'auto' })
expect(cursorCatalogHasMultiVariantBases(catalog)).toBe(true)
expect(shouldUseCursorDualPickers(catalog, 'composer-2.5[fast=false]')).toBe(true)
})
it('uses raw variant suffixes for compatibility helpers', () => {
expect(cursorEffortPickerLabel('claude-opus-4-8[effort=high,fast=false]', [])).toBe('effort=high,fast=false')
expect(cursorVariantDisambiguationSuffix('claude-opus-4-8[effort=high,fast=false]')).toBe('effort=high,fast=false')
expect(formatCursorModelPickerLabel('composer-2.5[fast=true]', 'ignored')).toBe('composer-2.5 · fast=true')
})
})
describe('filterCursorModelOptionsForCompactView (iOS-style nested picker)', () => {
const options: { value: string | null; label: string }[] = [
{ value: 'auto', label: 'Default' },
{ value: 'claude-fable-5', label: 'claude-fable-5' },
{ value: 'claude-opus-4-7', label: 'claude-opus-4-7' },
{ value: 'composer-2.5', label: 'composer-2.5' },
{ value: 'gpt-5.5', label: 'gpt-5.5' }
]
it('passes the full list through when nothing or Default is selected', () => {
expect(filterCursorModelOptionsForCompactView(options, undefined)).toEqual(options)
expect(filterCursorModelOptionsForCompactView(options, null)).toEqual(options)
expect(filterCursorModelOptionsForCompactView(options, 'auto')).toEqual(options)
})
it('collapses to Default + the selected base when a non-Default base is picked', () => {
expect(filterCursorModelOptionsForCompactView(options, 'claude-fable-5')).toEqual([
{ value: 'auto', label: 'Default' },
{ value: 'claude-fable-5', label: 'claude-fable-5' }
])
})
it('treats `null`-valued Default rows as the Default passthrough', () => {
const withNullDefault: { value: string | null; label: string }[] = [
{ value: null, label: 'Default' },
{ value: 'composer-2.5', label: 'composer-2.5' },
{ value: 'gpt-5.5', label: 'gpt-5.5' }
]
expect(filterCursorModelOptionsForCompactView(withNullDefault, 'gpt-5.5')).toEqual([
{ value: null, label: 'Default' },
{ value: 'gpt-5.5', label: 'gpt-5.5' }
])
})
it('returns just Default when the selected base is not in the option set (catalog drift)', () => {
expect(filterCursorModelOptionsForCompactView(options, 'phantom-model-9')).toEqual([
{ value: 'auto', label: 'Default' }
])
})
})