Files
hapi/web/src/lib/sessionChatCursorModel.test.ts
T
ad038bbf2e fix(cursor): merge SKU catalog under ACP lock and refcount agent guard (#835)
Fixes incomplete cliModelSkus while agent acp holds the CLI lock (#831) and
replace single-pid ACP lock with cross-process refcount (#832). Web picker
merges machine/session catalogs and waits for SKU readiness before showing
variant labels.

Fixes #831
Fixes #832

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:30:43 +08:00

312 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildSessionCursorPickerState,
isCursorEffortWireInCatalog,
isSessionCursorCatalogAwaitingSkus,
isSessionCursorCatalogLoading,
isSessionCursorCatalogPending,
isSessionCursorCatalogPendingWithTimeout,
resolveSessionCursorBaseSelectValue,
resolveSessionCursorModelChange
} from '@/lib/sessionChatCursorModel'
const sessionModels = [
{ modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' },
{ modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' }
] as const
describe('resolveSessionCursorModelChange', () => {
const picker = buildSessionCursorPickerState({
sessionModels,
machineModels: [],
sessionModel: 'composer-2.5[fast=true]',
sessionCurrentModelId: 'composer-2.5[fast=true]'
})
it('updates selected base without applying when the base has multiple variants', () => {
const plan = resolveSessionCursorModelChange({
picker,
sessionModel: 'composer-2.5[fast=true]',
cursorSelectedBase: 'composer-2.5',
kind: 'base',
value: 'composer-2.5'
})
expect(plan).toEqual({
ok: true,
wireId: null,
nextSelectedBase: 'composer-2.5',
shouldApply: false
})
})
it('applies a base change when the base has exactly one wire variant', () => {
const singlePicker = buildSessionCursorPickerState({
sessionModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' }],
machineModels: [],
sessionModel: null,
sessionCurrentModelId: null
})
const plan = resolveSessionCursorModelChange({
picker: singlePicker,
sessionModel: null,
cursorSelectedBase: 'auto',
kind: 'base',
value: 'gpt-5.5'
})
expect(plan).toEqual({
ok: true,
wireId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
nextSelectedBase: 'gpt-5.5',
shouldApply: true
})
})
it('accepts exact variant wire ids without matching stale session baseKey', () => {
const plan = resolveSessionCursorModelChange({
picker,
sessionModel: 'composer-2.5[fast=true]',
cursorSelectedBase: 'composer-2.5',
kind: 'effort',
value: 'composer-2.5[fast=false]'
})
expect(plan).toEqual({
ok: true,
wireId: 'composer-2.5[fast=false]',
nextSelectedBase: 'composer-2.5',
shouldApply: true
})
})
it('rejects variant wire ids missing from catalog', () => {
const plan = resolveSessionCursorModelChange({
picker,
sessionModel: 'composer-2.5[fast=true]',
cursorSelectedBase: 'composer-2.5',
kind: 'effort',
value: 'claude-opus-4-8[effort=high]'
})
expect(plan).toEqual({ ok: false, reason: 'effort wire id not in catalog' })
})
it('uses explicit selected base for dual-mode model row highlight', () => {
expect(
resolveSessionCursorBaseSelectValue(picker, 'composer-2.5')
).toBe('composer-2.5')
expect(
resolveSessionCursorBaseSelectValue(picker, 'auto')
).toBe('composer-2.5')
})
it('highlights Default when session has no model even if local base is auto', () => {
const defaultPicker = buildSessionCursorPickerState({
sessionModels,
machineModels: [],
sessionModel: null,
sessionCurrentModelId: null
})
expect(resolveSessionCursorBaseSelectValue(defaultPicker, 'auto')).toBe('auto')
})
})
describe('CLI sku variants in session picker', () => {
it('accepts CLI sku ids attached to an ACP base', () => {
const picker = buildSessionCursorPickerState({
sessionModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' }],
machineModels: [],
cliModelSkus: [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }
],
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
})
const plan = resolveSessionCursorModelChange({
picker,
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cursorSelectedBase: 'gpt-5.5',
kind: 'effort',
value: 'gpt-5.5-high-fast'
})
expect(plan).toMatchObject({
ok: true,
wireId: 'gpt-5.5-high-fast',
shouldApply: true
})
})
})
describe('session cursor catalog readiness', () => {
const dualPicker = buildSessionCursorPickerState({
sessionModels: [
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' },
{ modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' }
],
machineModels: [],
cliModelSkus: [],
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
})
it('waits for session and machine loading', () => {
expect(isSessionCursorCatalogLoading({
sessionLoading: true,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null
})).toBe(true)
expect(isSessionCursorCatalogLoading({
sessionLoading: false,
machineLoading: true,
hasMachineId: true,
sessionError: null,
machineError: null
})).toBe(true)
expect(isSessionCursorCatalogLoading({
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null
})).toBe(false)
})
it('does not wait for machine loading after machine error or without machine id', () => {
expect(isSessionCursorCatalogLoading({
sessionLoading: false,
machineLoading: true,
hasMachineId: true,
sessionError: null,
machineError: 'boom'
})).toBe(false)
expect(isSessionCursorCatalogLoading({
sessionLoading: false,
machineLoading: true,
hasMachineId: false,
sessionError: null,
machineError: null
})).toBe(false)
})
it('awaits SKUs for dual picker when merged catalog is still empty', () => {
expect(isSessionCursorCatalogAwaitingSkus({
sessionLoading: false,
machineLoading: false,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: dualPicker
})).toBe(true)
expect(isSessionCursorCatalogAwaitingSkus({
sessionLoading: false,
machineLoading: false,
sessionError: null,
machineError: null,
mergedSkus: [{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }],
picker: dualPicker
})).toBe(false)
})
it('combines loading and SKU awaiting into pending state', () => {
expect(isSessionCursorCatalogPending({
sessionLoading: false,
machineLoading: true,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: dualPicker
})).toBe(true)
expect(isSessionCursorCatalogPending({
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: dualPicker
})).toBe(true)
expect(isSessionCursorCatalogPending({
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }],
picker: dualPicker
})).toBe(false)
})
it('does not await SKUs for flat picker sessions', () => {
const flatPicker = buildSessionCursorPickerState({
sessionModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }],
machineModels: [],
cliModelSkus: [],
sessionModel: 'composer-2.5[fast=true]',
sessionCurrentModelId: 'composer-2.5[fast=true]'
})
expect(isSessionCursorCatalogPending({
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: flatPicker
})).toBe(false)
})
it('stays pending for loading even when SKU timeout has elapsed', () => {
expect(isSessionCursorCatalogPendingWithTimeout({
sessionLoading: true,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: dualPicker,
awaitingStartedAtMs: 0,
nowMs: 20_000,
timeoutMs: 15_000
})).toBe(true)
})
it('degrades SKU awaiting after timeout while keeping loading pending', () => {
const startedAt = 1_000
const args = {
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [] as const,
picker: dualPicker,
awaitingStartedAtMs: startedAt,
timeoutMs: 15_000
}
expect(isSessionCursorCatalogPendingWithTimeout({
...args,
nowMs: startedAt + 5_000
})).toBe(true)
expect(isSessionCursorCatalogPendingWithTimeout({
...args,
nowMs: startedAt + 15_000
})).toBe(false)
})
})
describe('isCursorEffortWireInCatalog', () => {
it('checks wireToBase membership', () => {
const picker = buildSessionCursorPickerState({
sessionModels,
machineModels: [],
sessionModel: null,
sessionCurrentModelId: null
})
expect(isCursorEffortWireInCatalog('composer-2.5[fast=false]', picker.catalog)).toBe(true)
expect(isCursorEffortWireInCatalog('unknown[fast=true]', picker.catalog)).toBe(false)
})
})