fix(cursor): support ACP parameterized model picker (#969)

* test: reproduce issue #968

* fix: support Cursor parameterized model picker (closes #968)
This commit is contained in:
SSU-WEI HUANG
2026-06-29 11:41:21 +08:00
committed by GitHub
parent 8493ec92f4
commit 5ade952218
8 changed files with 418 additions and 10 deletions
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
const transportState = vi.hoisted(() => ({
calls: [] as Array<{ method: string; params?: unknown }>
}));
vi.mock('./AcpStdioTransport', () => ({
AcpStdioTransport: class {
constructor(_options: unknown) {}
onNotification = vi.fn();
onStderrError = vi.fn();
registerRequestHandler = vi.fn();
sendRequest = vi.fn(async (method: string, params?: unknown) => {
transportState.calls.push({ method, params });
if (method === 'initialize') {
return { protocolVersion: 1, authMethods: [] };
}
return null;
});
close = vi.fn(async () => {});
}
}));
import { AcpSdkBackend } from './AcpSdkBackend';
describe('AcpSdkBackend.initialize', () => {
it('advertises Cursor-compatible parameterized model picker support', async () => {
const backend = new AcpSdkBackend({ command: 'agent', args: ['acp'] });
await backend.initialize();
expect(transportState.calls).toContainEqual({
method: 'initialize',
params: expect.objectContaining({
clientCapabilities: expect.objectContaining({
_meta: expect.objectContaining({
parameterizedModelPicker: true
})
})
})
});
});
});
+8 -2
View File
@@ -131,7 +131,13 @@ export class AcpSdkBackend implements AgentBackend {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
terminal: false
terminal: false,
_meta: {
// Cursor ACP exposes Composer's non-fast/fast choice as separate
// `model` + `fast` config options only when the client advertises
// this capability. Agents that do not know this metadata ignore it.
parameterizedModelPicker: true
}
},
clientInfo: {
name: 'hapi',
@@ -839,7 +845,7 @@ export class AcpSdkBackend implements AgentBackend {
for (const entry of response.configOptions) {
if (!isObject(entry)) continue;
if (asString(entry.category) !== 'model') continue;
if (asString(entry.category) !== 'model' && asString(entry.id) !== 'model') continue;
return {
currentValue: asString(entry.currentValue),
options: Array.isArray(entry.options) ? entry.options : []
@@ -47,4 +47,50 @@ describe('buildCursorModelsSnapshotFromAcp', () => {
expect(snapshot?.availableModels).toHaveLength(2);
});
it('synthesizes Composer fast variants from parameterized model + fast config options', () => {
const backend = {
getSessionModelsMetadata: () => ({
availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }],
currentModelId: 'composer-2.5'
}),
getConfigOptionByCategory: (_sessionId: string, category: string) => {
if (category === 'model') {
return {
id: 'model',
currentValue: 'composer-2.5',
options: [{ value: 'composer-2.5', name: 'Composer 2.5' }]
};
}
if (category === 'fast') {
return {
id: 'fast',
currentValue: 'false',
options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }]
};
}
return undefined;
},
getSessionConfigOptions: () => [
{
id: 'model',
currentValue: 'composer-2.5',
options: [{ value: 'composer-2.5', name: 'Composer 2.5' }]
},
{
id: 'fast',
currentValue: 'false',
options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }]
}
]
};
const snapshot = buildCursorModelsSnapshotFromAcp(backend, 's1');
expect(snapshot?.availableModels.map((entry) => entry.modelId).sort()).toEqual([
'composer-2.5[fast=false]',
'composer-2.5[fast=true]'
]);
expect(snapshot?.currentModelId).toBe('composer-2.5[fast=false]');
});
});
@@ -6,6 +6,18 @@ export type CursorModelsSnapshot = {
currentModelId: string | null;
};
type CursorAcpModelSnapshotBackend = Pick<AcpSdkBackend, 'getSessionModelsMetadata' | 'getConfigOptionByCategory'>
& Partial<Pick<AcpSdkBackend, 'getSessionConfigOptions'>>;
function findConfigOption(
backend: CursorAcpModelSnapshotBackend,
sessionId: string,
key: string
) {
return backend.getConfigOptionByCategory?.(sessionId, key)
?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key);
}
function mergeModelEntries(
target: Map<string, CursorModelSummary>,
entries: Iterable<{ modelId: string; name?: string | null }>
@@ -31,19 +43,43 @@ function mergeModelEntries(
* `availableModels` alone is often one variant per base family.
*/
export function buildCursorModelsSnapshotFromAcp(
backend: Pick<AcpSdkBackend, 'getSessionModelsMetadata' | 'getConfigOptionByCategory'>,
backend: CursorAcpModelSnapshotBackend,
sessionId: string
): CursorModelsSnapshot | null {
const metadata = backend.getSessionModelsMetadata(sessionId);
const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model');
const modelOption = findConfigOption(backend, sessionId, 'model');
const fastOption = findConfigOption(backend, sessionId, 'fast');
if (!metadata && !modelOption) {
return null;
}
const merged = new Map<string, CursorModelSummary>();
const parameterizedFastModels: CursorModelSummary[] = [];
if (modelOption?.options?.length) {
if (modelOption?.options?.length && fastOption?.options?.length) {
const fastValues = fastOption.options
.map((option) => option.value.trim())
.filter((value) => value === 'false' || value === 'true');
if (fastValues.length > 0) {
for (const option of modelOption.options) {
const modelId = option.value.trim();
if (!modelId || modelId.includes('[')) {
continue;
}
for (const fast of fastValues) {
parameterizedFastModels.push({
modelId: `${modelId}[fast=${fast}]`,
name: option.name
});
}
}
}
}
if (parameterizedFastModels.length > 0) {
mergeModelEntries(merged, parameterizedFastModels);
} else if (modelOption?.options?.length) {
mergeModelEntries(merged, modelOption.options.map((option) => ({
modelId: option.value,
name: option.name
@@ -51,14 +87,21 @@ export function buildCursorModelsSnapshotFromAcp(
}
if (metadata?.availableModels?.length) {
mergeModelEntries(merged, metadata.availableModels);
mergeModelEntries(
merged,
parameterizedFastModels.length > 0
? metadata.availableModels.filter((entry) => entry.modelId.includes('['))
: metadata.availableModels
);
}
if (merged.size === 0) {
return null;
}
const currentModelId = metadata?.currentModelId
const currentModelId = parameterizedFastModels.length > 0 && modelOption?.currentValue && fastOption?.currentValue
? `${modelOption.currentValue}[fast=${fastOption.currentValue}]`
: metadata?.currentModelId
?? modelOption?.currentValue
?? null;
@@ -194,6 +194,109 @@ describe('applyCursorAcpModel', () => {
expect(setConfigOption).toHaveBeenCalledWith('s1', 'model-opt', 'composer-2.5[fast=false]');
});
it('applies parameterized Cursor Composer fast=false for base CLI sku requests', async () => {
const setConfigOption = vi.fn(async () => {});
const backend = mockModelBackend({
setConfigOption,
getSessionModelsMetadata: vi.fn(() => ({
availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }],
currentModelId: 'composer-2.5'
})),
getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => {
if (category === 'model') {
return {
id: 'model',
category: 'model',
currentValue: 'composer-2.5',
options: [{ value: 'composer-2.5', name: 'Composer 2.5' }]
};
}
if (category === 'fast') {
return {
id: 'fast',
category: 'fast',
currentValue: 'true',
options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }]
};
}
return undefined;
})
});
await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({
applied: true,
resolvedWireId: 'composer-2.5[fast=false]',
requestedWireId: 'composer-2.5'
});
expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5');
expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'false');
expect(backend.pinSessionModelWireId).toHaveBeenCalledWith('s1', 'composer-2.5[fast=false]');
});
it('applies parameterized Cursor Composer fast=true for -fast CLI sku requests', async () => {
const setConfigOption = vi.fn(async () => {});
const backend = mockModelBackend({
setConfigOption,
getSessionModelsMetadata: vi.fn(() => ({
availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }],
currentModelId: 'composer-2.5'
})),
getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => {
if (category === 'model') {
return { id: 'model', category: 'model', options: [{ value: 'composer-2.5' }] };
}
if (category === 'fast') {
return { id: 'fast', category: 'fast', options: [{ value: 'false' }, { value: 'true' }] };
}
return undefined;
})
});
await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5-fast')).resolves.toMatchObject({
applied: true,
resolvedWireId: 'composer-2.5[fast=true]',
requestedWireId: 'composer-2.5-fast'
});
expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5');
expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'true');
});
it('does not fall back to base-only model apply when parameterized fast update fails', async () => {
const setConfigOption = vi.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('fast update failed'));
const backend = mockModelBackend({
setConfigOption,
getSessionModelsMetadata: vi.fn(() => ({
availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }],
currentModelId: 'composer-2.5'
})),
getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => {
if (category === 'model') {
return {
id: 'model',
category: 'model',
options: [{ value: 'composer-2.5', name: 'Composer 2.5' }]
};
}
if (category === 'fast') {
return {
id: 'fast',
category: 'fast',
options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }]
};
}
return undefined;
})
});
await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({
applied: false
});
expect(setConfigOption).toHaveBeenCalledTimes(2);
expect(backend.pinSessionModelWireId).not.toHaveBeenCalled();
});
it('retries set_config_option once before failing apply', async () => {
const setConfigOption = vi.fn()
.mockRejectedValueOnce(new Error('transient'))
+68 -1
View File
@@ -1,5 +1,5 @@
import type { CursorPermissionMode } from '@hapi/protocol/types';
import { matchCliSkuToAcpWireId } from '@hapi/protocol';
import { cursorCliSkuBaseId, cursorModelBaseId, matchCliSkuToAcpWireId } from '@hapi/protocol';
import type { AcpSdkBackend } from '@/agent/backends/acp';
import { logger } from '@/ui/logger';
@@ -71,6 +71,9 @@ export type ApplyCursorAcpModelResult = {
requestedWireId?: string;
};
type ConfigOption = NonNullable<ReturnType<AcpSdkBackend['getConfigOptionByCategory']>>;
type ParameterizedCursorModelResult = ApplyCursorAcpModelResult | 'unsupported' | 'failed';
/** Wire id stored on session + keepalive (preserve explicit variant picks). */
export function wireIdForCursorSessionState(requested: string, resolved: string): string {
const trimmed = requested.trim();
@@ -101,6 +104,61 @@ export function resolveCursorAcpWireId(
return matchCliSkuToAcpWireId(trimmed, available);
}
function findConfigOption(backend: AcpSdkBackend, sessionId: string, key: string): ConfigOption | undefined {
return backend.getConfigOptionByCategory?.(sessionId, key)
?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key);
}
function optionHasValue(option: ConfigOption | undefined, value: string): boolean {
return Boolean(option?.options?.some((entry) => entry.value === value));
}
function fastHintForCursorSkuOrWire(modelId: string): 'false' | 'true' {
const lower = modelId.trim().toLowerCase();
const fastMatch = lower.match(/[\[,](?:\s*)fast=(true|false)(?:\s*)[\],]/)
?? lower.match(/\[\s*fast=(true|false)\s*\]/);
if (fastMatch?.[1] === 'true' || fastMatch?.[1] === 'false') {
return fastMatch[1];
}
return lower.includes('-fast') ? 'true' : 'false';
}
function cursorRequestBaseId(modelId: string): string {
return modelId.includes('[')
? cursorModelBaseId(modelId)
: cursorCliSkuBaseId(modelId);
}
async function applyParameterizedCursorModel(
backend: AcpSdkBackend,
sessionId: string,
requested: string
): Promise<ParameterizedCursorModelResult> {
const modelOption = findConfigOption(backend, sessionId, 'model');
const fastOption = findConfigOption(backend, sessionId, 'fast');
if (!modelOption || !fastOption || !backend.setConfigOption) {
return 'unsupported';
}
const baseModel = cursorRequestBaseId(requested);
const fast = fastHintForCursorSkuOrWire(requested);
if (!optionHasValue(modelOption, baseModel) || !optionHasValue(fastOption, fast)) {
return 'unsupported';
}
try {
await backend.setConfigOption(sessionId, modelOption.id, baseModel);
await backend.setConfigOption(sessionId, fastOption.id, fast);
} catch (error) {
logger.debug('[cursor-acp] parameterized model config failed', error);
return 'failed';
}
const resolved = `${baseModel}[fast=${fast}]`;
backend.pinSessionModelWireId(sessionId, resolved);
return { applied: true, resolvedWireId: resolved, requestedWireId: requested };
}
/**
* Apply a model from the live ACP configOptions list (Zed-style).
* Only wire ids present in `availableModels` are accepted.
@@ -118,6 +176,15 @@ export async function applyCursorAcpModel(
const metadata = backend.getSessionModelsMetadata(sessionId);
const available = metadata?.availableModels ?? [];
const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model');
const parameterized = await applyParameterizedCursorModel(backend, sessionId, trimmed);
if (parameterized === 'failed') {
return { applied: false };
}
if (parameterized !== 'unsupported') {
return parameterized;
}
const optionWireIds = modelOption?.options?.map((option) => ({ modelId: option.value })) ?? [];
const catalog = [...available, ...optionWireIds];
const resolved = resolveCursorAcpWireId(trimmed, catalog);
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ModelEffortSettingsSection } from './HappyComposer';
vi.mock('@/lib/use-translation', () => ({
useTranslation: () => ({
t: (key: string) => key === 'misc.variant' ? 'Variant' : key
})
}));
describe('ModelEffortSettingsSection', () => {
it('renders Cursor variant choices and marks the selected variant', () => {
render(
<ModelEffortSettingsSection
agentFlavor="cursor"
options={[
{ value: 'composer-2.5', label: 'Composer 2.5' },
{ value: 'composer-2.5-fast', label: 'Composer 2.5 Fast' }
]}
selectedValue="composer-2.5"
controlsDisabled={false}
onChange={() => {}}
/>
);
expect(screen.getByText('Variant')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Composer 2.5$/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Composer 2.5 Fast/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Composer 2.5$/ }).innerHTML).toContain('bg-[var(--app-link)]');
});
});
@@ -81,6 +81,57 @@ export type ComposerSendError = {
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
export function ModelEffortSettingsSection(props: {
agentFlavor?: string | null
options: Array<{ value: string; label: string }>
selectedValue: string | null | undefined
controlsDisabled: boolean
onChange: (value: string) => void
}) {
const { t } = useTranslation()
const { agentFlavor, options, selectedValue, controlsDisabled, onChange } = props
return (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')}
</div>
{options.map((option) => {
const isSelected = selectedValue === option.value
return (
<button
key={option.value}
type="button"
disabled={controlsDisabled}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
controlsDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
}`}
onClick={() => onChange(option.value)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
isSelected
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={isSelected ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
)
})}
</div>
)
}
export function HappyComposer(props: {
sessionId?: string
disabled?: boolean
@@ -984,6 +1035,24 @@ export function HappyComposer(props: {
</div>
) : null}
{showModelSettings && showModelEffortSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{showModelEffortSettings ? (
<ModelEffortSettingsSection
agentFlavor={agentFlavor}
options={modelEffortOptions!}
selectedValue={selectedModelVariant ?? model}
controlsDisabled={controlsDisabled}
onChange={handleModelEffortChange}
/>
) : null}
{(showModelSettings || showModelEffortSettings) && showModelReasoningEffortSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{(showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings) && showEffortSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}