mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +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');
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,7 +17,8 @@ import { setCursorAcpModelsSnapshot } from './utils/cursorAcpModelsBridge';
|
||||
import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot';
|
||||
import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter';
|
||||
import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } from './utils/cursorModeConfig';
|
||||
import { seedCursorModelsCache } from '@/modules/common/cursorModels';
|
||||
import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels';
|
||||
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
|
||||
import type { AcpSdkBackend } from '@/agent/backends/acp';
|
||||
|
||||
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
|
||||
@@ -442,8 +443,9 @@ function syncCursorModelsFromAcp(backend: AcpSdkBackend, acpSessionId: string):
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildCursorModelsSeedPayload(snapshot, readSharedCursorModelsCache());
|
||||
setCursorAcpModelsSnapshot(snapshot);
|
||||
seedCursorModelsCache({ success: true, ...snapshot });
|
||||
seedCursorModelsCache(payload);
|
||||
}
|
||||
|
||||
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -18,6 +18,44 @@ import {
|
||||
runCursorAcpModelProbe
|
||||
} from './cursorAcpModelProbe';
|
||||
|
||||
export function buildCursorModelsSeedPayload(
|
||||
snapshot: {
|
||||
availableModels: CursorModelSummary[];
|
||||
currentModelId: string | null;
|
||||
cliModelSkus?: readonly CursorModelSummary[];
|
||||
},
|
||||
shared?: CursorModelsResponse | null
|
||||
): ListCursorModelsResponse {
|
||||
const cliModelSkus = mergeCliModelSkus(
|
||||
snapshot.cliModelSkus ?? [],
|
||||
shared?.cliModelSkus ?? []
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
availableModels: snapshot.availableModels,
|
||||
currentModelId: snapshot.currentModelId,
|
||||
...(cliModelSkus.length > 0 ? { cliModelSkus } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCliModelSkus(
|
||||
...lists: readonly (readonly CursorModelSummary[])[]
|
||||
): CursorModelSummary[] {
|
||||
const merged = new Map<string, CursorModelSummary>();
|
||||
for (const list of lists) {
|
||||
for (const entry of list) {
|
||||
const modelId = entry.modelId.trim();
|
||||
if (!modelId) {
|
||||
continue;
|
||||
}
|
||||
if (!merged.has(modelId)) {
|
||||
merged.set(modelId, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function filterCliSkusForWireBases(
|
||||
cliSkus: CursorModelSummary[],
|
||||
wires: CursorModelSummary[]
|
||||
@@ -39,44 +77,47 @@ function attachCliSkusToResponse(
|
||||
response: ListCursorModelsResponse,
|
||||
cliSkus: readonly CursorModelSummary[]
|
||||
): ListCursorModelsResponse {
|
||||
if ((response.cliModelSkus?.length ?? 0) > 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId));
|
||||
const filtered = filterCliSkusForWireBases([...cliSkus], wires);
|
||||
return filtered.length > 0 ? { ...response, cliModelSkus: filtered } : response;
|
||||
const merged = mergeCliModelSkus(response.cliModelSkus ?? [], filtered);
|
||||
if (merged.length === 0) {
|
||||
return response;
|
||||
}
|
||||
if (merged.length === (response.cliModelSkus?.length ?? 0)) {
|
||||
return response;
|
||||
}
|
||||
return { ...response, cliModelSkus: merged };
|
||||
}
|
||||
|
||||
async function enrichCursorModelsWithCliSkus(
|
||||
response: ListCursorModelsResponse
|
||||
): Promise<ListCursorModelsResponse> {
|
||||
if ((response.cliModelSkus?.length ?? 0) > 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId));
|
||||
if (wires.length === 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const candidates: CursorModelSummary[] = [];
|
||||
const shared = readSharedCursorModelsCache();
|
||||
if (shared?.cliModelSkus?.length) {
|
||||
return attachCliSkusToResponse(response, shared.cliModelSkus);
|
||||
candidates.push(...shared.cliModelSkus);
|
||||
}
|
||||
|
||||
// Never spawn `agent --list-models` while an ACP session holds the CLI lock.
|
||||
if (isAgentAcpTransportActive()) {
|
||||
if (!isAgentAcpTransportActive()) {
|
||||
try {
|
||||
const probe = await runCursorModelProbe();
|
||||
candidates.push(...(probe.availableModels ?? []));
|
||||
} catch {
|
||||
// Keep partial candidates from shared cache.
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const probe = await runCursorModelProbe();
|
||||
const cliSkus = filterCliSkusForWireBases(probe.availableModels ?? [], wires);
|
||||
return cliSkus.length > 0 ? { ...response, cliModelSkus: cliSkus } : response;
|
||||
} catch {
|
||||
return response;
|
||||
}
|
||||
return attachCliSkusToResponse(response, candidates);
|
||||
}
|
||||
|
||||
export type ListCursorModelsResponse = CursorModelsResponse;
|
||||
@@ -134,6 +175,10 @@ export function parseCursorModelsOutput(output: string): {
|
||||
}
|
||||
|
||||
async function runCursorModelProbe(): Promise<ListCursorModelsResponse> {
|
||||
if (isAgentAcpTransportActive()) {
|
||||
throw new Error('Cursor ACP transport is active');
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn('agent', ['--list-models'], {
|
||||
env: process.env,
|
||||
@@ -204,7 +249,10 @@ async function listCursorModelsWhileAcpActive(): Promise<ListCursorModelsRespons
|
||||
}
|
||||
if (cache.expiresAt > Date.now() && (cache.response.availableModels?.length ?? 0) > 0) {
|
||||
const shared = readSharedCursorModelsCache();
|
||||
const cachedSkus = cache.response.cliModelSkus ?? shared?.cliModelSkus ?? [];
|
||||
const cachedSkus = mergeCliModelSkus(
|
||||
cache.response.cliModelSkus ?? [],
|
||||
shared?.cliModelSkus ?? []
|
||||
);
|
||||
return attachCliSkusToResponse(cache.response, cachedSkus);
|
||||
}
|
||||
return { success: true, availableModels: [], currentModelId: null };
|
||||
@@ -240,9 +288,12 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {
|
||||
return applyInMemoryCache(acpResponse);
|
||||
}
|
||||
|
||||
const probeResponse = await runCursorModelProbe();
|
||||
if (cursorProbeResponseHasWireCatalog(probeResponse)) {
|
||||
return applyInMemoryCache(probeResponse);
|
||||
let probeResponse: ListCursorModelsResponse | null = null;
|
||||
if (!isAgentAcpTransportActive()) {
|
||||
probeResponse = await runCursorModelProbe();
|
||||
if (cursorProbeResponseHasWireCatalog(probeResponse)) {
|
||||
return applyInMemoryCache(probeResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI `--list-models` returns slug ids without bracket params; never cache
|
||||
@@ -250,9 +301,10 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {
|
||||
if (acpResponse.success) {
|
||||
return acpResponse;
|
||||
}
|
||||
return probeResponse.success
|
||||
? { success: true, availableModels: [], currentModelId: null }
|
||||
: probeResponse;
|
||||
if (probeResponse?.success) {
|
||||
return { success: true, availableModels: [], currentModelId: null };
|
||||
}
|
||||
return probeResponse ?? acpResponse;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -267,6 +319,9 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {
|
||||
}
|
||||
|
||||
export function seedCursorModelsCache(response: ListCursorModelsResponse): void {
|
||||
if ((response.availableModels?.length ?? 0) > 0) {
|
||||
writeSharedCursorModelsCache(response);
|
||||
}
|
||||
void applyInMemoryCache(response);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const listCursorModelsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./cursorModels', () => ({
|
||||
listCursorModels: listCursorModelsMock
|
||||
}));
|
||||
|
||||
import { scheduleCursorModelsPrewarm } from './cursorModelsPrewarm';
|
||||
|
||||
afterEach(() => {
|
||||
listCursorModelsMock.mockReset();
|
||||
});
|
||||
|
||||
describe('scheduleCursorModelsPrewarm', () => {
|
||||
test('starts a background listCursorModels call', async () => {
|
||||
listCursorModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
availableModels: [],
|
||||
currentModelId: null
|
||||
});
|
||||
|
||||
scheduleCursorModelsPrewarm();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(listCursorModelsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('swallows listCursorModels failures', async () => {
|
||||
listCursorModelsMock.mockRejectedValue(new Error('agent missing'));
|
||||
|
||||
scheduleCursorModelsPrewarm();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(listCursorModelsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { logger } from '@/ui/logger';
|
||||
import { listCursorModels } from './cursorModels';
|
||||
|
||||
/** Background fill of shared cursor-models cache; does not block runner startup. */
|
||||
export function scheduleCursorModelsPrewarm(): void {
|
||||
void listCursorModels().catch((error) => {
|
||||
logger.debug('[RUNNER RUN] Cursor model pre-warm failed', error);
|
||||
});
|
||||
}
|
||||
@@ -26,4 +26,19 @@ describe('cursorModelsSharedCache', () => {
|
||||
writeSharedCursorModelsCache({ success: true, availableModels: [], currentModelId: null });
|
||||
expect(readSharedCursorModelsCache()).toBeNull();
|
||||
});
|
||||
|
||||
test('round-trips cliModelSkus with wire catalog', () => {
|
||||
const payload = {
|
||||
success: true as const,
|
||||
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
|
||||
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
|
||||
cliModelSkus: [
|
||||
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }
|
||||
]
|
||||
};
|
||||
|
||||
writeSharedCursorModelsCache(payload);
|
||||
|
||||
expect(readSharedCursorModelsCache()?.cliModelSkus).toEqual(payload.cliModelSkus);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { join } from 'path';
|
||||
import { buildMachineMetadata } from '@/agent/sessionFactory';
|
||||
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
|
||||
import { hashRunnerCliApiToken } from './runnerIdentity';
|
||||
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
|
||||
|
||||
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
|
||||
// We don't have cleanup function at the time of server construction
|
||||
@@ -720,6 +721,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
||||
|
||||
// Connect to server
|
||||
apiMachine.connect();
|
||||
scheduleCursorModelsPrewarm();
|
||||
|
||||
// Visible startup banner. Use console.log so it always appears on stdout,
|
||||
// regardless of the verbose/quiet logger setting.
|
||||
|
||||
@@ -36,11 +36,14 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels'
|
||||
import { useCursorModels } from '@/hooks/queries/useCursorModels'
|
||||
import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine'
|
||||
import {
|
||||
buildCursorCatalogFromSources,
|
||||
buildCursorPickerState,
|
||||
mergeCursorCliModelSkus,
|
||||
resolveCursorBaseFromWire
|
||||
} from '@/lib/cursorPickerState'
|
||||
import {
|
||||
buildSessionCursorPickerState,
|
||||
isSessionCursorCatalogAwaitingSkus,
|
||||
isSessionCursorCatalogPendingWithTimeout,
|
||||
SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS,
|
||||
resolveSessionCursorBaseSelectValue,
|
||||
resolveSessionCursorModelChange,
|
||||
resolveSessionCursorVariantSelectValue
|
||||
@@ -225,39 +228,73 @@ export function SessionChat(props: {
|
||||
machineId: sessionMachineId,
|
||||
enabled: agentFlavor === 'cursor' && props.session.active && Boolean(sessionMachineId)
|
||||
})
|
||||
const sessionCliModelSkus = useMemo(() => {
|
||||
if (cursorModelsState.cliModelSkus.length > 0) {
|
||||
return cursorModelsState.cliModelSkus
|
||||
}
|
||||
return machineCursorModelsState.cliModelSkus
|
||||
}, [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus])
|
||||
const sessionCliModelSkus = useMemo(() => (
|
||||
mergeCursorCliModelSkus(
|
||||
machineCursorModelsState.cliModelSkus,
|
||||
cursorModelsState.cliModelSkus
|
||||
)
|
||||
), [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus])
|
||||
const cursorPicker = useMemo(() => {
|
||||
if (agentFlavor !== 'cursor') {
|
||||
return null
|
||||
}
|
||||
|
||||
const catalog = buildCursorCatalogFromSources({
|
||||
return buildSessionCursorPickerState({
|
||||
sessionModels: cursorModelsState.availableModels,
|
||||
machineModels: machineCursorModelsState.availableModels,
|
||||
cliModelSkus: sessionCliModelSkus,
|
||||
currentWireId: cursorModelsState.currentModelId ?? props.session.model,
|
||||
sessionModelFromHub: props.session.model,
|
||||
defaultValue: null
|
||||
})
|
||||
return buildCursorPickerState({
|
||||
catalog,
|
||||
currentWireId: props.session.model ?? cursorModelsState.currentModelId,
|
||||
defaultValue: null
|
||||
sessionModel: props.session.model,
|
||||
sessionCurrentModelId: cursorModelsState.currentModelId
|
||||
})
|
||||
}, [
|
||||
agentFlavor,
|
||||
cursorModelsState.availableModels,
|
||||
cursorModelsState.cliModelSkus,
|
||||
cursorModelsState.currentModelId,
|
||||
machineCursorModelsState.availableModels,
|
||||
sessionCliModelSkus,
|
||||
props.session.model
|
||||
])
|
||||
const cursorCatalogReadinessArgs = useMemo(() => ({
|
||||
sessionLoading: cursorModelsState.isLoading,
|
||||
machineLoading: machineCursorModelsState.isLoading,
|
||||
hasMachineId: Boolean(sessionMachineId),
|
||||
sessionError: cursorModelsState.error,
|
||||
machineError: machineCursorModelsState.error,
|
||||
mergedSkus: sessionCliModelSkus,
|
||||
picker: cursorPicker
|
||||
}), [
|
||||
cursorModelsState.isLoading,
|
||||
cursorModelsState.error,
|
||||
machineCursorModelsState.isLoading,
|
||||
machineCursorModelsState.error,
|
||||
sessionMachineId,
|
||||
sessionCliModelSkus,
|
||||
cursorPicker
|
||||
])
|
||||
const cursorCatalogAwaitingSkus = useMemo(
|
||||
() => isSessionCursorCatalogAwaitingSkus(cursorCatalogReadinessArgs),
|
||||
[cursorCatalogReadinessArgs]
|
||||
)
|
||||
const [cursorSkuAwaitingSince, setCursorSkuAwaitingSince] = useState<number | null>(null)
|
||||
const [cursorCatalogNowMs, setCursorCatalogNowMs] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
if (cursorCatalogAwaitingSkus) {
|
||||
setCursorSkuAwaitingSince((previous) => previous ?? Date.now())
|
||||
const timer = setTimeout(
|
||||
() => setCursorCatalogNowMs(Date.now()),
|
||||
SESSION_CURSOR_CATALOG_SKU_TIMEOUT_MS
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setCursorSkuAwaitingSince(null)
|
||||
setCursorCatalogNowMs(Date.now())
|
||||
return undefined
|
||||
}, [cursorCatalogAwaitingSkus])
|
||||
const cursorCatalogPending = isSessionCursorCatalogPendingWithTimeout({
|
||||
...cursorCatalogReadinessArgs,
|
||||
awaitingStartedAtMs: cursorSkuAwaitingSince,
|
||||
nowMs: cursorCatalogNowMs
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (agentFlavor !== 'cursor' || !cursorPicker) {
|
||||
@@ -816,7 +853,7 @@ export function SessionChat(props: {
|
||||
? codexModelOptions
|
||||
: agentFlavor === 'cursor'
|
||||
? (
|
||||
cursorModelsState.isLoading
|
||||
cursorCatalogPending
|
||||
|| !cursorPicker
|
||||
|| cursorPicker.modelOptions.length === 0
|
||||
? undefined
|
||||
@@ -847,10 +884,13 @@ export function SessionChat(props: {
|
||||
: undefined
|
||||
}
|
||||
selectedModelVariant={
|
||||
agentFlavor === 'cursor' ? cursorVariantSelectValue : undefined
|
||||
agentFlavor === 'cursor' && !cursorCatalogPending
|
||||
? cursorVariantSelectValue
|
||||
: undefined
|
||||
}
|
||||
modelEffortOptions={
|
||||
agentFlavor === 'cursor'
|
||||
&& !cursorCatalogPending
|
||||
&& cursorPicker?.mode === 'dual'
|
||||
&& cursorModelEffortOptions
|
||||
&& cursorModelEffortOptions.length > 1
|
||||
@@ -863,7 +903,7 @@ export function SessionChat(props: {
|
||||
: agentFlavor === 'cursor'
|
||||
? (props.session.active
|
||||
&& !controlledByUser
|
||||
&& !cursorModelsState.isLoading
|
||||
&& !cursorCatalogPending
|
||||
&& !cursorModelsState.error
|
||||
&& cursorPicker
|
||||
&& cursorPicker.modelOptions.length > 0
|
||||
@@ -875,6 +915,7 @@ export function SessionChat(props: {
|
||||
agentFlavor === 'cursor'
|
||||
&& props.session.active
|
||||
&& !controlledByUser
|
||||
&& !cursorCatalogPending
|
||||
&& !cursorModelsState.error
|
||||
? handleCursorEffortChange
|
||||
: undefined
|
||||
|
||||
@@ -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
|
||||
} from '@/lib/cursorModelOptions'
|
||||
|
||||
export function mergeCursorCliModelSkus(
|
||||
...sources: readonly (readonly CursorModelSummary[])[]
|
||||
): CursorModelSummary[] {
|
||||
const sorted = [...sources].sort((a, b) => b.length - a.length);
|
||||
const merged = new Map<string, CursorModelSummary>();
|
||||
for (const source of sorted) {
|
||||
for (const entry of source) {
|
||||
const modelId = entry.modelId.trim();
|
||||
if (!modelId) {
|
||||
continue;
|
||||
}
|
||||
if (!merged.has(modelId)) {
|
||||
merged.set(modelId, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
export type CursorPickerMode = 'dual' | 'flat'
|
||||
|
||||
export type CursorPickerOption = { value: string; label: string }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { appendCliSkusToCatalog, buildCursorModelCatalog } from '@/lib/cursorModelOptions'
|
||||
import { mergeCursorCliModelSkus } from '@/lib/cursorPickerState'
|
||||
import {
|
||||
buildSessionCursorPickerState,
|
||||
resolveSessionCursorVariantSelectValue
|
||||
@@ -29,6 +30,49 @@ describe('in-session cursor catalog with CLI skus', () => {
|
||||
expect(catalog.variantsByBase.get('gpt-5.5')?.some((row) => row.wireId === 'gpt-5.5-high-fast')).toBe(true)
|
||||
})
|
||||
|
||||
it('merges machine SKU catalog over partial session SKUs for friendly variant labels', () => {
|
||||
const machineWires = [
|
||||
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' },
|
||||
{ modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' }
|
||||
]
|
||||
const sessionSkus = [
|
||||
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
|
||||
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }
|
||||
]
|
||||
const machineSkus = [
|
||||
...sessionSkus,
|
||||
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
|
||||
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
|
||||
]
|
||||
const mergedSkus = mergeCursorCliModelSkus(machineSkus, sessionSkus)
|
||||
const picker = buildSessionCursorPickerState({
|
||||
sessionModels: sessionWires,
|
||||
machineModels: machineWires,
|
||||
cliModelSkus: mergedSkus,
|
||||
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
|
||||
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
|
||||
})
|
||||
|
||||
expect(picker.mode).toBe('dual')
|
||||
expect(picker.effortOptions.length).toBeGreaterThan(2)
|
||||
expect(picker.effortOptions.every((row) => !row.label.includes('context=272k'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows raw variant suffixes when dual catalog has wires but no CLI skus', () => {
|
||||
const picker = buildSessionCursorPickerState({
|
||||
sessionModels: [
|
||||
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' },
|
||||
{ modelId: 'gpt-5.5[context=272k,reasoning=high,fast=false]', name: 'gpt-5.5' }
|
||||
],
|
||||
machineModels: [],
|
||||
cliModelSkus: [],
|
||||
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
|
||||
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
|
||||
})
|
||||
|
||||
expect(picker.effortOptions.some((row) => row.label.includes('reasoning='))).toBe(true)
|
||||
})
|
||||
|
||||
it('highlights the matching CLI sku when session stores the ACP wire id', () => {
|
||||
const picker = buildSessionCursorPickerState({
|
||||
sessionModels: sessionWires,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user