mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user