Files
hapi/shared/src/cursorCliSku.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

257 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
cursorCliSkuBaseId,
findBestCliSkuForAcpWire,
isCursorAcpCatalogModelId,
isCursorAcpWireModelId,
isCursorCliSkuVariantId,
matchCliSkuToAcpWireId,
parseCursorAvailableModelsFromRejection,
remapStaleCursorModelId
} from './cursorCliSku';
const cursorGrokCatalog = [
{ modelId: 'cursor-grok-4.5-low' },
{ modelId: 'cursor-grok-4.5-medium' },
{ modelId: 'cursor-grok-4.5-high' },
{ modelId: 'cursor-grok-4.5-low-fast' },
{ modelId: 'cursor-grok-4.5-medium-fast' },
{ modelId: 'cursor-grok-4.5-high-fast' },
];
describe('cursorCliSkuBaseId', () => {
it('strips effort/speed suffixes from CLI skus', () => {
expect(cursorCliSkuBaseId('gpt-5.5-high-fast')).toBe('gpt-5.5');
expect(cursorCliSkuBaseId('composer-2.5-fast')).toBe('composer-2.5');
expect(cursorCliSkuBaseId('gpt-5.3-codex-xhigh-fast')).toBe('gpt-5.3-codex');
});
it('keeps wire base ids unchanged', () => {
expect(cursorCliSkuBaseId('composer-2.5[fast=true]')).toBe('composer-2.5');
});
});
describe('matchCliSkuToAcpWireId', () => {
const available = [
{ modelId: 'composer-2.5[fast=true]' },
{ modelId: 'composer-2.5[fast=false]' },
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' }
];
it('returns exact wire matches', () => {
expect(matchCliSkuToAcpWireId('composer-2.5[fast=false]', available)).toBe('composer-2.5[fast=false]');
});
it('maps CLI skus onto the matching ACP wire for the same base', () => {
expect(matchCliSkuToAcpWireId('composer-2.5-fast', available)).toBe('composer-2.5[fast=true]');
expect(matchCliSkuToAcpWireId('gpt-5.5-medium', available)).toBe('gpt-5.5[context=272k,reasoning=medium,fast=false]');
});
it('maps base-only SKU to fast=false when fast variants exist (cursor CLI convention)', () => {
expect(matchCliSkuToAcpWireId('composer-2.5', available)).toBe('composer-2.5[fast=false]');
});
it('still maps base-only SKU when only one variant exists', () => {
expect(matchCliSkuToAcpWireId('composer-2.5', [{ modelId: 'composer-2.5[fast=true]' }])).toBe(
'composer-2.5[fast=true]'
);
});
it('remaps stale grok ACP wires onto live cursor-grok CLI skus', () => {
expect(matchCliSkuToAcpWireId('grok-4.5[fast=false]', cursorGrokCatalog)).toBe(
'cursor-grok-4.5-medium'
);
expect(matchCliSkuToAcpWireId('grok-4.5[fast=true]', cursorGrokCatalog)).toBe(
'cursor-grok-4.5-medium-fast'
);
});
it('rejects unavailable explicit SKU variants when no ACP wires exist', () => {
expect(matchCliSkuToAcpWireId('gpt-5.5-high', [{ modelId: 'gpt-5.5-medium' }])).toBeNull();
});
});
describe('remapStaleCursorModelId', () => {
it('returns exact catalog matches unchanged', () => {
expect(remapStaleCursorModelId('cursor-grok-4.5-medium', cursorGrokCatalog)).toBe(
'cursor-grok-4.5-medium'
);
});
it('maps legacy grok wires using fast hints', () => {
expect(remapStaleCursorModelId('grok-4.5[fast=false]', cursorGrokCatalog)).toBe(
'cursor-grok-4.5-medium'
);
expect(remapStaleCursorModelId('grok-4.5[fast=true]', cursorGrokCatalog)).toBe(
'cursor-grok-4.5-medium-fast'
);
});
it('returns null when no catalog candidate matches', () => {
expect(remapStaleCursorModelId('grok-4.5[fast=false]', [{ modelId: 'composer-2.5' }])).toBeNull();
});
it('remaps when cache still lists the stale legacy wire alongside live CLI skus', () => {
expect(
remapStaleCursorModelId('grok-4.5[fast=false]', [
{ modelId: 'grok-4.5[fast=false]' },
{ modelId: 'cursor-grok-4.5-medium' },
{ modelId: 'cursor-grok-4.5-medium-fast' },
])
).toBe('cursor-grok-4.5-medium');
});
it('prefers any fast SKU over slow medium when medium-fast is absent', () => {
expect(
remapStaleCursorModelId('grok-4.5[fast=true]', [
{ modelId: 'cursor-grok-4.5-medium' },
{ modelId: 'cursor-grok-4.5-high-fast' },
])
).toBe('cursor-grok-4.5-high-fast');
});
});
describe('parseCursorAvailableModelsFromRejection', () => {
it('parses comma-separated ids from stderr', () => {
expect(
parseCursorAvailableModelsFromRejection(
'Cannot use this model: grok-4.5[fast=true]. Available models: auto, cursor-grok-4.5-high-fast, composer-2.5'
)
).toEqual(['cursor-grok-4.5-high-fast', 'composer-2.5']);
});
it('stops at Tip text on the same line as Available models', () => {
expect(
parseCursorAvailableModelsFromRejection(
'Cannot use this model: grok-4.5[fast=true]. Available models: cursor-grok-4.5-medium Tip: run agent --list-models'
)
).toEqual(['cursor-grok-4.5-medium']);
});
it('does not consume following lines after Available models', () => {
expect(
parseCursorAvailableModelsFromRejection(
'Cannot use this model: grok-4.5[fast=true]. Available models: cursor-grok-4.5-high-fast\nTip: use --list-models for full catalog'
)
).toEqual(['cursor-grok-4.5-high-fast']);
});
});
describe('findBestCliSkuForAcpWire', () => {
it('picks the sku that best matches wire params, not the first partial match', () => {
const wire = 'gpt-5.5[context=272k,reasoning=medium,fast=false]';
const best = findBestCliSkuForAcpWire(wire, [
'gpt-5.5-high-fast',
'gpt-5.5-medium',
'gpt-5.5-low'
]);
expect(best).toBe('gpt-5.5-medium');
});
it('prefers base-only sku for fast=false wire over -fast sku', () => {
const wire = 'composer-2.5[fast=false]';
const best = findBestCliSkuForAcpWire(wire, ['composer-2.5', 'composer-2.5-fast']);
expect(best).toBe('composer-2.5');
});
it('prefers -fast sku for fast=true wire over base-only sku', () => {
const wire = 'composer-2.5[fast=true]';
const best = findBestCliSkuForAcpWire(wire, ['composer-2.5', 'composer-2.5-fast']);
expect(best).toBe('composer-2.5-fast');
});
});
describe('round-trip (regression for #883: "selected but no response")', () => {
const acpWires = [
{ modelId: 'composer-2.5[fast=true]' },
{ modelId: 'composer-2.5[fast=false]' }
];
const pickerSkus = ['composer-2.5', 'composer-2.5-fast'];
function simulateRoundTrip(clickedSku: string): { sessionModel: string; radioOn: string | null } {
// CLI side: applyCursorAcpModel → resolveCursorAcpWireId → matchCliSkuToAcpWireId
const sessionModel = matchCliSkuToAcpWireId(clickedSku, acpWires);
if (!sessionModel) {
throw new Error('CLI rejected sku');
}
// Web side after refetch: cursorVariantSelectValue uses findBestCliSkuForAcpWire
const radioOn = findBestCliSkuForAcpWire(sessionModel, pickerSkus);
return { sessionModel, radioOn };
}
it('clicking composer-2.5 (slow) lands on the slow radio, not the fast one', () => {
const result = simulateRoundTrip('composer-2.5');
expect(result.sessionModel).toBe('composer-2.5[fast=false]');
expect(result.radioOn).toBe('composer-2.5');
});
it('clicking composer-2.5-fast lands on the fast radio', () => {
const result = simulateRoundTrip('composer-2.5-fast');
expect(result.sessionModel).toBe('composer-2.5[fast=true]');
expect(result.radioOn).toBe('composer-2.5-fast');
});
it('clicking each picker option lands on a distinct session model (no collapse)', () => {
const slow = simulateRoundTrip('composer-2.5').sessionModel;
const fast = simulateRoundTrip('composer-2.5-fast').sessionModel;
expect(slow).not.toBe(fast);
});
});
describe('isCursorAcpWireModelId', () => {
it('detects parameterized wire ids only', () => {
expect(isCursorAcpWireModelId('gpt-5.5[fast=false]')).toBe(true);
expect(isCursorAcpWireModelId('default[]')).toBe(true);
expect(isCursorAcpWireModelId('composer-2.5')).toBe(false);
expect(isCursorAcpWireModelId('gpt-5.5-high-fast')).toBe(false);
});
});
describe('isCursorCliSkuVariantId', () => {
it('detects effort/speed CLI SKU slugs', () => {
expect(isCursorCliSkuVariantId('gpt-5.5-high-fast')).toBe(true);
expect(isCursorCliSkuVariantId('composer-2.5-fast')).toBe(true);
expect(isCursorCliSkuVariantId('claude-opus-4-8-thinking-high-fast')).toBe(true);
expect(isCursorCliSkuVariantId('composer-2.5')).toBe(false);
expect(isCursorCliSkuVariantId('composer-2.5[fast=true]')).toBe(false);
});
});
describe('isCursorAcpCatalogModelId', () => {
it('accepts parameterized wires and bare non-default ACP bases', () => {
expect(isCursorAcpCatalogModelId('composer-2.5[fast=false]')).toBe(true);
expect(isCursorAcpCatalogModelId('default[]')).toBe(true);
expect(isCursorAcpCatalogModelId('composer-2.5')).toBe(true);
expect(isCursorAcpCatalogModelId('claude-opus-4-8')).toBe(true);
});
it('rejects CLI effort/speed SKUs and default tokens', () => {
expect(isCursorAcpCatalogModelId('gpt-5.5-high-fast')).toBe(false);
expect(isCursorAcpCatalogModelId('composer-2.5-fast')).toBe(false);
expect(isCursorAcpCatalogModelId('default')).toBe(false);
expect(isCursorAcpCatalogModelId('auto')).toBe(false);
expect(isCursorAcpCatalogModelId('')).toBe(false);
});
});
describe('matchCliSkuToAcpWireId with bare ACP catalog (#1129)', () => {
it('maps base SKUs onto bare ACP ids but rejects suffixed variants', () => {
const bare = [{ modelId: 'composer-2.5' }, { modelId: 'gpt-5.5' }];
expect(matchCliSkuToAcpWireId('composer-2.5', bare)).toBe('composer-2.5');
expect(matchCliSkuToAcpWireId('gpt-5.5', bare)).toBe('gpt-5.5');
expect(matchCliSkuToAcpWireId('composer-2.5-fast', bare)).toBeNull();
expect(matchCliSkuToAcpWireId('gpt-5.5-high-fast', bare)).toBeNull();
expect(matchCliSkuToAcpWireId('gpt-5.5-medium', bare)).toBeNull();
});
it('still maps suffixed SKUs when parameterized ACP wires exist', () => {
const wires = [
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' },
{ modelId: 'gpt-5.5[context=272k,reasoning=high,fast=true]' }
];
expect(matchCliSkuToAcpWireId('gpt-5.5-high-fast', wires)).toBe(
'gpt-5.5[context=272k,reasoning=high,fast=true]'
);
});
});