mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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>
This commit is contained in:
co-authored by
Cursor
Debian
parent
00e8fc3a47
commit
3c83fe58c9
@@ -13,6 +13,7 @@ import {
|
||||
cursorVariantDisambiguationSuffix,
|
||||
cursorVariantLabel,
|
||||
cursorVaryingWireParamKeys,
|
||||
filterCursorModelOptionsForCompactView,
|
||||
formatCursorModelPickerLabel,
|
||||
parseCursorWireParams,
|
||||
resolveCursorBaseKey,
|
||||
@@ -156,3 +157,44 @@ describe('picker labels and modes', () => {
|
||||
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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { cursorCliSkuBaseId } from '@hapi/protocol'
|
||||
import {
|
||||
cursorCliSkuBaseId,
|
||||
findBestCliSkuForAcpWire,
|
||||
isCursorAcpCatalogModelId,
|
||||
isCursorAcpWireModelId as isSharedCursorAcpWireModelId
|
||||
} from '@hapi/protocol'
|
||||
import type { CursorModelSummary } from '@/types/api'
|
||||
|
||||
export type CursorModelOption = { value: string | null; label: string }
|
||||
@@ -130,6 +135,57 @@ export function buildCursorEffortPickerOptions(
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Default variant for a base: the first ACP wire row in catalog order, mapped to
|
||||
* the best CLI sku when sku rows replace raw ACP wires in the picker.
|
||||
*/
|
||||
export function resolveDefaultCursorVariantWire(
|
||||
baseKey: string,
|
||||
catalog: CursorModelCatalog
|
||||
): string | null {
|
||||
const pickerVariants = resolveCursorVariantOptions(baseKey, catalog)
|
||||
if (pickerVariants.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (pickerVariants.length === 1) {
|
||||
return pickerVariants[0].wireId
|
||||
}
|
||||
|
||||
const catalogVariants = catalog.variantsByBase.get(baseKey) ?? []
|
||||
const defaultAcp = catalogVariants.find((entry) => isCursorAcpWireModelId(entry.wireId))
|
||||
?? catalogVariants[0]
|
||||
if (pickerVariants.some((entry) => entry.wireId === defaultAcp.wireId)) {
|
||||
return defaultAcp.wireId
|
||||
}
|
||||
|
||||
const bestSku = findBestCliSkuForAcpWire(
|
||||
defaultAcp.wireId,
|
||||
pickerVariants.map((entry) => entry.wireId)
|
||||
)
|
||||
return bestSku ?? pickerVariants[0].wireId
|
||||
}
|
||||
|
||||
/** Variant rows with the base default first (for drill-down picker step). */
|
||||
export function buildCursorEffortPickerOptionsWithDefaultFirst(
|
||||
baseKey: string,
|
||||
catalog: CursorModelCatalog
|
||||
): Array<{ value: string; label: string }> {
|
||||
const variants = resolveCursorVariantOptions(baseKey, catalog)
|
||||
const options = buildCursorEffortPickerOptions(variants)
|
||||
const defaultWire = resolveDefaultCursorVariantWire(baseKey, catalog)
|
||||
if (!defaultWire) {
|
||||
return options
|
||||
}
|
||||
const defaultOption = options.find((option) => option.value === defaultWire)
|
||||
if (!defaultOption) {
|
||||
return options
|
||||
}
|
||||
return [
|
||||
defaultOption,
|
||||
...options.filter((option) => option.value !== defaultWire)
|
||||
]
|
||||
}
|
||||
|
||||
/** Raw suffix for compatibility with older callers/tests. */
|
||||
export function cursorVariantDisambiguationSuffix(modelId: string): string {
|
||||
return cursorVariantLabel(modelId)
|
||||
@@ -180,7 +236,7 @@ export function buildCursorModelCatalog(
|
||||
}
|
||||
|
||||
const normalizedCurrent = normalizeCurrentModel(options?.currentModel)
|
||||
if (normalizedCurrent && isCursorAcpWireModelId(normalizedCurrent) && !wireToBase.has(normalizedCurrent)) {
|
||||
if (normalizedCurrent && isCursorAcpCatalogModelId(normalizedCurrent) && !wireToBase.has(normalizedCurrent)) {
|
||||
addWire(normalizedCurrent)
|
||||
}
|
||||
|
||||
@@ -280,10 +336,9 @@ export function cursorBaseHasMultipleVariants(
|
||||
return (catalog.variantsByBase.get(baseKey)?.length ?? 0) > 1
|
||||
}
|
||||
|
||||
/** ACP wire ids use bracket params; CLI probe slugs (e.g. gpt-5.5-high-fast) are not picker rows. */
|
||||
/** ACP parameterized wire ids use bracket params; re-export shared predicate. */
|
||||
export function isCursorAcpWireModelId(modelId: string): boolean {
|
||||
const trimmed = modelId.trim()
|
||||
return trimmed === 'default[]' || trimmed.includes('[')
|
||||
return isSharedCursorAcpWireModelId(modelId)
|
||||
}
|
||||
|
||||
/** Dual pickers only when at least one base has multiple ACP wire ids. */
|
||||
@@ -337,3 +392,25 @@ export function formatCursorModelPickerLabel(modelId: string, _name?: string | n
|
||||
const variant = cursorModelVariantId(modelId)
|
||||
return variant ? `${base} · ${variant}` : base
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style "configure my model" view: when a non-Default base is selected, hide
|
||||
* every other base row so the user sees Default + selected + (optional Variant
|
||||
* section below). Caller appends a "Change model…" toggle to re-expand.
|
||||
*
|
||||
* Default-row passthrough recognizes both `'auto'` (the in-picker token) and
|
||||
* `null` (the underlying base option value) so callers don't need to normalize.
|
||||
*/
|
||||
export function filterCursorModelOptionsForCompactView(
|
||||
modelOptions: readonly { value: string | null; label: string }[],
|
||||
selectedModelBase: string | null | undefined
|
||||
): readonly { value: string | null; label: string }[] {
|
||||
if (!selectedModelBase || selectedModelBase === 'auto') {
|
||||
return modelOptions
|
||||
}
|
||||
return modelOptions.filter(
|
||||
(option) => option.value === null
|
||||
|| option.value === 'auto'
|
||||
|| option.value === selectedModelBase
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('mergeCursorModelSummaries', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('drops CLI probe slugs without bracket wire params', () => {
|
||||
it('drops CLI effort/speed SKU slugs but keeps bare ACP bases', () => {
|
||||
const merged = mergeCursorModelSummaries(
|
||||
[{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
|
||||
[
|
||||
@@ -39,7 +39,8 @@ describe('mergeCursorModelSummaries', () => {
|
||||
]
|
||||
)
|
||||
expect(merged.map((entry) => entry.modelId)).toEqual([
|
||||
'gpt-5.5[context=272k,reasoning=medium,fast=false]'
|
||||
'gpt-5.5[context=272k,reasoning=medium,fast=false]',
|
||||
'composer-2.5'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -109,6 +110,96 @@ describe('buildCursorPickerState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('live bare ACP catalog (#1129)', () => {
|
||||
/** Live Cursor ACP shape (2026-07-22): bare bases, no brackets, empty cliModelSkus. */
|
||||
const LIVE_BARE_ACP_IDS = [
|
||||
'composer-2',
|
||||
'composer-2.5',
|
||||
'gpt-5.5',
|
||||
'gpt-5.4',
|
||||
'gpt-5.3-codex',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-haiku-4-5',
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3-flash',
|
||||
'grok-4-20',
|
||||
'kimi-k2.5',
|
||||
'o3',
|
||||
'o4-mini',
|
||||
'gpt-4.1',
|
||||
'gpt-4o',
|
||||
'claude-4-sonnet',
|
||||
'claude-4-opus',
|
||||
'claude-3.7-sonnet',
|
||||
'claude-3.5-sonnet',
|
||||
'claude-3.5-haiku',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'deepseek-r1',
|
||||
'deepseek-v3.1',
|
||||
'cheetah',
|
||||
'auto',
|
||||
'default',
|
||||
// Accidental CLI SKU leakage into availableModels must not become a top-level row.
|
||||
'composer-2.5-fast'
|
||||
] as const
|
||||
|
||||
it('builds a non-empty flat picker from live-shaped bare ACP ids', () => {
|
||||
expect(LIVE_BARE_ACP_IDS).toHaveLength(31)
|
||||
const sessionModels = LIVE_BARE_ACP_IDS.map((modelId) => ({ modelId }))
|
||||
const catalog = buildCursorCatalogFromSources({
|
||||
sessionModels,
|
||||
machineModels: [],
|
||||
cliModelSkus: [],
|
||||
currentWireId: 'default',
|
||||
defaultValue: null
|
||||
})
|
||||
const picker = buildCursorPickerState({
|
||||
catalog,
|
||||
currentWireId: 'default',
|
||||
defaultValue: null
|
||||
})
|
||||
|
||||
expect(catalog.variantsByBase.size).toBeGreaterThan(0)
|
||||
expect(picker.modelOptions.length).toBeGreaterThan(1)
|
||||
expect(picker.modelOptions.some((row) => row.value === 'composer-2.5')).toBe(true)
|
||||
expect(picker.modelOptions.some((row) => row.value === 'claude-opus-4-8')).toBe(true)
|
||||
// CLI effort/speed SKUs must not become top-level bases.
|
||||
expect(picker.modelOptions.some((row) => row.value === 'composer-2.5-fast')).toBe(false)
|
||||
// Default tokens stay out of the catalog rows (Default row is synthetic auto).
|
||||
expect(picker.modelOptions.some((row) => row.value === 'default')).toBe(false)
|
||||
expect(picker.modelOptions[0]).toEqual({ value: 'auto', label: 'Auto' })
|
||||
})
|
||||
|
||||
it('attaches CLI SKUs under bare ACP bases for dual/nested variant UX', () => {
|
||||
const catalog = buildCursorCatalogFromSources({
|
||||
sessionModels: [
|
||||
{ modelId: 'composer-2.5' },
|
||||
{ modelId: 'gpt-5.5' }
|
||||
],
|
||||
cliModelSkus: [
|
||||
{ modelId: 'composer-2.5', name: 'Composer 2.5' },
|
||||
{ modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' },
|
||||
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
|
||||
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }
|
||||
],
|
||||
defaultValue: null
|
||||
})
|
||||
const picker = buildCursorPickerState({
|
||||
catalog,
|
||||
currentWireId: 'composer-2.5',
|
||||
defaultValue: null
|
||||
})
|
||||
expect(picker.mode).toBe('dual')
|
||||
expect(picker.modelOptions.some((row) => row.value === 'composer-2.5')).toBe(true)
|
||||
expect(picker.showEffortPicker).toBe(true)
|
||||
expect(picker.effortOptions.some((row) => row.value === 'composer-2.5-fast')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWireIdForBaseChange', () => {
|
||||
it('does not guess when switching to a base with multiple variants', () => {
|
||||
const catalog = buildCursorCatalogFromSources({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isCursorAcpCatalogModelId } from '@hapi/protocol'
|
||||
import type { CursorModelSummary } from '@/types/api'
|
||||
import {
|
||||
appendCliSkusToCatalog,
|
||||
@@ -5,7 +6,6 @@ import {
|
||||
buildCursorModelCatalog,
|
||||
buildFlatCursorModelPickerOptions,
|
||||
cursorModelDedupeKey,
|
||||
isCursorAcpWireModelId,
|
||||
resolveCursorBaseKey,
|
||||
resolveCursorVariantOptions,
|
||||
shouldUseCursorDualPickers,
|
||||
@@ -46,11 +46,14 @@ export type CursorPickerState = {
|
||||
showEffortPicker: boolean
|
||||
}
|
||||
|
||||
/** Only ACP wire ids (and default[]); never CLI probe slugs without bracket params. */
|
||||
/**
|
||||
* ACP catalog rows for the picker: parameterized wires and bare non-default bases.
|
||||
* Never CLI effort/speed SKU slugs (those attach as variants under a base).
|
||||
*/
|
||||
export function pickCursorModelsForPicker(
|
||||
availableModels: readonly CursorModelSummary[]
|
||||
): CursorModelSummary[] {
|
||||
return availableModels.filter((model) => isCursorAcpWireModelId(model.modelId))
|
||||
return availableModels.filter((model) => isCursorAcpCatalogModelId(model.modelId))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +69,7 @@ export function mergeCursorModelSummaries(
|
||||
|
||||
const add = (model: CursorModelSummary) => {
|
||||
const modelId = model.modelId.trim()
|
||||
if (!modelId || !isCursorAcpWireModelId(modelId)) {
|
||||
if (!modelId || !isCursorAcpCatalogModelId(modelId)) {
|
||||
return
|
||||
}
|
||||
if (!merged.has(modelId)) {
|
||||
@@ -82,7 +85,7 @@ export function mergeCursorModelSummaries(
|
||||
}
|
||||
|
||||
const trimmedCurrent = currentWireId?.trim()
|
||||
if (trimmedCurrent && isCursorAcpWireModelId(trimmedCurrent) && !merged.has(trimmedCurrent)) {
|
||||
if (trimmedCurrent && isCursorAcpCatalogModelId(trimmedCurrent) && !merged.has(trimmedCurrent)) {
|
||||
merged.set(trimmedCurrent, { modelId: trimmedCurrent })
|
||||
}
|
||||
|
||||
@@ -105,7 +108,7 @@ export function buildCursorCatalogFromSources(args: {
|
||||
args.machineModels ?? [],
|
||||
wireHint
|
||||
)
|
||||
const injectCurrent = wireHint && isCursorAcpWireModelId(wireHint) ? wireHint : null
|
||||
const injectCurrent = wireHint && isCursorAcpCatalogModelId(wireHint) ? wireHint : null
|
||||
const catalog = buildCursorModelCatalog(pickCursorModelsForPicker(merged), {
|
||||
currentModel: injectCurrent,
|
||||
defaultValue: args.defaultValue
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { appendCliSkusToCatalog, buildCursorEffortPickerOptions, buildCursorModelCatalog, resolveCursorVariantOptions } from '@/lib/cursorModelOptions'
|
||||
import {
|
||||
appendCliSkusToCatalog,
|
||||
buildCursorEffortPickerOptions,
|
||||
buildCursorEffortPickerOptionsWithDefaultFirst,
|
||||
buildCursorModelCatalog,
|
||||
resolveCursorVariantOptions,
|
||||
resolveDefaultCursorVariantWire
|
||||
} from '@/lib/cursorModelOptions'
|
||||
|
||||
describe('resolveCursorVariantOptions with CLI skus', () => {
|
||||
it('omits raw ACP wire row when CLI skus exist for the same base', () => {
|
||||
@@ -19,6 +26,23 @@ describe('resolveCursorVariantOptions with CLI skus', () => {
|
||||
expect(options.some((row) => row.label.includes('context=272k'))).toBe(false)
|
||||
})
|
||||
|
||||
it('puts the default variant first for drill-down picker rows', () => {
|
||||
const catalog = appendCliSkusToCatalog(
|
||||
buildCursorModelCatalog([
|
||||
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }
|
||||
]),
|
||||
[
|
||||
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
|
||||
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }
|
||||
]
|
||||
)
|
||||
expect(resolveDefaultCursorVariantWire('gpt-5.5', catalog)).toBe('gpt-5.5-medium')
|
||||
expect(buildCursorEffortPickerOptionsWithDefaultFirst('gpt-5.5', catalog).map((row) => row.value)).toEqual([
|
||||
'gpt-5.5-medium',
|
||||
'gpt-5.5-high-fast'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps ACP wire rows when no CLI skus are attached', () => {
|
||||
const catalog = buildCursorModelCatalog([
|
||||
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
|
||||
|
||||
@@ -955,6 +955,8 @@ export default {
|
||||
'misc.fastModeStandard': 'Standard',
|
||||
'misc.fastModeFast': 'Fast',
|
||||
'misc.variant': 'Variant',
|
||||
'misc.changeModel': 'Change model…',
|
||||
'misc.backToModelList': '← Models',
|
||||
'misc.loading': 'Loading…',
|
||||
'misc.newMessage': '{n} new message{s}',
|
||||
'misc.loadingMessages': 'Loading messages…',
|
||||
|
||||
@@ -954,6 +954,8 @@ export default {
|
||||
'misc.fastModeStandard': '标准',
|
||||
'misc.fastModeFast': '快速',
|
||||
'misc.variant': '变体',
|
||||
'misc.changeModel': '更换模型…',
|
||||
'misc.backToModelList': '← 模型',
|
||||
'misc.loading': '加载中…',
|
||||
'misc.newMessage': '{n} 条新消息',
|
||||
'misc.loadingMessages': '加载消息中…',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSerialAsyncQueue } from './serialAsyncQueue'
|
||||
|
||||
describe('createSerialAsyncQueue', () => {
|
||||
it('runs enqueued work in order even when the second starts while the first is pending', async () => {
|
||||
const enqueue = createSerialAsyncQueue()
|
||||
const order: number[] = []
|
||||
let releaseFirst!: () => void
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
|
||||
const first = enqueue(async () => {
|
||||
await firstGate
|
||||
order.push(1)
|
||||
})
|
||||
const second = enqueue(async () => {
|
||||
order.push(2)
|
||||
})
|
||||
|
||||
expect(order).toEqual([])
|
||||
releaseFirst()
|
||||
await Promise.all([first, second])
|
||||
expect(order).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('continues after a rejected run', async () => {
|
||||
const enqueue = createSerialAsyncQueue()
|
||||
const order: number[] = []
|
||||
await enqueue(async () => {
|
||||
order.push(1)
|
||||
throw new Error('boom')
|
||||
}).catch(() => undefined)
|
||||
await enqueue(async () => {
|
||||
order.push(2)
|
||||
})
|
||||
expect(order).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* FIFO chain for async work that must not overlap (e.g. Cursor setModel RPCs).
|
||||
* Failures in one run do not break later enqueues.
|
||||
*/
|
||||
export function createSerialAsyncQueue(): (run: () => Promise<void>) => Promise<void> {
|
||||
let chain: Promise<void> = Promise.resolve()
|
||||
return (run) => {
|
||||
const pending = chain.then(run, run)
|
||||
chain = pending.then(() => undefined, () => undefined)
|
||||
return pending
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ describe('resolveSessionCursorModelChange', () => {
|
||||
sessionCurrentModelId: 'composer-2.5[fast=true]'
|
||||
})
|
||||
|
||||
it('updates selected base without applying when the base has multiple variants', () => {
|
||||
it('applies the default variant and keeps base selected when the base has multiple variants', () => {
|
||||
const plan = resolveSessionCursorModelChange({
|
||||
picker,
|
||||
sessionModel: 'composer-2.5[fast=true]',
|
||||
@@ -33,9 +33,9 @@ describe('resolveSessionCursorModelChange', () => {
|
||||
})
|
||||
expect(plan).toEqual({
|
||||
ok: true,
|
||||
wireId: null,
|
||||
wireId: 'composer-2.5[fast=true]',
|
||||
nextSelectedBase: 'composer-2.5',
|
||||
shouldApply: false
|
||||
shouldApply: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,6 +106,54 @@ describe('resolveSessionCursorModelChange', () => {
|
||||
})
|
||||
expect(resolveSessionCursorBaseSelectValue(defaultPicker, 'auto')).toBe('auto')
|
||||
})
|
||||
|
||||
// Cursor ACP without parameterizedModelPicker returns one wire per base = flat picker.
|
||||
// The picker row for 'Default' is value='auto', so the resolver must yield 'auto' when
|
||||
// session.model is null, otherwise HappyComposer's `selectedModelBase === option.value`
|
||||
// check fails for every row and the dropdown looks empty.
|
||||
it('highlights Default in flat-mode picker when session is on ACP default[]', () => {
|
||||
const flatPicker = buildSessionCursorPickerState({
|
||||
sessionModels: [
|
||||
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
|
||||
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }
|
||||
],
|
||||
machineModels: [],
|
||||
sessionModel: null,
|
||||
sessionCurrentModelId: null
|
||||
})
|
||||
expect(flatPicker.mode).toBe('flat')
|
||||
expect(resolveSessionCursorBaseSelectValue(flatPicker, 'auto')).toBe('auto')
|
||||
})
|
||||
|
||||
it('highlights the active wire id in flat-mode picker when session has an explicit model', () => {
|
||||
const flatPicker = buildSessionCursorPickerState({
|
||||
sessionModels: [
|
||||
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
|
||||
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }
|
||||
],
|
||||
machineModels: [],
|
||||
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
|
||||
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
|
||||
})
|
||||
expect(flatPicker.mode).toBe('flat')
|
||||
expect(resolveSessionCursorBaseSelectValue(flatPicker, 'auto'))
|
||||
.toBe('gpt-5.5[context=272k,reasoning=medium,fast=false]')
|
||||
})
|
||||
|
||||
it('highlights bare ACP bases in flat mode (#1129)', () => {
|
||||
const flatPicker = buildSessionCursorPickerState({
|
||||
sessionModels: [
|
||||
{ modelId: 'composer-2.5', name: 'composer-2.5' },
|
||||
{ modelId: 'gpt-5.5', name: 'gpt-5.5' }
|
||||
],
|
||||
machineModels: [],
|
||||
sessionModel: 'composer-2.5',
|
||||
sessionCurrentModelId: 'composer-2.5'
|
||||
})
|
||||
expect(flatPicker.mode).toBe('flat')
|
||||
expect(flatPicker.modelOptions.some((row) => row.value === 'composer-2.5')).toBe(true)
|
||||
expect(resolveSessionCursorBaseSelectValue(flatPicker, 'auto')).toBe('composer-2.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CLI sku variants in session picker', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { findBestCliSkuForAcpWire, matchCliSkuToAcpWireId } from '@hapi/protocol'
|
||||
import type { CursorModelCatalog } from '@/lib/cursorModelOptions'
|
||||
import { resolveCursorVariantOptions, resolveDefaultCursorVariantWire } from '@/lib/cursorModelOptions'
|
||||
import type { CursorModelSummary } from '@/types/api'
|
||||
import {
|
||||
buildCursorCatalogFromSources,
|
||||
@@ -50,6 +51,16 @@ export function resolveSessionCursorModelChange(args: {
|
||||
const base = resolveCursorBaseFromWire(value, picker.catalog)
|
||||
return { ok: true, wireId: value, nextSelectedBase: base, shouldApply: true }
|
||||
}
|
||||
const variants = resolveCursorVariantOptions(value, picker.catalog)
|
||||
if (variants.length > 1) {
|
||||
const defaultWire = resolveDefaultCursorVariantWire(value, picker.catalog)
|
||||
return {
|
||||
ok: true,
|
||||
wireId: defaultWire,
|
||||
nextSelectedBase: value,
|
||||
shouldApply: defaultWire !== null
|
||||
}
|
||||
}
|
||||
const wireId = resolveWireIdForBaseChange(value, picker.catalog, sessionModel)
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user