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.
+62 -21
View File
@@ -36,11 +36,14 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels'
import { useCursorModels } from '@/hooks/queries/useCursorModels'
import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine'
import {
buildCursorCatalogFromSources,
buildCursorPickerState,
mergeCursorCliModelSkus,
resolveCursorBaseFromWire
} from '@/lib/cursorPickerState'
import {
buildSessionCursorPickerState,
isSessionCursorCatalogAwaitingSkus,
isSessionCursorCatalogPendingWithTimeout,
SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS,
resolveSessionCursorBaseSelectValue,
resolveSessionCursorModelChange,
resolveSessionCursorVariantSelectValue
@@ -225,39 +228,73 @@ export function SessionChat(props: {
machineId: sessionMachineId,
enabled: agentFlavor === 'cursor' && props.session.active && Boolean(sessionMachineId)
})
const sessionCliModelSkus = useMemo(() => {
if (cursorModelsState.cliModelSkus.length > 0) {
return cursorModelsState.cliModelSkus
}
return machineCursorModelsState.cliModelSkus
}, [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus])
const sessionCliModelSkus = useMemo(() => (
mergeCursorCliModelSkus(
machineCursorModelsState.cliModelSkus,
cursorModelsState.cliModelSkus
)
), [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus])
const cursorPicker = useMemo(() => {
if (agentFlavor !== 'cursor') {
return null
}
const catalog = buildCursorCatalogFromSources({
return buildSessionCursorPickerState({
sessionModels: cursorModelsState.availableModels,
machineModels: machineCursorModelsState.availableModels,
cliModelSkus: sessionCliModelSkus,
currentWireId: cursorModelsState.currentModelId ?? props.session.model,
sessionModelFromHub: props.session.model,
defaultValue: null
})
return buildCursorPickerState({
catalog,
currentWireId: props.session.model ?? cursorModelsState.currentModelId,
defaultValue: null
sessionModel: props.session.model,
sessionCurrentModelId: cursorModelsState.currentModelId
})
}, [
agentFlavor,
cursorModelsState.availableModels,
cursorModelsState.cliModelSkus,
cursorModelsState.currentModelId,
machineCursorModelsState.availableModels,
sessionCliModelSkus,
props.session.model
])
const cursorCatalogReadinessArgs = useMemo(() => ({
sessionLoading: cursorModelsState.isLoading,
machineLoading: machineCursorModelsState.isLoading,
hasMachineId: Boolean(sessionMachineId),
sessionError: cursorModelsState.error,
machineError: machineCursorModelsState.error,
mergedSkus: sessionCliModelSkus,
picker: cursorPicker
}), [
cursorModelsState.isLoading,
cursorModelsState.error,
machineCursorModelsState.isLoading,
machineCursorModelsState.error,
sessionMachineId,
sessionCliModelSkus,
cursorPicker
])
const cursorCatalogAwaitingSkus = useMemo(
() => isSessionCursorCatalogAwaitingSkus(cursorCatalogReadinessArgs),
[cursorCatalogReadinessArgs]
)
const [cursorSkuAwaitingSince, setCursorSkuAwaitingSince] = useState<number | null>(null)
const [cursorCatalogNowMs, setCursorCatalogNowMs] = useState(() => Date.now())
useEffect(() => {
if (cursorCatalogAwaitingSkus) {
setCursorSkuAwaitingSince((previous) => previous ?? Date.now())
const timer = setTimeout(
() => setCursorCatalogNowMs(Date.now()),
SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS
)
return () => clearTimeout(timer)
}
setCursorSkuAwaitingSince(null)
setCursorCatalogNowMs(Date.now())
return undefined
}, [cursorCatalogAwaitingSkus])
const cursorCatalogPending = isSessionCursorCatalogPendingWithTimeout({
...cursorCatalogReadinessArgs,
awaitingStartedAtMs: cursorSkuAwaitingSince,
nowMs: cursorCatalogNowMs
})
useEffect(() => {
if (agentFlavor !== 'cursor' || !cursorPicker) {
@@ -816,7 +853,7 @@ export function SessionChat(props: {
? codexModelOptions
: agentFlavor === 'cursor'
? (
cursorModelsState.isLoading
cursorCatalogPending
|| !cursorPicker
|| cursorPicker.modelOptions.length === 0
? undefined
@@ -847,10 +884,13 @@ export function SessionChat(props: {
: undefined
}
selectedModelVariant={
agentFlavor === 'cursor' ? cursorVariantSelectValue : undefined
agentFlavor === 'cursor' && !cursorCatalogPending
? cursorVariantSelectValue
: undefined
}
modelEffortOptions={
agentFlavor === 'cursor'
&& !cursorCatalogPending
&& cursorPicker?.mode === 'dual'
&& cursorModelEffortOptions
&& cursorModelEffortOptions.length > 1
@@ -863,7 +903,7 @@ export function SessionChat(props: {
: agentFlavor === 'cursor'
? (props.session.active
&& !controlledByUser
&& !cursorModelsState.isLoading
&& !cursorCatalogPending
&& !cursorModelsState.error
&& cursorPicker
&& cursorPicker.modelOptions.length > 0
@@ -875,6 +915,7 @@ export function SessionChat(props: {
agentFlavor === 'cursor'
&& props.session.active
&& !controlledByUser
&& !cursorCatalogPending
&& !cursorModelsState.error
? handleCursorEffortChange
: undefined
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { mergeCursorCliModelSkus } from '@/lib/cursorPickerState'
describe('mergeCursorCliModelSkus', () => {
it('prefers the richer source and unions ids without duplicates', () => {
const partial = [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }
]
const full = [
...partial,
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
]
expect(mergeCursorCliModelSkus(partial, full).map((row) => row.modelId)).toEqual([
'gpt-5.5-high-fast',
'gpt-5.5-low',
'gpt-5.5-medium',
'gpt-5.5-high'
])
})
it('prefers the richer source name for duplicate model ids', () => {
const machine = [{ modelId: 'gpt-5.5-medium', name: 'From Machine' }]
const session = [{ modelId: 'gpt-5.5-medium', name: 'From Session' }]
expect(mergeCursorCliModelSkus(machine, session)).toEqual([
{ modelId: 'gpt-5.5-medium', name: 'From Machine' }
])
})
})
+19
View File
@@ -13,6 +13,25 @@ import {
type CursorModelOption
} from '@/lib/cursorModelOptions'
export function mergeCursorCliModelSkus(
...sources: readonly (readonly CursorModelSummary[])[]
): CursorModelSummary[] {
const sorted = [...sources].sort((a, b) => b.length - a.length);
const merged = new Map<string, CursorModelSummary>();
for (const source of sorted) {
for (const entry of source) {
const modelId = entry.modelId.trim();
if (!modelId) {
continue;
}
if (!merged.has(modelId)) {
merged.set(modelId, entry);
}
}
}
return [...merged.values()];
}
export type CursorPickerMode = 'dual' | 'flat'
export type CursorPickerOption = { value: string; label: string }
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { appendCliSkusToCatalog, buildCursorModelCatalog } from '@/lib/cursorModelOptions'
import { mergeCursorCliModelSkus } from '@/lib/cursorPickerState'
import {
buildSessionCursorPickerState,
resolveSessionCursorVariantSelectValue
@@ -29,6 +30,49 @@ describe('in-session cursor catalog with CLI skus', () => {
expect(catalog.variantsByBase.get('gpt-5.5')?.some((row) => row.wireId === 'gpt-5.5-high-fast')).toBe(true)
})
it('merges machine SKU catalog over partial session SKUs for friendly variant labels', () => {
const machineWires = [
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' },
{ modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' }
]
const sessionSkus = [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }
]
const machineSkus = [
...sessionSkus,
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
]
const mergedSkus = mergeCursorCliModelSkus(machineSkus, sessionSkus)
const picker = buildSessionCursorPickerState({
sessionModels: sessionWires,
machineModels: machineWires,
cliModelSkus: mergedSkus,
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
})
expect(picker.mode).toBe('dual')
expect(picker.effortOptions.length).toBeGreaterThan(2)
expect(picker.effortOptions.every((row) => !row.label.includes('context=272k'))).toBe(true)
})
it('shows raw variant suffixes when dual catalog has wires but no CLI skus', () => {
const picker = buildSessionCursorPickerState({
sessionModels: [
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' },
{ modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' }
],
machineModels: [],
cliModelSkus: [],
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
})
expect(picker.effortOptions.some((row) => row.label.includes('reasoning='))).toBe(true)
})
it('highlights the matching CLI sku when session stores the ACP wire id', () => {
const picker = buildSessionCursorPickerState({
sessionModels: sessionWires,
+166
View File
@@ -2,6 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
buildSessionCursorPickerState,
isCursorEffortWireInCatalog,
isSessionCursorCatalogAwaitingSkus,
isSessionCursorCatalogLoading,
isSessionCursorCatalogPending,
isSessionCursorCatalogPendingWithTimeout,
resolveSessionCursorBaseSelectValue,
resolveSessionCursorModelChange
} from '@/lib/sessionChatCursorModel'
@@ -131,6 +135,168 @@ describe('CLI sku variants in session picker', () => {
})
})
describe('session cursor catalog readiness', () => {
const dualPicker = buildSessionCursorPickerState({
sessionModels: [
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' },
{ modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' }
],
machineModels: [],
cliModelSkus: [],
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
})
it('waits for session and machine loading', () => {
expect(isSessionCursorCatalogLoading({
sessionLoading: true,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null
})).toBe(true)
expect(isSessionCursorCatalogLoading({
sessionLoading: false,
machineLoading: true,
hasMachineId: true,
sessionError: null,
machineError: null
})).toBe(true)
expect(isSessionCursorCatalogLoading({
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null
})).toBe(false)
})
it('does not wait for machine loading after machine error or without machine id', () => {
expect(isSessionCursorCatalogLoading({
sessionLoading: false,
machineLoading: true,
hasMachineId: true,
sessionError: null,
machineError: 'boom'
})).toBe(false)
expect(isSessionCursorCatalogLoading({
sessionLoading: false,
machineLoading: true,
hasMachineId: false,
sessionError: null,
machineError: null
})).toBe(false)
})
it('awaits SKUs for dual picker when merged catalog is still empty', () => {
expect(isSessionCursorCatalogAwaitingSkus({
sessionLoading: false,
machineLoading: false,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: dualPicker
})).toBe(true)
expect(isSessionCursorCatalogAwaitingSkus({
sessionLoading: false,
machineLoading: false,
sessionError: null,
machineError: null,
mergedSkus: [{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }],
picker: dualPicker
})).toBe(false)
})
it('combines loading and SKU awaiting into pending state', () => {
expect(isSessionCursorCatalogPending({
sessionLoading: false,
machineLoading: true,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: dualPicker
})).toBe(true)
expect(isSessionCursorCatalogPending({
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: dualPicker
})).toBe(true)
expect(isSessionCursorCatalogPending({
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }],
picker: dualPicker
})).toBe(false)
})
it('does not await SKUs for flat picker sessions', () => {
const flatPicker = buildSessionCursorPickerState({
sessionModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }],
machineModels: [],
cliModelSkus: [],
sessionModel: 'composer-2.5[fast=true]',
sessionCurrentModelId: 'composer-2.5[fast=true]'
})
expect(isSessionCursorCatalogPending({
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: flatPicker
})).toBe(false)
})
it('stays pending for loading even when SKU timeout has elapsed', () => {
expect(isSessionCursorCatalogPendingWithTimeout({
sessionLoading: true,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [],
picker: dualPicker,
awaitingStartedAtMs: 0,
nowMs: 20_000,
timeoutMs: 15_000
})).toBe(true)
})
it('degrades SKU awaiting after timeout while keeping loading pending', () => {
const startedAt = 1_000
const args = {
sessionLoading: false,
machineLoading: false,
hasMachineId: true,
sessionError: null,
machineError: null,
mergedSkus: [] as const,
picker: dualPicker,
awaitingStartedAtMs: startedAt,
timeoutMs: 15_000
}
expect(isSessionCursorCatalogPendingWithTimeout({
...args,
nowMs: startedAt + 5_000
})).toBe(true)
expect(isSessionCursorCatalogPendingWithTimeout({
...args,
nowMs: startedAt + 15_000
})).toBe(false)
})
})
describe('isCursorEffortWireInCatalog', () => {
it('checks wireToBase membership', () => {
const picker = buildSessionCursorPickerState({
+79
View File
@@ -112,6 +112,85 @@ export function resolveSessionCursorVariantSelectValue(
return null
}
export function isSessionCursorCatalogLoading(args: {
sessionLoading: boolean
machineLoading: boolean
hasMachineId: boolean
sessionError: string | null
machineError: string | null
}): boolean {
if (args.sessionLoading) {
return true
}
if (args.hasMachineId && args.machineLoading && !args.machineError) {
return true
}
return false
}
export function isSessionCursorCatalogAwaitingSkus(args: {
sessionLoading: boolean
machineLoading: boolean
sessionError: string | null
machineError: string | null
mergedSkus: readonly CursorModelSummary[]
picker: CursorPickerState | null
}): boolean {
if (args.sessionLoading || args.machineLoading) {
return false
}
if (args.sessionError || args.machineError) {
return false
}
if (!args.picker || args.picker.mode !== 'dual') {
return false
}
if (args.mergedSkus.length > 0) {
return false
}
return args.picker.showEffortPicker
}
export const SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS = 15_000
export function isSessionCursorCatalogPending(args: {
sessionLoading: boolean
machineLoading: boolean
hasMachineId: boolean
sessionError: string | null
machineError: string | null
mergedSkus: readonly CursorModelSummary[]
picker: CursorPickerState | null
}): boolean {
return isSessionCursorCatalogLoading(args)
|| isSessionCursorCatalogAwaitingSkus(args)
}
export function isSessionCursorCatalogPendingWithTimeout(args: {
sessionLoading: boolean
machineLoading: boolean
hasMachineId: boolean
sessionError: string | null
machineError: string | null
mergedSkus: readonly CursorModelSummary[]
picker: CursorPickerState | null
awaitingStartedAtMs: number | null
nowMs?: number
timeoutMs?: number
}): boolean {
if (!isSessionCursorCatalogPending(args)) {
return false
}
if (!isSessionCursorCatalogAwaitingSkus(args)) {
return true
}
if (args.awaitingStartedAtMs === null) {
return true
}
const elapsed = (args.nowMs ?? Date.now()) - args.awaitingStartedAtMs
return elapsed < (args.timeoutMs ?? SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS)
}
export function buildSessionCursorPickerState(args: {
sessionModels: readonly CursorModelSummary[]
machineModels: readonly CursorModelSummary[]