fix(cli): remap stale Cursor ACP model wires on resume (grok-4.5[fast=…] → cursor-grok-4.5-*) (#1271)

* fix(cli): remap stale Cursor grok wires on ACP resume (#1270)

When hub sessions still store legacy grok-4.5[fast=…] wires, remap to live
cursor-grok-4.5-* catalog ids before spawn and retry once on model_not_found.
Keeps #1198 honest errors when remap cannot find a candidate.

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

* fix(cli): address HAPI bot review on grok wire remap (#1271)

Stop Available-models parsing at newline/Tip; remap legacy wires even when
stale id remains in mixed availableModels+cliModelSkus cache.

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

* fix(shared): rank catalog SKUs by fast hint before effort score

When medium-fast is absent, grok-4.5[fast=true] must not lose to slow
medium just because default effort scoring double-counts medium.

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

* fix(cli): stderr remap fallback + queued model sync (#1271)

Retry model_not_found remaps on the original legacy wire when cache
pre-resolution picked a stale SKU; enqueue user turns from session model.

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

* fix(shared): reject unavailable SKU variants without ACP wires

matchCliSkuToAcpWireId no longer nearest-matches same-base CLI SKUs
when no wire exists; legacy grok remap stays on remapStaleCursorModelId.

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

* fix(cli): suppress transient model rejection on remap retry

Defer surfacing Cannot use this model stderr until initialize/load
retry fails; success path no longer shows a false error in chat.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-31 14:25:51 +01:00
committed by GitHub
co-authored by Cursor
parent 0384a6e837
commit fdd286138a
10 changed files with 672 additions and 66 deletions
+67 -3
View File
@@ -4,6 +4,7 @@ import type { EnhancedMode } from './loop';
const harness = vi.hoisted(() => ({
initializeError: null as Error | null,
initializeAttempts: 0,
loadSessionError: null as Error | null,
supportsLoadSession: true,
loadSessionCalled: false,
@@ -15,7 +16,8 @@ const harness = vi.hoisted(() => ({
deferSetConfigOption: null as Promise<void> | null,
releaseSetConfigOption: null as (() => void) | null,
deferLoadSession: null as Promise<void> | null,
releaseLoadSession: null as (() => void) | null
releaseLoadSession: null as (() => void) | null,
stderrErrorHandler: null as ((error: { type: string; message: string; raw?: string }) => void) | null
}));
const legacyLauncher = vi.hoisted(() => vi.fn());
@@ -35,7 +37,15 @@ vi.mock('./utils/cursorAcpBackend', () => ({
harness.backendArgs = { command: 'agent', args };
return {
initialize: vi.fn(async () => {
if (harness.initializeError) throw harness.initializeError;
harness.initializeAttempts += 1;
if (harness.initializeError && harness.initializeAttempts === 1) {
harness.stderrErrorHandler?.({
type: 'model_not_found',
message: harness.initializeError.message,
raw: harness.initializeError.message
});
throw harness.initializeError;
}
}),
authenticateIfAvailable: vi.fn(async () => {}),
supportsLoadSession: vi.fn(() => harness.supportsLoadSession),
@@ -96,7 +106,9 @@ vi.mock('./utils/cursorAcpBackend', () => ({
}),
cancelPrompt: vi.fn(async () => {}),
respondToPermission: vi.fn(async () => {}),
onStderrError: vi.fn(),
onStderrError: vi.fn((handler) => {
harness.stderrErrorHandler = handler ?? null;
}),
setUsageUpdateListener: vi.fn(),
setSessionInfoUpdateListener: vi.fn(),
refreshSessionInfo: vi.fn(async () => {}),
@@ -181,6 +193,7 @@ function makeClient() {
describe('cursorAcpRemoteLauncher', () => {
beforeEach(() => {
harness.initializeError = null;
harness.initializeAttempts = 0;
harness.loadSessionError = null;
harness.supportsLoadSession = true;
harness.loadSessionCalled = false;
@@ -192,6 +205,7 @@ describe('cursorAcpRemoteLauncher', () => {
harness.releaseSetConfigOption = null;
harness.deferLoadSession = null;
harness.releaseLoadSession = null;
harness.stderrErrorHandler = null;
legacyLauncher.mockClear();
process.stdin.isTTY = false;
process.stdout.isTTY = false;
@@ -280,6 +294,56 @@ describe('cursorAcpRemoteLauncher', () => {
expect(legacyLauncher).not.toHaveBeenCalled();
});
it('remaps stale spawn model and retries initialize once on model rejection', async () => {
harness.initializeError = new Error(
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=false]. Available models: auto, cursor-grok-4.5-medium, cursor-grok-4.5-medium-fast'
);
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
const keepAlive = vi.fn();
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive,
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
api: {} as never,
client,
path: '/tmp/project',
logPath: '/tmp/log',
sessionId: null,
messageQueue: queue,
onModeChange: vi.fn(),
mode: 'remote',
startedBy: 'runner',
startingMode: 'remote',
permissionMode: 'default',
model: 'grok-4.5[fast=false]'
});
session.onSessionFoundWithProtocol = vi.fn();
queue.push('hold-open', { permissionMode: 'default' });
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.initializeAttempts).toBe(2));
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
expect(harness.backendArgs?.args).toContain('cursor-grok-4.5-medium');
expect(keepAlive).toHaveBeenCalled();
expect(
(client.sendAgentMessage as ReturnType<typeof vi.fn>).mock.calls.some((call) =>
JSON.stringify(call[0]).includes('Cannot use this model')
)
).toBe(false);
queue.close();
await runPromise;
});
it('surfaces Cursor model rejection from session/load instead of claiming legacy protocol', async () => {
harness.loadSessionError = new Error(
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=true]. Available models: auto, cursor-grok-4.5-high-fast'
+163 -58
View File
@@ -32,7 +32,12 @@ import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cur
import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels';
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
import type { AcpSdkBackend } from '@/agent/backends/acp';
import type { AcpStderrError } from '@/agent/backends/acp/AcpStdioTransport';
import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle';
import {
resolveCursorSpawnModel,
tryRemapCursorSpawnModelFromConnectError
} from './utils/cursorStaleModelRemap';
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
private readonly session: CursorSession;
private backend: ReturnType<typeof createCursorAcpBackend> | null = null;
@@ -78,41 +83,72 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
const autoReview = isCursorAutoReviewMode(session.getPermissionMode() as PermissionMode);
this.spawnedWithAutoReview = autoReview;
const backend = createCursorAcpBackend({
cwd: session.path,
model: session.model,
autoReview,
worktree: session.cursorWorktree,
addDirs: session.cursorAddDirs
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
this.recordCursorNativeWorktreeMetadata();
backend.setUsageUpdateListener((message) => this.handleAgentMessage(message));
const requestedSpawnModel = session.model;
let spawnModel = resolveCursorSpawnModel(requestedSpawnModel);
let backend: AcpSdkBackend | null = null;
let recentStderrHint: string | null = null;
backend.onStderrError((error) => {
logger.debug('[cursor-acp] stderr error', error);
recentStderrHint = error.raw || error.message;
const converted = convertAgentMessage({ type: 'error', message: error.message });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(error.message, 'status');
});
try {
await backend.initialize();
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
const modelRejection = extractCannotUseThisModelMessage(errMsg)
?? extractCannotUseThisModelMessage(recentStderrHint);
if (modelRejection) {
const fullMsg = classifyCursorAcpLoadError(error, {
recentStderr: recentStderrHint,
action: 'start'
});
for (let connectAttempt = 0; connectAttempt < 2; connectAttempt += 1) {
if (spawnModel && spawnModel !== session.model) {
session.setModel(spawnModel);
session.pushKeepAlive();
this.messageBuffer.addMessage(`[MODEL:${spawnModel}]`, 'system');
}
backend = createCursorAcpBackend({
cwd: session.path,
model: spawnModel,
autoReview,
worktree: session.cursorWorktree,
addDirs: session.cursorAddDirs
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
this.recordCursorNativeWorktreeMetadata();
backend.setUsageUpdateListener((message) => this.handleAgentMessage(message));
recentStderrHint = null;
this.wireStderrErrorListener(backend, (hint) => {
recentStderrHint = hint;
});
try {
await backend.initialize();
break;
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
const remapped = tryRemapCursorSpawnModelFromConnectError(
spawnModel,
requestedSpawnModel,
errMsg,
recentStderrHint
);
await backend.disconnect();
this.backend = null;
if (remapped && connectAttempt === 0) {
logger.info(`[cursor-acp] Remapping stale spawn model ${spawnModel}${remapped}`);
spawnModel = remapped;
continue;
}
const modelRejection = extractCannotUseThisModelMessage(errMsg)
?? extractCannotUseThisModelMessage(recentStderrHint);
if (modelRejection) {
const fullMsg = classifyCursorAcpLoadError(error, {
recentStderr: recentStderrHint,
action: 'start'
});
const converted = convertAgentMessage({ type: 'error', message: fullMsg });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(fullMsg, 'status');
throw new Error(fullMsg);
}
const fullMsg = `${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`;
const converted = convertAgentMessage({ type: 'error', message: fullMsg });
if (converted) {
session.sendAgentMessage(converted);
@@ -120,13 +156,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
messageBuffer.addMessage(fullMsg, 'status');
throw new Error(fullMsg);
}
const fullMsg = `${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`;
const converted = convertAgentMessage({ type: 'error', message: fullMsg });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(fullMsg, 'status');
throw new Error(fullMsg);
}
if (!backend) {
throw new Error(CURSOR_ACP_REQUIRED_MESSAGE);
}
await backend.authenticateIfAvailable('cursor_login');
@@ -148,30 +181,81 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
const resumeSessionId = session.sessionId;
const mcpServerList = toAcpMcpServers(mcpServers);
let acpSessionId: string;
let acpSessionId: string | undefined;
if (resumeSessionId && backend.supportsLoadSession()) {
// Register pending cursorSessionId before awaiting session/load (Zed PR #54431).
session.onSessionFoundWithProtocol(resumeSessionId, 'acp');
try {
acpSessionId = await backend.loadSession({
sessionId: resumeSessionId,
for (let loadAttempt = 0; loadAttempt < 2; loadAttempt += 1) {
if (resumeSessionId && backend.supportsLoadSession()) {
session.onSessionFoundWithProtocol(resumeSessionId, 'acp');
try {
acpSessionId = await backend.loadSession({
sessionId: resumeSessionId,
cwd: session.path,
mcpServers: mcpServerList
});
break;
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
const remapped = tryRemapCursorSpawnModelFromConnectError(
spawnModel,
requestedSpawnModel,
errMsg,
recentStderrHint
);
if (remapped && loadAttempt === 0) {
logger.info(`[cursor-acp] Remapping stale resume model ${spawnModel}${remapped}`);
spawnModel = remapped;
session.setModel(remapped);
session.pushKeepAlive();
this.messageBuffer.addMessage(`[MODEL:${remapped}]`, 'system');
await backend.disconnect();
backend = createCursorAcpBackend({
cwd: session.path,
model: spawnModel,
autoReview,
worktree: session.cursorWorktree,
addDirs: session.cursorAddDirs
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
backend.setUsageUpdateListener((message) => this.handleAgentMessage(message));
recentStderrHint = null;
this.wireStderrErrorListener(backend, (hint) => {
recentStderrHint = hint;
});
await backend.initialize();
await backend.authenticateIfAvailable('cursor_login');
this.extensionAdapter = new CursorExtensionAdapter(
session.client,
backend,
(message) => this.handleAgentMessage(message),
() => this.handleCreatePlanAccepted()
);
this.permissionAdapter = new PermissionAdapter(
session.client,
backend,
() => session.getPermissionMode(),
(response) => this.extensionAdapter!.handlePermissionResponse(response)
);
continue;
}
logger.warn('[cursor-acp] session/load failed', formatAcpLoadError(error));
throw new Error(classifyCursorAcpLoadError(error, { recentStderr: recentStderrHint }));
}
} else if (resumeSessionId) {
throw new Error(
'Cursor ACP session/load is not supported by this agent build. Start a new Cursor session.'
);
} else {
acpSessionId = await backend.newSession({
cwd: session.path,
mcpServers: mcpServerList
});
} catch (error) {
logger.warn('[cursor-acp] session/load failed', formatAcpLoadError(error));
throw new Error(classifyCursorAcpLoadError(error, { recentStderr: recentStderrHint }));
break;
}
} else if (resumeSessionId) {
throw new Error(
'Cursor ACP session/load is not supported by this agent build. Start a new Cursor session.'
);
} else {
acpSessionId = await backend.newSession({
cwd: session.path,
mcpServers: mcpServerList
});
}
if (!acpSessionId) {
throw new Error('Failed to establish Cursor ACP session');
}
this.acpSessionId = acpSessionId;
@@ -323,6 +407,27 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
setCursorAcpModelsSnapshot(null);
}
private wireStderrErrorListener(
backend: AcpSdkBackend,
onHint: (hint: string | null) => void
): void {
const session = this.session;
const messageBuffer = this.messageBuffer;
backend.onStderrError((error: AcpStderrError) => {
logger.debug('[cursor-acp] stderr error', error);
const hint = error.raw || error.message;
onHint(hint);
if (error.type === 'model_not_found' && extractCannotUseThisModelMessage(hint)) {
return;
}
const converted = convertAgentMessage({ type: 'error', message: error.message });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(error.message, 'status');
});
}
private handleCreatePlanAccepted(): void {
const backend = this.backend;
const acpSessionId = this.acpSessionId;
+35
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockCursorSession = vi.hoisted(() => ({
setPermissionMode: vi.fn(),
setModel: vi.fn(),
getModel: vi.fn(() => undefined as string | undefined),
pushKeepAlive: vi.fn(),
stopKeepAlive: vi.fn(),
canApplyModelConfig: vi.fn(() => false)
@@ -86,7 +87,12 @@ vi.mock('@/utils/attachmentFormatter', () => ({
formatMessageWithAttachments: vi.fn((text: string) => text)
}));
vi.mock('./cursorUserMessageQueue', () => ({
enqueueCursorUserMessage: vi.fn()
}));
import { runCursor } from './runCursor';
import { enqueueCursorUserMessage } from './cursorUserMessageQueue';
describe('runCursor', () => {
beforeEach(() => {
@@ -103,6 +109,9 @@ describe('runCursor', () => {
lifecycleMock.setExitCode.mockClear();
lifecycleMock.setArchiveReason.mockClear();
lifecycleMock.setSessionEndReason.mockClear();
mockCursorSession.getModel.mockReset();
mockCursorSession.getModel.mockReturnValue(undefined);
vi.mocked(enqueueCursorUserMessage).mockReset();
});
it('surfaces loop-level ACP failures to the web UI before archiving', async () => {
@@ -118,4 +127,30 @@ describe('runCursor', () => {
expect(lifecycleMock.setSessionEndReason).not.toHaveBeenCalledWith('completed');
expect(lifecycleMock.cleanupAndExit).toHaveBeenCalled();
});
it('queues user turns with the live session model after startup remap', async () => {
mockCursorSession.getModel.mockReturnValue('cursor-grok-4.5-medium');
await runCursor({
startedBy: 'runner',
model: 'grok-4.5[fast=false]'
});
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
| ((msg: { content: { text: string } }, localId?: string) => void)
| undefined;
expect(userMessageHandler).toBeDefined();
userMessageHandler!({ content: { text: 'hello' } }, 'local-1');
expect(enqueueCursorUserMessage).toHaveBeenCalledWith(
expect.anything(),
'hello',
{
permissionMode: 'default',
model: 'cursor-grok-4.5-medium'
},
'local-1'
);
});
});
+2 -1
View File
@@ -98,9 +98,10 @@ export async function runCursor(opts: {
};
session.onUserMessage((message, localId) => {
const queuedModel = sessionWrapperRef.current?.getModel() ?? currentModel;
const enhancedMode: EnhancedMode = {
permissionMode: currentPermissionMode ?? 'default',
model: currentModel
model: queuedModel
};
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
enqueueCursorUserMessage(messageQueue, formattedText, enhancedMode, localId);
@@ -129,6 +129,15 @@ describe('wireIdForCursorSessionState', () => {
).toBe('composer-2.5[fast=false]');
});
it('stores remapped catalog ids when a legacy wire base was upgraded', () => {
expect(
wireIdForCursorSessionState(
'grok-4.5[fast=false]',
'cursor-grok-4.5-medium'
)
).toBe('cursor-grok-4.5-medium');
});
it('uses resolved wire id for base-only requests', () => {
expect(
wireIdForCursorSessionState('composer-2.5', 'composer-2.5[fast=true]')
+5 -1
View File
@@ -1,5 +1,5 @@
import type { CursorPermissionMode } from '@hapi/protocol/types';
import { cursorCliSkuBaseId, cursorModelBaseId, matchCliSkuToAcpWireId } from '@hapi/protocol';
import { cursorCliSkuBaseId, cursorModelBaseId, matchCliSkuToAcpWireId, resolveCursorLegacyModelBase } from '@hapi/protocol';
import type { AcpSdkBackend } from '@/agent/backends/acp';
import { logger } from '@/ui/logger';
@@ -98,6 +98,10 @@ type ParameterizedCursorModelResult = ApplyCursorAcpModelResult | 'unsupported'
export function wireIdForCursorSessionState(requested: string, resolved: string): string {
const trimmed = requested.trim();
if (trimmed.includes('[')) {
const legacyBase = resolveCursorLegacyModelBase(cursorModelBaseId(trimmed));
if (legacyBase !== cursorModelBaseId(trimmed)) {
return resolved;
}
return trimmed;
}
return resolved;
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
_resetSharedCursorModelsCacheForTests,
writeSharedCursorModelsCache
} from '@/modules/common/cursorModelsSharedCache';
import {
resolveCursorSpawnModel,
tryRemapCursorSpawnModelFromConnectError,
tryRemapCursorSpawnModelFromError
} from './cursorStaleModelRemap';
describe('cursorStaleModelRemap', () => {
afterEach(() => {
_resetSharedCursorModelsCacheForTests();
});
it('pre-spawns with a remapped model when shared cache has cursor-grok skus', () => {
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'cursor-grok-4.5-medium' }],
currentModelId: 'cursor-grok-4.5-medium',
cliModelSkus: [
{ modelId: 'cursor-grok-4.5-medium' },
{ modelId: 'cursor-grok-4.5-medium-fast' },
]
});
expect(resolveCursorSpawnModel('grok-4.5[fast=false]')).toBe('cursor-grok-4.5-medium');
});
it('remaps once from stderr Available models on model_not_found', () => {
const remapped = tryRemapCursorSpawnModelFromError(
'grok-4.5[fast=true]',
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=true]. Available models: auto, cursor-grok-4.5-high-fast'
);
expect(remapped).toBe('cursor-grok-4.5-high-fast');
});
it('remaps from stderr when Tip text follows on the same line', () => {
const remapped = tryRemapCursorSpawnModelFromError(
'grok-4.5[fast=false]',
'Cannot use this model: grok-4.5[fast=false]. Available models: cursor-grok-4.5-medium Tip: agent --list-models'
);
expect(remapped).toBe('cursor-grok-4.5-medium');
});
it('falls back to the legacy wire when a cached SKU is rejected', () => {
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'cursor-grok-4.5-medium' }],
currentModelId: 'cursor-grok-4.5-medium',
cliModelSkus: [{ modelId: 'cursor-grok-4.5-medium' }],
});
const spawnModel = resolveCursorSpawnModel('grok-4.5[fast=false]');
expect(spawnModel).toBe('cursor-grok-4.5-medium');
const stderr = 'Cannot use this model: cursor-grok-4.5-medium. Available models: cursor-grok-4.5-high';
expect(
tryRemapCursorSpawnModelFromConnectError(
spawnModel,
'grok-4.5[fast=false]',
stderr
)
).toBe('cursor-grok-4.5-high');
});
});
@@ -0,0 +1,86 @@
import type { CursorModelsResponse } from '@hapi/protocol/apiTypes';
import {
parseCursorAvailableModelsFromRejection,
remapStaleCursorModelId
} from '@hapi/protocol';
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
function isDefaultSpawnModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
return normalized === 'auto' || normalized === 'default' || normalized === 'default[]';
}
export function catalogEntriesFromCursorModelsResponse(
response: CursorModelsResponse | null | undefined
): { modelId: string }[] {
if (!response?.success) {
return [];
}
const entries: { modelId: string }[] = [];
for (const entry of response.availableModels ?? []) {
const modelId = entry.modelId?.trim();
if (modelId) {
entries.push({ modelId });
}
}
for (const entry of response.cliModelSkus ?? []) {
const modelId = entry.modelId?.trim();
if (modelId) {
entries.push({ modelId });
}
}
return entries;
}
export function resolveCursorSpawnModel(
model: string | null | undefined
): string | null | undefined {
const trimmed = model?.trim();
if (!trimmed || isDefaultSpawnModel(trimmed)) {
return model;
}
const cached = catalogEntriesFromCursorModelsResponse(readSharedCursorModelsCache());
if (cached.length === 0) {
return model;
}
return remapStaleCursorModelId(trimmed, cached) ?? model;
}
export function tryRemapCursorSpawnModelFromError(
model: string | null | undefined,
...sources: Array<string | null | undefined>
): string | null {
const trimmed = model?.trim();
if (!trimmed || isDefaultSpawnModel(trimmed)) {
return null;
}
for (const source of sources) {
if (!source?.trim()) {
continue;
}
const available = parseCursorAvailableModelsFromRejection(source).map((modelId) => ({ modelId }));
if (available.length === 0) {
continue;
}
const remapped = remapStaleCursorModelId(trimmed, available);
if (remapped && remapped !== trimmed) {
return remapped;
}
}
return null;
}
/** Retry stderr remap on the original legacy wire when cache pre-resolution picked a stale SKU. */
export function tryRemapCursorSpawnModelFromConnectError(
resolvedSpawnModel: string | null | undefined,
requestedSpawnModel: string | null | undefined,
...sources: Array<string | null | undefined>
): string | null {
return tryRemapCursorSpawnModelFromError(resolvedSpawnModel, ...sources)
?? tryRemapCursorSpawnModelFromError(requestedSpawnModel, ...sources);
}
+91 -1
View File
@@ -3,9 +3,20 @@ import {
cursorCliSkuBaseId,
findBestCliSkuForAcpWire,
isCursorAcpWireModelId,
matchCliSkuToAcpWireId
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');
@@ -43,6 +54,85 @@ describe('matchCliSkuToAcpWireId', () => {
'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', () => {
+147 -2
View File
@@ -1,3 +1,13 @@
/** Renamed ACP wire bases → live Cursor CLI sku family (e.g. grok-4.5 → cursor-grok-4.5). */
const CURSOR_LEGACY_MODEL_BASE_ALIASES: Readonly<Record<string, string>> = {
'grok-4.5': 'cursor-grok-4.5',
};
export function resolveCursorLegacyModelBase(baseId: string): string {
const trimmed = baseId.trim();
return CURSOR_LEGACY_MODEL_BASE_ALIASES[trimmed] ?? trimmed;
}
/** ACP wire ids use bracket params; CLI `agent --list-models` slugs do not. */
export function isCursorAcpWireModelId(modelId: string): boolean {
const trimmed = modelId.trim();
@@ -51,7 +61,7 @@ export function cursorCliSkuBaseId(slug: string): string {
return base;
}
function parseWireParams(modelId: string): Record<string, string> {
export function parseCursorWireParams(modelId: string): Record<string, string> {
const variant = modelId.includes('[') ? modelId.slice(modelId.indexOf('[') + 1).replace(/\]$/, '') : '';
if (!variant) {
return {};
@@ -105,7 +115,7 @@ function inferSkuParamHints(slug: string): Record<string, string> {
function scoreWireAgainstSku(slug: string, wireId: string): number {
const hints = inferSkuParamHints(slug);
const params = parseWireParams(wireId);
const params = parseCursorWireParams(wireId);
let score = 0;
for (const [key, value] of Object.entries(hints)) {
@@ -145,6 +155,138 @@ export function findBestCliSkuForAcpWire(
return best;
}
function syntheticSkuFromWireParams(base: string, params: Record<string, string>): string {
const effort = params.reasoning ?? params.effort ?? 'medium';
let sku = resolveCursorLegacyModelBase(base);
if (effort === 'extra-high' || effort === 'xhigh') {
sku += '-extra-high';
} else if (effort === 'high') {
sku += '-high';
} else if (effort === 'low') {
sku += '-low';
} else if (effort === 'medium') {
sku += '-medium';
} else if (effort === 'none') {
sku += '-none';
}
if (params.fast === 'true') {
sku += '-fast';
}
return sku;
}
function pseudoWireFromSku(sku: string): string {
const base = cursorCliSkuBaseId(sku);
const hints = inferSkuParamHints(sku);
const parts = Object.entries(hints).map(([key, value]) => `${key}=${value}`);
return `${base}[${parts.join(',')}]`;
}
function pickBestCatalogSku(
requestedSku: string,
available: readonly { modelId: string }[]
): string | null {
const skuBase = cursorCliSkuBaseId(requestedSku);
const candidates = available.filter((entry) => {
const modelId = entry.modelId.trim();
return modelId && !isCursorAcpWireModelId(modelId) && cursorCliSkuBaseId(modelId) === skuBase;
});
if (candidates.length === 0) {
return null;
}
if (candidates.length === 1) {
return candidates[0].modelId;
}
const exact = candidates.find((entry) => entry.modelId === requestedSku);
if (exact) {
return exact.modelId;
}
const pseudoWire = pseudoWireFromSku(requestedSku);
const requestedFast = inferSkuParamHints(requestedSku).fast;
const sameSpeed = candidates.filter(
(entry) => inferSkuParamHints(entry.modelId).fast === requestedFast
);
const rankedCandidates = sameSpeed.length > 0 ? sameSpeed : candidates;
let best = rankedCandidates[0].modelId;
let bestScore = Number.NEGATIVE_INFINITY;
for (const entry of rankedCandidates) {
const score = scoreWireAgainstSku(entry.modelId, pseudoWire);
if (score > bestScore) {
bestScore = score;
best = entry.modelId;
}
}
return best;
}
/** Parse model ids from Cursor `Cannot use this model` stderr (Available models: …). */
export function parseCursorAvailableModelsFromRejection(text: string): string[] {
const match = text.match(/Available models:\s*([^\n]*)/i);
if (!match) {
return [];
}
let catalog = match[1].trim();
const tipIdx = catalog.search(/\s+Tip:/i);
if (tipIdx !== -1) {
catalog = catalog.slice(0, tipIdx).trim();
}
return catalog
.split(',')
.map((part) => part.trim())
.filter((part) => part.length > 0 && part.toLowerCase() !== 'auto');
}
/**
* Remap a stale hub/ACP wire id onto a live Cursor catalog entry.
* Returns null when no catalog candidate matches.
*/
export function remapStaleCursorModelId(
requested: string,
available: readonly { modelId: string }[]
): string | null {
const trimmed = requested.trim();
if (!trimmed) {
return null;
}
const exact = available.find((entry) => entry.modelId === trimmed);
const legacyWire = isCursorAcpWireModelId(trimmed)
&& resolveCursorLegacyModelBase(cursorModelBaseId(trimmed)) !== cursorModelBaseId(trimmed);
if (exact && !legacyWire) {
return trimmed;
}
if (isCursorAcpWireModelId(trimmed)) {
const wireBase = cursorModelBaseId(trimmed);
if (resolveCursorLegacyModelBase(wireBase) === wireBase) {
return null;
}
const syntheticSku = syntheticSkuFromWireParams(
wireBase,
parseCursorWireParams(trimmed)
);
return pickBestCatalogSku(syntheticSku, available)
?? matchCliSkuToAcpWireId(syntheticSku, available);
}
const legacyBase = resolveCursorLegacyModelBase(cursorCliSkuBaseId(trimmed));
if (legacyBase !== cursorCliSkuBaseId(trimmed)) {
const rewritten = trimmed.replace(cursorCliSkuBaseId(trimmed), legacyBase);
return pickBestCatalogSku(rewritten, available)
?? matchCliSkuToAcpWireId(rewritten, available);
}
return null;
}
/**
* Map UI/CLI model id (wire or slug) onto an ACP configOptions wire id.
*/
@@ -163,6 +305,9 @@ export function matchCliSkuToAcpWireId(
}
if (isCursorAcpWireModelId(trimmed)) {
if (resolveCursorLegacyModelBase(cursorModelBaseId(trimmed)) !== cursorModelBaseId(trimmed)) {
return remapStaleCursorModelId(trimmed, available);
}
return null;
}