mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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:
@@ -15,6 +15,21 @@ function lockDir(): string {
|
|||||||
return join(testHome, 'locks', 'agent-acp-active');
|
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', () => {
|
describe('agentCliGuard', () => {
|
||||||
const previousHome = process.env.HAPI_HOME;
|
const previousHome = process.env.HAPI_HOME;
|
||||||
|
|
||||||
@@ -35,32 +50,72 @@ describe('agentCliGuard', () => {
|
|||||||
expect(isAgentAcpTransportActive()).toBe(false);
|
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;
|
process.env.HAPI_HOME = testHome;
|
||||||
const dir = lockDir();
|
registerActiveAcpTransport();
|
||||||
mkdirSync(dir, { recursive: true });
|
registerActiveAcpTransport();
|
||||||
writeFileSync(join(dir, 'pid'), '99999999');
|
|
||||||
|
|
||||||
|
unregisterActiveAcpTransport();
|
||||||
|
expect(isAgentAcpTransportActive()).toBe(true);
|
||||||
|
expect(existsSync(lockDir())).toBe(true);
|
||||||
|
|
||||||
|
unregisterActiveAcpTransport();
|
||||||
expect(isAgentAcpTransportActive()).toBe(false);
|
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;
|
process.env.HAPI_HOME = testHome;
|
||||||
|
registerActiveAcpTransport();
|
||||||
|
registerActiveAcpTransport();
|
||||||
|
|
||||||
const dir = lockDir();
|
const dir = lockDir();
|
||||||
mkdirSync(dir, { recursive: true });
|
unregisterActiveAcpTransport();
|
||||||
writeFileSync(join(dir, 'pid'), String(process.pid));
|
|
||||||
|
|
||||||
expect(isAgentAcpTransportActive()).toBe(true);
|
expect(isAgentAcpTransportActive()).toBe(true);
|
||||||
expect(existsSync(dir)).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;
|
process.env.HAPI_HOME = testHome;
|
||||||
const dir = lockDir();
|
const dir = lockDir();
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, 'count'), '1', 'utf8');
|
||||||
|
|
||||||
expect(isAgentAcpTransportActive()).toBe(false);
|
expect(isAgentAcpTransportActive()).toBe(false);
|
||||||
expect(existsSync(dir)).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
@@ -17,6 +24,10 @@ function getAcpLockDir(): string {
|
|||||||
return join(home, 'locks', 'agent-acp-active');
|
return join(home, 'locks', 'agent-acp-active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPidsDir(lockDir: string): string {
|
||||||
|
return join(lockDir, 'pids');
|
||||||
|
}
|
||||||
|
|
||||||
function readLockPid(lockDir: string): number | null {
|
function readLockPid(lockDir: string): number | null {
|
||||||
const pidPath = join(lockDir, 'pid');
|
const pidPath = join(lockDir, 'pid');
|
||||||
if (!existsSync(pidPath)) {
|
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 {
|
function isProcessAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
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. */
|
/** Remove lock directories left behind by SIGKILL / crash / reboot. */
|
||||||
function clearStaleAcpLockIfNeeded(): void {
|
function clearStaleAcpLockIfNeeded(): void {
|
||||||
const lockDir = getAcpLockDir();
|
const lockDir = getAcpLockDir();
|
||||||
@@ -65,10 +156,15 @@ function clearStaleAcpLockIfNeeded(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLegacyLock(lockDir)) {
|
||||||
const pid = readLockPid(lockDir);
|
const pid = readLockPid(lockDir);
|
||||||
if (pid === null || !isProcessAlive(pid)) {
|
if (pid === null || !isProcessAlive(pid)) {
|
||||||
removeAcpLockDir();
|
removeAcpLockDir();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileRefcountLock(lockDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerActiveAcpTransport(): void {
|
export function registerActiveAcpTransport(): void {
|
||||||
@@ -76,7 +172,8 @@ export function registerActiveAcpTransport(): void {
|
|||||||
const lockDir = getAcpLockDir();
|
const lockDir = getAcpLockDir();
|
||||||
try {
|
try {
|
||||||
mkdirSync(lockDir, { recursive: true });
|
mkdirSync(lockDir, { recursive: true });
|
||||||
writeFileSync(join(lockDir, 'pid'), String(process.pid));
|
writeLockCount(lockDir, readLockCount(lockDir) + 1);
|
||||||
|
addLockPid(lockDir, process.pid);
|
||||||
} catch {
|
} catch {
|
||||||
// Another process may have created the lock; in-process guard still applies.
|
// Another process may have created the lock; in-process guard still applies.
|
||||||
}
|
}
|
||||||
@@ -84,18 +181,45 @@ export function registerActiveAcpTransport(): void {
|
|||||||
|
|
||||||
export function unregisterActiveAcpTransport(): void {
|
export function unregisterActiveAcpTransport(): void {
|
||||||
activeAcpTransportCount = Math.max(0, activeAcpTransportCount - 1);
|
activeAcpTransportCount = Math.max(0, activeAcpTransportCount - 1);
|
||||||
if (activeAcpTransportCount > 0) {
|
|
||||||
|
const lockDir = getAcpLockDir();
|
||||||
|
if (!existsSync(lockDir)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLegacyLock(lockDir)) {
|
||||||
|
if (activeAcpTransportCount <= 0) {
|
||||||
removeAcpLockDir();
|
removeAcpLockDir();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (activeAcpTransportCount <= 0) {
|
||||||
|
removeLockPid(lockDir, process.pid);
|
||||||
|
}
|
||||||
|
reconcileRefcountLock(lockDir);
|
||||||
|
} catch {
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isAgentAcpTransportActive(): boolean {
|
export function isAgentAcpTransportActive(): boolean {
|
||||||
if (activeAcpTransportCount > 0) {
|
if (activeAcpTransportCount > 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
clearStaleAcpLockIfNeeded();
|
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 {
|
export function _resetAgentCliGuardForTests(): void {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { setCursorAcpModelsSnapshot } from './utils/cursorAcpModelsBridge';
|
|||||||
import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot';
|
import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot';
|
||||||
import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter';
|
import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter';
|
||||||
import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } from './utils/cursorModeConfig';
|
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';
|
import type { AcpSdkBackend } from '@/agent/backends/acp';
|
||||||
|
|
||||||
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
|
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
|
||||||
@@ -442,8 +443,9 @@ function syncCursorModelsFromAcp(backend: AcpSdkBackend, acpSessionId: string):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = buildCursorModelsSeedPayload(snapshot, readSharedCursorModelsCache());
|
||||||
setCursorAcpModelsSnapshot(snapshot);
|
setCursorAcpModelsSnapshot(snapshot);
|
||||||
seedCursorModelsCache({ success: true, ...snapshot });
|
seedCursorModelsCache(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
|
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from './cursorModelsSharedCache';
|
} from './cursorModelsSharedCache';
|
||||||
import {
|
import {
|
||||||
_resetCursorModelsCacheForTests,
|
_resetCursorModelsCacheForTests,
|
||||||
|
buildCursorModelsSeedPayload,
|
||||||
listCursorModels,
|
listCursorModels,
|
||||||
parseCursorModelsOutput,
|
parseCursorModelsOutput,
|
||||||
seedCursorModelsCache
|
seedCursorModelsCache
|
||||||
@@ -48,6 +49,33 @@ afterEach(() => {
|
|||||||
acpProbeMock.runCursorAcpModelProbe.mockReset()
|
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', () => {
|
describe('parseCursorModelsOutput', () => {
|
||||||
test('parses Cursor agent model list output', () => {
|
test('parses Cursor agent model list output', () => {
|
||||||
const result = parseCursorModelsOutput(`
|
const result = parseCursorModelsOutput(`
|
||||||
@@ -153,6 +181,107 @@ describe('listCursorModels', () => {
|
|||||||
expect(readSharedCursorModelsCache()?.currentModelId).toBe('composer-2.5[fast=true]')
|
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 () => {
|
test('prefers ACP wire probe over CLI slug probe when cache is empty', async () => {
|
||||||
acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({
|
acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({
|
||||||
success: true,
|
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 () => {
|
test('prefers live ACP snapshot over cache while ACP transport is active', async () => {
|
||||||
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
|
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
|
||||||
seedCursorModelsCache({
|
seedCursorModelsCache({
|
||||||
|
|||||||
@@ -18,6 +18,44 @@ import {
|
|||||||
runCursorAcpModelProbe
|
runCursorAcpModelProbe
|
||||||
} from './cursorAcpModelProbe';
|
} 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(
|
function filterCliSkusForWireBases(
|
||||||
cliSkus: CursorModelSummary[],
|
cliSkus: CursorModelSummary[],
|
||||||
wires: CursorModelSummary[]
|
wires: CursorModelSummary[]
|
||||||
@@ -39,44 +77,47 @@ function attachCliSkusToResponse(
|
|||||||
response: ListCursorModelsResponse,
|
response: ListCursorModelsResponse,
|
||||||
cliSkus: readonly CursorModelSummary[]
|
cliSkus: readonly CursorModelSummary[]
|
||||||
): ListCursorModelsResponse {
|
): ListCursorModelsResponse {
|
||||||
if ((response.cliModelSkus?.length ?? 0) > 0) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId));
|
const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId));
|
||||||
const filtered = filterCliSkusForWireBases([...cliSkus], wires);
|
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(
|
async function enrichCursorModelsWithCliSkus(
|
||||||
response: ListCursorModelsResponse
|
response: ListCursorModelsResponse
|
||||||
): Promise<ListCursorModelsResponse> {
|
): Promise<ListCursorModelsResponse> {
|
||||||
if ((response.cliModelSkus?.length ?? 0) > 0) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId));
|
const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId));
|
||||||
if (wires.length === 0) {
|
if (wires.length === 0) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const candidates: CursorModelSummary[] = [];
|
||||||
const shared = readSharedCursorModelsCache();
|
const shared = readSharedCursorModelsCache();
|
||||||
if (shared?.cliModelSkus?.length) {
|
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.
|
// 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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return attachCliSkusToResponse(response, candidates);
|
||||||
const probe = await runCursorModelProbe();
|
|
||||||
const cliSkus = filterCliSkusForWireBases(probe.availableModels ?? [], wires);
|
|
||||||
return cliSkus.length > 0 ? { ...response, cliModelSkus: cliSkus } : response;
|
|
||||||
} catch {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListCursorModelsResponse = CursorModelsResponse;
|
export type ListCursorModelsResponse = CursorModelsResponse;
|
||||||
@@ -134,6 +175,10 @@ export function parseCursorModelsOutput(output: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runCursorModelProbe(): Promise<ListCursorModelsResponse> {
|
async function runCursorModelProbe(): Promise<ListCursorModelsResponse> {
|
||||||
|
if (isAgentAcpTransportActive()) {
|
||||||
|
throw new Error('Cursor ACP transport is active');
|
||||||
|
}
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const child = spawn('agent', ['--list-models'], {
|
const child = spawn('agent', ['--list-models'], {
|
||||||
env: process.env,
|
env: process.env,
|
||||||
@@ -204,7 +249,10 @@ async function listCursorModelsWhileAcpActive(): Promise<ListCursorModelsRespons
|
|||||||
}
|
}
|
||||||
if (cache.expiresAt > Date.now() && (cache.response.availableModels?.length ?? 0) > 0) {
|
if (cache.expiresAt > Date.now() && (cache.response.availableModels?.length ?? 0) > 0) {
|
||||||
const shared = readSharedCursorModelsCache();
|
const shared = readSharedCursorModelsCache();
|
||||||
const cachedSkus = cache.response.cliModelSkus ?? shared?.cliModelSkus ?? [];
|
const cachedSkus = mergeCliModelSkus(
|
||||||
|
cache.response.cliModelSkus ?? [],
|
||||||
|
shared?.cliModelSkus ?? []
|
||||||
|
);
|
||||||
return attachCliSkusToResponse(cache.response, cachedSkus);
|
return attachCliSkusToResponse(cache.response, cachedSkus);
|
||||||
}
|
}
|
||||||
return { success: true, availableModels: [], currentModelId: null };
|
return { success: true, availableModels: [], currentModelId: null };
|
||||||
@@ -240,19 +288,23 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {
|
|||||||
return applyInMemoryCache(acpResponse);
|
return applyInMemoryCache(acpResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
const probeResponse = await runCursorModelProbe();
|
let probeResponse: ListCursorModelsResponse | null = null;
|
||||||
|
if (!isAgentAcpTransportActive()) {
|
||||||
|
probeResponse = await runCursorModelProbe();
|
||||||
if (cursorProbeResponseHasWireCatalog(probeResponse)) {
|
if (cursorProbeResponseHasWireCatalog(probeResponse)) {
|
||||||
return applyInMemoryCache(probeResponse);
|
return applyInMemoryCache(probeResponse);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CLI `--list-models` returns slug ids without bracket params; never cache
|
// CLI `--list-models` returns slug ids without bracket params; never cache
|
||||||
// those for the web picker (New Session would show only Default + current slug).
|
// those for the web picker (New Session would show only Default + current slug).
|
||||||
if (acpResponse.success) {
|
if (acpResponse.success) {
|
||||||
return acpResponse;
|
return acpResponse;
|
||||||
}
|
}
|
||||||
return probeResponse.success
|
if (probeResponse?.success) {
|
||||||
? { success: true, availableModels: [], currentModelId: null }
|
return { success: true, availableModels: [], currentModelId: null };
|
||||||
: probeResponse;
|
}
|
||||||
|
return probeResponse ?? acpResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -267,6 +319,9 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function seedCursorModelsCache(response: ListCursorModelsResponse): void {
|
export function seedCursorModelsCache(response: ListCursorModelsResponse): void {
|
||||||
|
if ((response.availableModels?.length ?? 0) > 0) {
|
||||||
|
writeSharedCursorModelsCache(response);
|
||||||
|
}
|
||||||
void applyInMemoryCache(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 });
|
writeSharedCursorModelsCache({ success: true, availableModels: [], currentModelId: null });
|
||||||
expect(readSharedCursorModelsCache()).toBeNull();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { join } from 'path';
|
|||||||
import { buildMachineMetadata } from '@/agent/sessionFactory';
|
import { buildMachineMetadata } from '@/agent/sessionFactory';
|
||||||
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
|
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
|
||||||
import { hashRunnerCliApiToken } from './runnerIdentity';
|
import { hashRunnerCliApiToken } from './runnerIdentity';
|
||||||
|
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
|
||||||
|
|
||||||
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
|
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
|
||||||
// We don't have cleanup function at the time of server construction
|
// 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
|
// Connect to server
|
||||||
apiMachine.connect();
|
apiMachine.connect();
|
||||||
|
scheduleCursorModelsPrewarm();
|
||||||
|
|
||||||
// Visible startup banner. Use console.log so it always appears on stdout,
|
// Visible startup banner. Use console.log so it always appears on stdout,
|
||||||
// regardless of the verbose/quiet logger setting.
|
// regardless of the verbose/quiet logger setting.
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels'
|
|||||||
import { useCursorModels } from '@/hooks/queries/useCursorModels'
|
import { useCursorModels } from '@/hooks/queries/useCursorModels'
|
||||||
import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine'
|
import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine'
|
||||||
import {
|
import {
|
||||||
buildCursorCatalogFromSources,
|
mergeCursorCliModelSkus,
|
||||||
buildCursorPickerState,
|
|
||||||
resolveCursorBaseFromWire
|
resolveCursorBaseFromWire
|
||||||
} from '@/lib/cursorPickerState'
|
} from '@/lib/cursorPickerState'
|
||||||
import {
|
import {
|
||||||
|
buildSessionCursorPickerState,
|
||||||
|
isSessionCursorCatalogAwaitingSkus,
|
||||||
|
isSessionCursorCatalogPendingWithTimeout,
|
||||||
|
SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS,
|
||||||
resolveSessionCursorBaseSelectValue,
|
resolveSessionCursorBaseSelectValue,
|
||||||
resolveSessionCursorModelChange,
|
resolveSessionCursorModelChange,
|
||||||
resolveSessionCursorVariantSelectValue
|
resolveSessionCursorVariantSelectValue
|
||||||
@@ -225,39 +228,73 @@ export function SessionChat(props: {
|
|||||||
machineId: sessionMachineId,
|
machineId: sessionMachineId,
|
||||||
enabled: agentFlavor === 'cursor' && props.session.active && Boolean(sessionMachineId)
|
enabled: agentFlavor === 'cursor' && props.session.active && Boolean(sessionMachineId)
|
||||||
})
|
})
|
||||||
const sessionCliModelSkus = useMemo(() => {
|
const sessionCliModelSkus = useMemo(() => (
|
||||||
if (cursorModelsState.cliModelSkus.length > 0) {
|
mergeCursorCliModelSkus(
|
||||||
return cursorModelsState.cliModelSkus
|
machineCursorModelsState.cliModelSkus,
|
||||||
}
|
cursorModelsState.cliModelSkus
|
||||||
return machineCursorModelsState.cliModelSkus
|
)
|
||||||
}, [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus])
|
), [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus])
|
||||||
const cursorPicker = useMemo(() => {
|
const cursorPicker = useMemo(() => {
|
||||||
if (agentFlavor !== 'cursor') {
|
if (agentFlavor !== 'cursor') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const catalog = buildCursorCatalogFromSources({
|
return buildSessionCursorPickerState({
|
||||||
sessionModels: cursorModelsState.availableModels,
|
sessionModels: cursorModelsState.availableModels,
|
||||||
machineModels: machineCursorModelsState.availableModels,
|
machineModels: machineCursorModelsState.availableModels,
|
||||||
cliModelSkus: sessionCliModelSkus,
|
cliModelSkus: sessionCliModelSkus,
|
||||||
currentWireId: cursorModelsState.currentModelId ?? props.session.model,
|
sessionModel: props.session.model,
|
||||||
sessionModelFromHub: props.session.model,
|
sessionCurrentModelId: cursorModelsState.currentModelId
|
||||||
defaultValue: null
|
|
||||||
})
|
|
||||||
return buildCursorPickerState({
|
|
||||||
catalog,
|
|
||||||
currentWireId: props.session.model ?? cursorModelsState.currentModelId,
|
|
||||||
defaultValue: null
|
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
agentFlavor,
|
agentFlavor,
|
||||||
cursorModelsState.availableModels,
|
cursorModelsState.availableModels,
|
||||||
cursorModelsState.cliModelSkus,
|
|
||||||
cursorModelsState.currentModelId,
|
cursorModelsState.currentModelId,
|
||||||
machineCursorModelsState.availableModels,
|
machineCursorModelsState.availableModels,
|
||||||
sessionCliModelSkus,
|
sessionCliModelSkus,
|
||||||
props.session.model
|
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(() => {
|
useEffect(() => {
|
||||||
if (agentFlavor !== 'cursor' || !cursorPicker) {
|
if (agentFlavor !== 'cursor' || !cursorPicker) {
|
||||||
@@ -816,7 +853,7 @@ export function SessionChat(props: {
|
|||||||
? codexModelOptions
|
? codexModelOptions
|
||||||
: agentFlavor === 'cursor'
|
: agentFlavor === 'cursor'
|
||||||
? (
|
? (
|
||||||
cursorModelsState.isLoading
|
cursorCatalogPending
|
||||||
|| !cursorPicker
|
|| !cursorPicker
|
||||||
|| cursorPicker.modelOptions.length === 0
|
|| cursorPicker.modelOptions.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
@@ -847,10 +884,13 @@ export function SessionChat(props: {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
selectedModelVariant={
|
selectedModelVariant={
|
||||||
agentFlavor === 'cursor' ? cursorVariantSelectValue : undefined
|
agentFlavor === 'cursor' && !cursorCatalogPending
|
||||||
|
? cursorVariantSelectValue
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
modelEffortOptions={
|
modelEffortOptions={
|
||||||
agentFlavor === 'cursor'
|
agentFlavor === 'cursor'
|
||||||
|
&& !cursorCatalogPending
|
||||||
&& cursorPicker?.mode === 'dual'
|
&& cursorPicker?.mode === 'dual'
|
||||||
&& cursorModelEffortOptions
|
&& cursorModelEffortOptions
|
||||||
&& cursorModelEffortOptions.length > 1
|
&& cursorModelEffortOptions.length > 1
|
||||||
@@ -863,7 +903,7 @@ export function SessionChat(props: {
|
|||||||
: agentFlavor === 'cursor'
|
: agentFlavor === 'cursor'
|
||||||
? (props.session.active
|
? (props.session.active
|
||||||
&& !controlledByUser
|
&& !controlledByUser
|
||||||
&& !cursorModelsState.isLoading
|
&& !cursorCatalogPending
|
||||||
&& !cursorModelsState.error
|
&& !cursorModelsState.error
|
||||||
&& cursorPicker
|
&& cursorPicker
|
||||||
&& cursorPicker.modelOptions.length > 0
|
&& cursorPicker.modelOptions.length > 0
|
||||||
@@ -875,6 +915,7 @@ export function SessionChat(props: {
|
|||||||
agentFlavor === 'cursor'
|
agentFlavor === 'cursor'
|
||||||
&& props.session.active
|
&& props.session.active
|
||||||
&& !controlledByUser
|
&& !controlledByUser
|
||||||
|
&& !cursorCatalogPending
|
||||||
&& !cursorModelsState.error
|
&& !cursorModelsState.error
|
||||||
? handleCursorEffortChange
|
? handleCursorEffortChange
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,6 +13,25 @@ import {
|
|||||||
type CursorModelOption
|
type CursorModelOption
|
||||||
} from '@/lib/cursorModelOptions'
|
} 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 CursorPickerMode = 'dual' | 'flat'
|
||||||
|
|
||||||
export type CursorPickerOption = { value: string; label: string }
|
export type CursorPickerOption = { value: string; label: string }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { appendCliSkusToCatalog, buildCursorModelCatalog } from '@/lib/cursorModelOptions'
|
import { appendCliSkusToCatalog, buildCursorModelCatalog } from '@/lib/cursorModelOptions'
|
||||||
|
import { mergeCursorCliModelSkus } from '@/lib/cursorPickerState'
|
||||||
import {
|
import {
|
||||||
buildSessionCursorPickerState,
|
buildSessionCursorPickerState,
|
||||||
resolveSessionCursorVariantSelectValue
|
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)
|
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', () => {
|
it('highlights the matching CLI sku when session stores the ACP wire id', () => {
|
||||||
const picker = buildSessionCursorPickerState({
|
const picker = buildSessionCursorPickerState({
|
||||||
sessionModels: sessionWires,
|
sessionModels: sessionWires,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
buildSessionCursorPickerState,
|
buildSessionCursorPickerState,
|
||||||
isCursorEffortWireInCatalog,
|
isCursorEffortWireInCatalog,
|
||||||
|
isSessionCursorCatalogAwaitingSkus,
|
||||||
|
isSessionCursorCatalogLoading,
|
||||||
|
isSessionCursorCatalogPending,
|
||||||
|
isSessionCursorCatalogPendingWithTimeout,
|
||||||
resolveSessionCursorBaseSelectValue,
|
resolveSessionCursorBaseSelectValue,
|
||||||
resolveSessionCursorModelChange
|
resolveSessionCursorModelChange
|
||||||
} from '@/lib/sessionChatCursorModel'
|
} 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', () => {
|
describe('isCursorEffortWireInCatalog', () => {
|
||||||
it('checks wireToBase membership', () => {
|
it('checks wireToBase membership', () => {
|
||||||
const picker = buildSessionCursorPickerState({
|
const picker = buildSessionCursorPickerState({
|
||||||
|
|||||||
@@ -112,6 +112,85 @@ export function resolveSessionCursorVariantSelectValue(
|
|||||||
return null
|
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: {
|
export function buildSessionCursorPickerState(args: {
|
||||||
sessionModels: readonly CursorModelSummary[]
|
sessionModels: readonly CursorModelSummary[]
|
||||||
machineModels: readonly CursorModelSummary[]
|
machineModels: readonly CursorModelSummary[]
|
||||||
|
|||||||
Reference in New Issue
Block a user