mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +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);
|
||||
}
|
||||
Reference in New Issue
Block a user