fix(cursor): merge SKU catalog under ACP lock and refcount agent guard (#835)

Fixes incomplete cliModelSkus while agent acp holds the CLI lock (#831) and
replace single-pid ACP lock with cross-process refcount (#832). Web picker
merges machine/session catalogs and waits for SKU readiness before showing
variant labels.

Fixes #831
Fixes #832

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-06-08 13:30:43 +08:00
committed by GitHub
co-authored by Cursor
parent 6d2d0d4707
commit ad038bbf2e
15 changed files with 891 additions and 65 deletions
@@ -15,6 +15,21 @@ function lockDir(): string {
return join(testHome, 'locks', 'agent-acp-active');
}
function writeTestAcpLock(args: { count: number; pids: number[] }): void {
const dir = lockDir();
mkdirSync(join(dir, 'pids'), { recursive: true });
writeFileSync(join(dir, 'count'), String(args.count), 'utf8');
for (const pid of args.pids) {
writeFileSync(join(dir, 'pids', String(pid)), String(pid), 'utf8');
}
}
function writeLegacyAcpLock(pid: number): void {
const dir = lockDir();
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'pid'), String(pid), 'utf8');
}
describe('agentCliGuard', () => {
const previousHome = process.env.HAPI_HOME;
@@ -35,32 +50,72 @@ describe('agentCliGuard', () => {
expect(isAgentAcpTransportActive()).toBe(false);
});
test('clears stale cross-process lock when pid is not running', () => {
test('keeps cross-process lock until the last transport unregisters', () => {
process.env.HAPI_HOME = testHome;
const dir = lockDir();
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'pid'), '99999999');
registerActiveAcpTransport();
registerActiveAcpTransport();
unregisterActiveAcpTransport();
expect(isAgentAcpTransportActive()).toBe(true);
expect(existsSync(lockDir())).toBe(true);
unregisterActiveAcpTransport();
expect(isAgentAcpTransportActive()).toBe(false);
expect(existsSync(dir)).toBe(false);
expect(existsSync(lockDir())).toBe(false);
});
test('keeps lock when pid file points at a live process', () => {
test('leaves refcount at one after the first of two in-process unregisters', () => {
process.env.HAPI_HOME = testHome;
registerActiveAcpTransport();
registerActiveAcpTransport();
const dir = lockDir();
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'pid'), String(process.pid));
unregisterActiveAcpTransport();
expect(isAgentAcpTransportActive()).toBe(true);
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, 'pids', String(process.pid)))).toBe(true);
});
test('clears lock when pid file is missing or invalid', () => {
test('clears stale cross-process lock when pid is not running', () => {
process.env.HAPI_HOME = testHome;
writeLegacyAcpLock(99999999);
expect(isAgentAcpTransportActive()).toBe(false);
expect(existsSync(lockDir())).toBe(false);
});
test('keeps legacy lock when pid file points at a live process', () => {
process.env.HAPI_HOME = testHome;
writeLegacyAcpLock(process.pid);
expect(isAgentAcpTransportActive()).toBe(true);
expect(existsSync(lockDir())).toBe(true);
});
test('clears refcount lock when pid entries are missing or invalid', () => {
process.env.HAPI_HOME = testHome;
const dir = lockDir();
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'count'), '1', 'utf8');
expect(isAgentAcpTransportActive()).toBe(false);
expect(existsSync(dir)).toBe(false);
});
test('clears refcount lock when all pid entries are stale', () => {
process.env.HAPI_HOME = testHome;
writeTestAcpLock({ count: 2, pids: [99999998, 99999999] });
expect(isAgentAcpTransportActive()).toBe(false);
expect(existsSync(lockDir())).toBe(false);
});
test('reconciles refcount lock down to live pid entries', () => {
process.env.HAPI_HOME = testHome;
writeTestAcpLock({ count: 3, pids: [process.pid, 99999999] });
expect(isAgentAcpTransportActive()).toBe(true);
expect(existsSync(lockDir())).toBe(true);
});
});
+132 -8
View File
@@ -1,4 +1,11 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@@ -17,6 +24,10 @@ function getAcpLockDir(): string {
return join(home, 'locks', 'agent-acp-active');
}
function getPidsDir(lockDir: string): string {
return join(lockDir, 'pids');
}
function readLockPid(lockDir: string): number | null {
const pidPath = join(lockDir, 'pid');
if (!existsSync(pidPath)) {
@@ -35,6 +46,46 @@ function readLockPid(lockDir: string): number | null {
}
}
function readLockCount(lockDir: string): number {
const countPath = join(lockDir, 'count');
if (!existsSync(countPath)) {
return 0;
}
try {
const raw = readFileSync(countPath, 'utf8').trim();
const count = Number(raw);
if (!Number.isInteger(count) || count < 0) {
return 0;
}
return count;
} catch {
return 0;
}
}
function writeLockCount(lockDir: string, count: number): void {
writeFileSync(join(lockDir, 'count'), String(Math.max(0, count)), 'utf8');
}
function addLockPid(lockDir: string, pid: number): void {
const pidsDir = getPidsDir(lockDir);
mkdirSync(pidsDir, { recursive: true });
writeFileSync(join(pidsDir, String(pid)), String(pid), 'utf8');
}
function removeLockPid(lockDir: string, pid: number): void {
try {
rmSync(join(getPidsDir(lockDir), String(pid)), { force: true });
} catch {
// Best effort.
}
}
function isLegacyLock(lockDir: string): boolean {
return existsSync(join(lockDir, 'pid')) && !existsSync(join(lockDir, 'count'));
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -58,6 +109,46 @@ function removeAcpLockDir(): void {
}
}
function reconcileRefcountLock(lockDir: string): boolean {
const pidsDir = getPidsDir(lockDir);
if (!existsSync(pidsDir)) {
removeAcpLockDir();
return false;
}
let liveCount = 0;
for (const entry of readdirSync(pidsDir)) {
const pid = Number(entry);
if (!Number.isInteger(pid) || pid <= 0) {
try {
rmSync(join(pidsDir, entry), { force: true });
} catch {
// Best effort.
}
continue;
}
if (isProcessAlive(pid)) {
liveCount += 1;
continue;
}
try {
rmSync(join(pidsDir, entry), { force: true });
} catch {
// Best effort.
}
}
if (liveCount <= 0) {
removeAcpLockDir();
return false;
}
writeLockCount(lockDir, liveCount);
return true;
}
/** Remove lock directories left behind by SIGKILL / crash / reboot. */
function clearStaleAcpLockIfNeeded(): void {
const lockDir = getAcpLockDir();
@@ -65,10 +156,15 @@ function clearStaleAcpLockIfNeeded(): void {
return;
}
const pid = readLockPid(lockDir);
if (pid === null || !isProcessAlive(pid)) {
removeAcpLockDir();
if (isLegacyLock(lockDir)) {
const pid = readLockPid(lockDir);
if (pid === null || !isProcessAlive(pid)) {
removeAcpLockDir();
}
return;
}
reconcileRefcountLock(lockDir);
}
export function registerActiveAcpTransport(): void {
@@ -76,7 +172,8 @@ export function registerActiveAcpTransport(): void {
const lockDir = getAcpLockDir();
try {
mkdirSync(lockDir, { recursive: true });
writeFileSync(join(lockDir, 'pid'), String(process.pid));
writeLockCount(lockDir, readLockCount(lockDir) + 1);
addLockPid(lockDir, process.pid);
} catch {
// Another process may have created the lock; in-process guard still applies.
}
@@ -84,10 +181,27 @@ export function registerActiveAcpTransport(): void {
export function unregisterActiveAcpTransport(): void {
activeAcpTransportCount = Math.max(0, activeAcpTransportCount - 1);
if (activeAcpTransportCount > 0) {
const lockDir = getAcpLockDir();
if (!existsSync(lockDir)) {
return;
}
removeAcpLockDir();
if (isLegacyLock(lockDir)) {
if (activeAcpTransportCount <= 0) {
removeAcpLockDir();
}
return;
}
try {
if (activeAcpTransportCount <= 0) {
removeLockPid(lockDir, process.pid);
}
reconcileRefcountLock(lockDir);
} catch {
// Best effort.
}
}
export function isAgentAcpTransportActive(): boolean {
@@ -95,7 +209,17 @@ export function isAgentAcpTransportActive(): boolean {
return true;
}
clearStaleAcpLockIfNeeded();
return existsSync(getAcpLockDir());
const lockDir = getAcpLockDir();
if (!existsSync(lockDir)) {
return false;
}
if (isLegacyLock(lockDir)) {
const pid = readLockPid(lockDir);
return pid !== null && isProcessAlive(pid);
}
return readLockCount(lockDir) > 0;
}
export function _resetAgentCliGuardForTests(): void {
+4 -2
View File
@@ -17,7 +17,8 @@ import { setCursorAcpModelsSnapshot } from './utils/cursorAcpModelsBridge';
import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot';
import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter';
import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } from './utils/cursorModeConfig';
import { seedCursorModelsCache } from '@/modules/common/cursorModels';
import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels';
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
import type { AcpSdkBackend } from '@/agent/backends/acp';
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
@@ -442,8 +443,9 @@ function syncCursorModelsFromAcp(backend: AcpSdkBackend, acpSessionId: string):
return;
}
const payload = buildCursorModelsSeedPayload(snapshot, readSharedCursorModelsCache());
setCursorAcpModelsSnapshot(snapshot);
seedCursorModelsCache({ success: true, ...snapshot });
seedCursorModelsCache(payload);
}
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
+144
View File
@@ -34,6 +34,7 @@ import {
} from './cursorModelsSharedCache';
import {
_resetCursorModelsCacheForTests,
buildCursorModelsSeedPayload,
listCursorModels,
parseCursorModelsOutput,
seedCursorModelsCache
@@ -48,6 +49,33 @@ afterEach(() => {
acpProbeMock.runCursorAcpModelProbe.mockReset()
})
describe('buildCursorModelsSeedPayload', () => {
test('inherits cliModelSkus from shared cache when ACP snapshot has none', () => {
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
]
})
const seeded = buildCursorModelsSeedPayload(
{
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
},
readSharedCursorModelsCache()
)
expect(seeded.cliModelSkus?.map((row) => row.modelId)).toEqual([
'gpt-5.5-medium',
'gpt-5.5-high'
])
})
})
describe('parseCursorModelsOutput', () => {
test('parses Cursor agent model list output', () => {
const result = parseCursorModelsOutput(`
@@ -153,6 +181,107 @@ describe('listCursorModels', () => {
expect(readSharedCursorModelsCache()?.currentModelId).toBe('composer-2.5[fast=true]')
})
test('unions partial response cliModelSkus with fuller shared cache while lock is active', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
setCursorAcpModelsSnapshot({
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }
]
} as never)
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' },
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
]
})
const result = await listCursorModels()
expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([
'gpt-5.5-high-fast',
'gpt-5.5-low',
'gpt-5.5-medium',
'gpt-5.5-high'
])
expect(spawnMock).not.toHaveBeenCalled()
})
test('enriches live ACP snapshot with fuller shared cliModelSkus while lock is active', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
setCursorAcpModelsSnapshot({
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
})
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' },
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
]
})
const result = await listCursorModels()
expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([
'gpt-5.5-high-fast',
'gpt-5.5-low',
'gpt-5.5-medium',
'gpt-5.5-high'
])
expect(spawnMock).not.toHaveBeenCalled()
})
test('unions shared partial cliModelSkus with probe results when lock is inactive', async () => {
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }
]
})
spawnMock.mockImplementation(() => ({
stdout: {
on: vi.fn((event: string, handler: (chunk: Buffer) => void) => {
if (event === 'data') {
handler(Buffer.from(
'gpt-5.5-high-fast - GPT-5.5 High Fast\n'
+ 'gpt-5.5-high - GPT-5.5 High\n'
));
}
})
},
stderr: { on: vi.fn() },
on: vi.fn((event: string, handler: (code: number) => void) => {
if (event === 'exit') {
setTimeout(() => handler(0), 0);
}
}),
kill: vi.fn()
}));
const result = await listCursorModels();
expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([
'gpt-5.5-medium',
'gpt-5.5-high-fast',
'gpt-5.5-high'
]);
expect(spawnMock).toHaveBeenCalled();
});
test('prefers ACP wire probe over CLI slug probe when cache is empty', async () => {
acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({
success: true,
@@ -216,6 +345,21 @@ describe('listCursorModels', () => {
});
});
test('skips CLI slug probe when ACP lock is active after ACP probe', async () => {
vi.mocked(isAgentAcpTransportActive)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({
success: false,
error: 'no wires'
});
const result = await listCursorModels();
expect(spawnMock).not.toHaveBeenCalled();
expect(result).toEqual({ success: false, error: 'no wires' });
});
test('prefers live ACP snapshot over cache while ACP transport is active', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
seedCursorModelsCache({
+80 -25
View File
@@ -18,6 +18,44 @@ import {
runCursorAcpModelProbe
} from './cursorAcpModelProbe';
export function buildCursorModelsSeedPayload(
snapshot: {
availableModels: CursorModelSummary[];
currentModelId: string | null;
cliModelSkus?: readonly CursorModelSummary[];
},
shared?: CursorModelsResponse | null
): ListCursorModelsResponse {
const cliModelSkus = mergeCliModelSkus(
snapshot.cliModelSkus ?? [],
shared?.cliModelSkus ?? []
);
return {
success: true,
availableModels: snapshot.availableModels,
currentModelId: snapshot.currentModelId,
...(cliModelSkus.length > 0 ? { cliModelSkus } : {})
};
}
export function mergeCliModelSkus(
...lists: readonly (readonly CursorModelSummary[])[]
): CursorModelSummary[] {
const merged = new Map<string, CursorModelSummary>();
for (const list of lists) {
for (const entry of list) {
const modelId = entry.modelId.trim();
if (!modelId) {
continue;
}
if (!merged.has(modelId)) {
merged.set(modelId, entry);
}
}
}
return [...merged.values()];
}
function filterCliSkusForWireBases(
cliSkus: CursorModelSummary[],
wires: CursorModelSummary[]
@@ -39,44 +77,47 @@ function attachCliSkusToResponse(
response: ListCursorModelsResponse,
cliSkus: readonly CursorModelSummary[]
): ListCursorModelsResponse {
if ((response.cliModelSkus?.length ?? 0) > 0) {
return response;
}
const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId));
const filtered = filterCliSkusForWireBases([...cliSkus], wires);
return filtered.length > 0 ? { ...response, cliModelSkus: filtered } : response;
const merged = mergeCliModelSkus(response.cliModelSkus ?? [], filtered);
if (merged.length === 0) {
return response;
}
if (merged.length === (response.cliModelSkus?.length ?? 0)) {
return response;
}
return { ...response, cliModelSkus: merged };
}
async function enrichCursorModelsWithCliSkus(
response: ListCursorModelsResponse
): Promise<ListCursorModelsResponse> {
if ((response.cliModelSkus?.length ?? 0) > 0) {
return response;
}
const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId));
if (wires.length === 0) {
return response;
}
const candidates: CursorModelSummary[] = [];
const shared = readSharedCursorModelsCache();
if (shared?.cliModelSkus?.length) {
return attachCliSkusToResponse(response, shared.cliModelSkus);
candidates.push(...shared.cliModelSkus);
}
// Never spawn `agent --list-models` while an ACP session holds the CLI lock.
if (isAgentAcpTransportActive()) {
if (!isAgentAcpTransportActive()) {
try {
const probe = await runCursorModelProbe();
candidates.push(...(probe.availableModels ?? []));
} catch {
// Keep partial candidates from shared cache.
}
}
if (candidates.length === 0) {
return response;
}
try {
const probe = await runCursorModelProbe();
const cliSkus = filterCliSkusForWireBases(probe.availableModels ?? [], wires);
return cliSkus.length > 0 ? { ...response, cliModelSkus: cliSkus } : response;
} catch {
return response;
}
return attachCliSkusToResponse(response, candidates);
}
export type ListCursorModelsResponse = CursorModelsResponse;
@@ -134,6 +175,10 @@ export function parseCursorModelsOutput(output: string): {
}
async function runCursorModelProbe(): Promise<ListCursorModelsResponse> {
if (isAgentAcpTransportActive()) {
throw new Error('Cursor ACP transport is active');
}
return await new Promise((resolve, reject) => {
const child = spawn('agent', ['--list-models'], {
env: process.env,
@@ -204,7 +249,10 @@ async function listCursorModelsWhileAcpActive(): Promise<ListCursorModelsRespons
}
if (cache.expiresAt > Date.now() && (cache.response.availableModels?.length ?? 0) > 0) {
const shared = readSharedCursorModelsCache();
const cachedSkus = cache.response.cliModelSkus ?? shared?.cliModelSkus ?? [];
const cachedSkus = mergeCliModelSkus(
cache.response.cliModelSkus ?? [],
shared?.cliModelSkus ?? []
);
return attachCliSkusToResponse(cache.response, cachedSkus);
}
return { success: true, availableModels: [], currentModelId: null };
@@ -240,9 +288,12 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {
return applyInMemoryCache(acpResponse);
}
const probeResponse = await runCursorModelProbe();
if (cursorProbeResponseHasWireCatalog(probeResponse)) {
return applyInMemoryCache(probeResponse);
let probeResponse: ListCursorModelsResponse | null = null;
if (!isAgentAcpTransportActive()) {
probeResponse = await runCursorModelProbe();
if (cursorProbeResponseHasWireCatalog(probeResponse)) {
return applyInMemoryCache(probeResponse);
}
}
// CLI `--list-models` returns slug ids without bracket params; never cache
@@ -250,9 +301,10 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {
if (acpResponse.success) {
return acpResponse;
}
return probeResponse.success
? { success: true, availableModels: [], currentModelId: null }
: probeResponse;
if (probeResponse?.success) {
return { success: true, availableModels: [], currentModelId: null };
}
return probeResponse ?? acpResponse;
} catch (error) {
return {
success: false,
@@ -267,6 +319,9 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {
}
export function seedCursorModelsCache(response: ListCursorModelsResponse): void {
if ((response.availableModels?.length ?? 0) > 0) {
writeSharedCursorModelsCache(response);
}
void applyInMemoryCache(response);
}
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const listCursorModelsMock = vi.hoisted(() => vi.fn());
vi.mock('./cursorModels', () => ({
listCursorModels: listCursorModelsMock
}));
import { scheduleCursorModelsPrewarm } from './cursorModelsPrewarm';
afterEach(() => {
listCursorModelsMock.mockReset();
});
describe('scheduleCursorModelsPrewarm', () => {
test('starts a background listCursorModels call', async () => {
listCursorModelsMock.mockResolvedValue({
success: true,
availableModels: [],
currentModelId: null
});
scheduleCursorModelsPrewarm();
await Promise.resolve();
expect(listCursorModelsMock).toHaveBeenCalledTimes(1);
});
test('swallows listCursorModels failures', async () => {
listCursorModelsMock.mockRejectedValue(new Error('agent missing'));
scheduleCursorModelsPrewarm();
await Promise.resolve();
expect(listCursorModelsMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,9 @@
import { logger } from '@/ui/logger';
import { listCursorModels } from './cursorModels';
/** Background fill of shared cursor-models cache; does not block runner startup. */
export function scheduleCursorModelsPrewarm(): void {
void listCursorModels().catch((error) => {
logger.debug('[RUNNER RUN] Cursor model pre-warm failed', error);
});
}
@@ -26,4 +26,19 @@ describe('cursorModelsSharedCache', () => {
writeSharedCursorModelsCache({ success: true, availableModels: [], currentModelId: null });
expect(readSharedCursorModelsCache()).toBeNull();
});
test('round-trips cliModelSkus with wire catalog', () => {
const payload = {
success: true as const,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }
]
};
writeSharedCursorModelsCache(payload);
expect(readSharedCursorModelsCache()?.cliModelSkus).toEqual(payload.cliModelSkus);
});
});
+2
View File
@@ -24,6 +24,7 @@ import { join } from 'path';
import { buildMachineMetadata } from '@/agent/sessionFactory';
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
import { hashRunnerCliApiToken } from './runnerIdentity';
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
// We don't have cleanup function at the time of server construction
@@ -720,6 +721,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
// Connect to server
apiMachine.connect();
scheduleCursorModelsPrewarm();
// Visible startup banner. Use console.log so it always appears on stdout,
// regardless of the verbose/quiet logger setting.