From ad038bbf2e0000f8128aab77ac431ebd25aa4231 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Mon, 8 Jun 2026 13:30:43 +0800 Subject: [PATCH] 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 --- .../agent/backends/acp/agentCliGuard.test.ts | 73 +++++++- cli/src/agent/backends/acp/agentCliGuard.ts | 140 ++++++++++++++- cli/src/cursor/cursorAcpRemoteLauncher.ts | 6 +- cli/src/modules/common/cursorModels.test.ts | 144 +++++++++++++++ cli/src/modules/common/cursorModels.ts | 105 ++++++++--- .../common/cursorModelsPrewarm.test.ts | 39 ++++ cli/src/modules/common/cursorModelsPrewarm.ts | 9 + .../common/cursorModelsSharedCache.test.ts | 15 ++ cli/src/runner/run.ts | 2 + web/src/components/SessionChat.tsx | 83 ++++++--- web/src/lib/cursorCliSkusMerge.test.ts | 32 ++++ web/src/lib/cursorPickerState.ts | 19 ++ web/src/lib/sessionChatCursorCatalog.test.ts | 44 +++++ web/src/lib/sessionChatCursorModel.test.ts | 166 ++++++++++++++++++ web/src/lib/sessionChatCursorModel.ts | 79 +++++++++ 15 files changed, 891 insertions(+), 65 deletions(-) create mode 100644 cli/src/modules/common/cursorModelsPrewarm.test.ts create mode 100644 cli/src/modules/common/cursorModelsPrewarm.ts create mode 100644 web/src/lib/cursorCliSkusMerge.test.ts diff --git a/cli/src/agent/backends/acp/agentCliGuard.test.ts b/cli/src/agent/backends/acp/agentCliGuard.test.ts index 812f45e9..e9c78332 100644 --- a/cli/src/agent/backends/acp/agentCliGuard.test.ts +++ b/cli/src/agent/backends/acp/agentCliGuard.test.ts @@ -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); + }); }); diff --git a/cli/src/agent/backends/acp/agentCliGuard.ts b/cli/src/agent/backends/acp/agentCliGuard.ts index 7c68d8e6..b4937ef0 100644 --- a/cli/src/agent/backends/acp/agentCliGuard.ts +++ b/cli/src/agent/backends/acp/agentCliGuard.ts @@ -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 { diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 3c102876..ba6aef50 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -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): McpServerStdio[] { diff --git a/cli/src/modules/common/cursorModels.test.ts b/cli/src/modules/common/cursorModels.test.ts index 4fc2576d..5fd6ecbc 100644 --- a/cli/src/modules/common/cursorModels.test.ts +++ b/cli/src/modules/common/cursorModels.test.ts @@ -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({ diff --git a/cli/src/modules/common/cursorModels.ts b/cli/src/modules/common/cursorModels.ts index d61d4641..14e72851 100644 --- a/cli/src/modules/common/cursorModels.ts +++ b/cli/src/modules/common/cursorModels.ts @@ -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(); + 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 { - 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 { + 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 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 { 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 { 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 { } export function seedCursorModelsCache(response: ListCursorModelsResponse): void { + if ((response.availableModels?.length ?? 0) > 0) { + writeSharedCursorModelsCache(response); + } void applyInMemoryCache(response); } diff --git a/cli/src/modules/common/cursorModelsPrewarm.test.ts b/cli/src/modules/common/cursorModelsPrewarm.test.ts new file mode 100644 index 00000000..a985fe13 --- /dev/null +++ b/cli/src/modules/common/cursorModelsPrewarm.test.ts @@ -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); + }); +}); diff --git a/cli/src/modules/common/cursorModelsPrewarm.ts b/cli/src/modules/common/cursorModelsPrewarm.ts new file mode 100644 index 00000000..7ebd6ed3 --- /dev/null +++ b/cli/src/modules/common/cursorModelsPrewarm.ts @@ -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); + }); +} diff --git a/cli/src/modules/common/cursorModelsSharedCache.test.ts b/cli/src/modules/common/cursorModelsSharedCache.test.ts index 90006a5e..db8e1ae8 100644 --- a/cli/src/modules/common/cursorModelsSharedCache.test.ts +++ b/cli/src/modules/common/cursorModelsSharedCache.test.ts @@ -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); + }); }); diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 3f276cf3..51b6a974 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -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 { // 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. diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 254dfcfe..9694c5cb 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -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(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 diff --git a/web/src/lib/cursorCliSkusMerge.test.ts b/web/src/lib/cursorCliSkusMerge.test.ts new file mode 100644 index 00000000..f31ad216 --- /dev/null +++ b/web/src/lib/cursorCliSkusMerge.test.ts @@ -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' } + ]) + }) +}) diff --git a/web/src/lib/cursorPickerState.ts b/web/src/lib/cursorPickerState.ts index 4416fee1..aa8b0b3e 100644 --- a/web/src/lib/cursorPickerState.ts +++ b/web/src/lib/cursorPickerState.ts @@ -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(); + 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 } diff --git a/web/src/lib/sessionChatCursorCatalog.test.ts b/web/src/lib/sessionChatCursorCatalog.test.ts index 5298ebdb..9262aeed 100644 --- a/web/src/lib/sessionChatCursorCatalog.test.ts +++ b/web/src/lib/sessionChatCursorCatalog.test.ts @@ -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, diff --git a/web/src/lib/sessionChatCursorModel.test.ts b/web/src/lib/sessionChatCursorModel.test.ts index 98b32f5f..8f3118cd 100644 --- a/web/src/lib/sessionChatCursorModel.test.ts +++ b/web/src/lib/sessionChatCursorModel.test.ts @@ -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({ diff --git a/web/src/lib/sessionChatCursorModel.ts b/web/src/lib/sessionChatCursorModel.ts index 25eb0f5c..cf3a24fd 100644 --- a/web/src/lib/sessionChatCursorModel.ts +++ b/web/src/lib/sessionChatCursorModel.ts @@ -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[]