diff --git a/cli/src/modules/common/cursorModels.ts b/cli/src/modules/common/cursorModels.ts index 33a17c5e85..352cf469f0 100644 --- a/cli/src/modules/common/cursorModels.ts +++ b/cli/src/modules/common/cursorModels.ts @@ -11,6 +11,7 @@ import { killProcessByChildProcess } from '@/utils/process'; import { getCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge'; import { getErrorMessage } from './rpcResponses'; import { + getSharedCursorModelsCacheAgeMs, readSharedCursorModelsCache, writeSharedCursorModelsCache, _resetSharedCursorModelsCacheForTests @@ -162,6 +163,8 @@ interface CacheEntry { } const CACHE_TTL_MS = 60_000; +/** Beyond this, a cached catalog is too old to reject a model id against. */ +const CACHED_CATALOG_MAX_AGE_MS = 24 * 60 * 60 * 1000; const PROBE_TIMEOUT_MS = 30_000; const cache: CacheEntry = { expiresAt: 0, @@ -283,12 +286,23 @@ async function runCursorModelProbe(): Promise { }); } -async function applyInMemoryCache(response: ListCursorModelsResponse): Promise { +/** + * `persist` must stay false whenever `response` was itself read back off the + * shared cache. The file's mtime is what dates the catalog for the spawn + * preflight (getCachedCursorModelIds), so writing an old snapshot back would + * renew its freshness without anyone having asked Cursor for a new one — a + * catalog from before a Cursor upgrade would then keep rejecting models that + * are now valid. Only a live source (ACP snapshot or probe) may stamp the file. + */ +async function applyInMemoryCache( + response: ListCursorModelsResponse, + persist = true +): Promise { const enriched = await enrichCursorModelsWithCliSkus(response); if ((enriched.availableModels?.length ?? 0) > 0) { cache.expiresAt = Date.now() + CACHE_TTL_MS; cache.response = enriched; - writeSharedCursorModelsCache(enriched); + if (persist) writeSharedCursorModelsCache(enriched); } return enriched; } @@ -302,7 +316,7 @@ async function listCursorModelsWhileAcpActive(): Promise Date.now() && (cache.response.availableModels?.length ?? 0) > 0) { const shared = readSharedCursorModelsCache(); @@ -331,7 +345,7 @@ export async function listCursorModels(): Promise { const shared = readSharedCursorModelsCache(); if (shared) { - return applyInMemoryCache(shared); + return applyInMemoryCache(shared, false); } if (inflight) { @@ -376,6 +390,40 @@ export async function listCursorModels(): Promise { return inflight; } +/** + * Catalog ids from whatever is already cached (ACP snapshot, in-process cache, + * on-disk shared cache). Never probes: the spawn preflight must not block on + * `agent --list-models`, which can take 30s and contends with the ACP spawn + * lease. Returns [] when nothing usable is cached, which callers must read as + * "unknown catalog", not "no models". + */ +export function getCachedCursorModelIds(): string[] { + const sources: (readonly CursorModelSummary[] | undefined)[] = []; + // Live ACP session snapshot — current by construction. + sources.push(getCursorAcpModelsSnapshot()?.availableModels); + + // The in-memory entry and the file are written together by + // applyInMemoryCache, so the file's mtime dates both. Past the age bound + // both are dropped rather than trusted past their TTL: a runner can stay up + // for weeks, and a catalog that predates a Cursor upgrade would reject an id + // that is now valid. + const sharedAgeMs = getSharedCursorModelsCacheAgeMs(); + if (sharedAgeMs !== null && sharedAgeMs <= CACHED_CATALOG_MAX_AGE_MS) { + sources.push(cache.response.availableModels, cache.response.cliModelSkus); + const shared = readSharedCursorModelsCache(); + sources.push(shared?.availableModels, shared?.cliModelSkus); + } + + const ids = new Set(); + for (const source of sources) { + for (const entry of source ?? []) { + const modelId = entry.modelId.trim(); + if (modelId) ids.add(modelId); + } + } + return [...ids]; +} + export function seedCursorModelsCache(response: ListCursorModelsResponse): void { if ((response.availableModels?.length ?? 0) > 0) { writeSharedCursorModelsCache(response); diff --git a/cli/src/modules/common/cursorModelsSharedCache.ts b/cli/src/modules/common/cursorModelsSharedCache.ts index b7e508497d..c99605d241 100644 --- a/cli/src/modules/common/cursorModelsSharedCache.ts +++ b/cli/src/modules/common/cursorModelsSharedCache.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { CursorModelsResponse } from '@hapi/protocol/apiTypes'; import { resolveHapiHomeDir } from '@/configuration'; @@ -33,6 +33,16 @@ export function readSharedCursorModelsCache(): CursorModelsResponse | null { } } +/** Age of the on-disk catalog in ms, or null when there is no cache file. */ +export function getSharedCursorModelsCacheAgeMs(): number | null { + const path = getSharedCachePath(); + try { + return Math.max(0, Date.now() - statSync(path).mtimeMs); + } catch { + return null; + } +} + export function writeSharedCursorModelsCache(response: CursorModelsResponse): void { if (!isUsableModelsResponse(response)) { return; diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 53ae84f12f..766c62c266 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -37,6 +37,6 @@ export type SpawnSessionResult = | { type: 'error' errorMessage: string - code?: 'agent_unavailable' | 'outside_workspace_roots' + code?: 'agent_unavailable' | 'model_unavailable' | 'outside_workspace_roots' agent?: AgentFlavor } diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 245dc398fe..ee0e4cfa57 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -29,6 +29,7 @@ import { buildMachineMetadata } from '@/agent/sessionFactory'; import { resolveWorkspaceRoots } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity'; import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm'; +import { checkSpawnModel } from './spawnModelPreflight'; import { isLinkedGitWorktree } from '@/utils/isLinkedGitWorktree'; import { agentUnavailableMessage, getAgentAvailability } from '@/agent/agentAvailability'; @@ -515,6 +516,20 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): agent }; } + const modelCheck = checkSpawnModel(agent, options.model); + if (!modelCheck.ok) { + logger.debug(`[RUNNER RUN] Model preflight failed: ${modelCheck.message}`); + reportSpawnOutcomeToHub?.({ + type: 'error', + details: { message: modelCheck.message } + }); + return { + type: 'error', + errorMessage: modelCheck.message, + code: 'model_unavailable', + agent + }; + } if (options.validateDirectory && !(await options.validateDirectory(directory))) { return { type: 'error', diff --git a/cli/src/runner/spawnModelPreflight.test.ts b/cli/src/runner/spawnModelPreflight.test.ts new file mode 100644 index 0000000000..2b4cf9d7d4 --- /dev/null +++ b/cli/src/runner/spawnModelPreflight.test.ts @@ -0,0 +1,112 @@ +import { afterAll, afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge'; +import { _resetCursorModelsCacheForTests, listCursorModels, seedCursorModelsCache } from '@/modules/common/cursorModels'; +import { writeSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; +import { checkSpawnModel } from './spawnModelPreflight'; + +// Isolate the on-disk cursor-models cache to this file's own HAPI_HOME so +// parallel vitest workers don't race on the shared $HAPI_HOME/cache path. +const previousHapiHome = process.env.HAPI_HOME; +const testHapiHome = mkdtempSync(join(tmpdir(), 'hapi-spawn-model-preflight-')); +process.env.HAPI_HOME = testHapiHome; + +function ageSharedCache(ageMs: number): void { + const when = new Date(Date.now() - ageMs); + utimesSync(join(testHapiHome, 'cache', 'cursor-models.json'), when, when); +} + +afterEach(() => { + setCursorAcpModelsSnapshot(null); + _resetCursorModelsCacheForTests(); +}); + +afterAll(() => { + if (previousHapiHome === undefined) delete process.env.HAPI_HOME; + else process.env.HAPI_HOME = previousHapiHome; + rmSync(testHapiHome, { recursive: true, force: true }); +}); + +describe('checkSpawnModel', () => { + it('rejects a Cursor model missing from the cached catalog (#1752)', () => { + setCursorAcpModelsSnapshot({ + availableModels: [{ modelId: 'composer-2.5[thinking]' }, { modelId: 'gpt-5.1[reasoning=high]' }], + currentModelId: 'composer-2.5[thinking]' + }); + + const result = checkSpawnModel('cursor', 'gpt-5'); + + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toContain("Model 'gpt-5' is not in the cursor model catalog"); + expect(result.ok === false && result.message).toContain('Accepted: gpt-5.1, composer-2.5'); + }); + + it('accepts a model present in the cached catalog', () => { + setCursorAcpModelsSnapshot({ + availableModels: [{ modelId: 'composer-2.5[thinking]' }], + currentModelId: 'composer-2.5[thinking]' + }); + + expect(checkSpawnModel('cursor', 'composer-2.5[thinking]')).toEqual({ ok: true }); + }); + + it('reads the on-disk shared catalog when no ACP snapshot is live', () => { + seedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'composer-2.5[thinking]' }], + currentModelId: 'composer-2.5[thinking]' + }); + + expect(checkSpawnModel('cursor', 'composer-2.5')).toEqual({ ok: true }); + expect(checkSpawnModel('cursor', 'gpt-5').ok).toBe(false); + }); + + it('ignores a shared catalog older than the freshness bound', () => { + seedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'composer-2.5[thinking]' }], + currentModelId: 'composer-2.5[thinking]' + }); + // A catalog that predates a Cursor upgrade must not reject an id that is + // now valid; the preflight goes dormant instead. + ageSharedCache(2 * 24 * 60 * 60 * 1000); + + expect(checkSpawnModel('cursor', 'gpt-5')).toEqual({ ok: true }); + }); + + it('does not let a cache read renew the freshness of a stale catalog', async () => { + // Fresh runner: cold in-process cache, shared file left over from before a + // Cursor upgrade. Startup prewarm reads that file; it must not stamp it as + // current, or the preflight would keep rejecting models added since. + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'composer-2.5' }], + currentModelId: 'composer-2.5' + }); + ageSharedCache(2 * 24 * 60 * 60 * 1000); + + await listCursorModels(); + + expect(checkSpawnModel('cursor', 'gpt-5')).toEqual({ ok: true }); + }); + + it('allows any model when no catalog is cached', () => { + expect(checkSpawnModel('cursor', 'gpt-5')).toEqual({ ok: true }); + }); + + it('allows flavors without a cached catalog', () => { + expect(checkSpawnModel('claude', 'claude-opus-5')).toEqual({ ok: true }); + expect(checkSpawnModel('codex', 'gpt-5')).toEqual({ ok: true }); + }); + + it('allows a spawn with no model', () => { + setCursorAcpModelsSnapshot({ + availableModels: [{ modelId: 'composer-2.5[thinking]' }], + currentModelId: 'composer-2.5[thinking]' + }); + + expect(checkSpawnModel('cursor', undefined)).toEqual({ ok: true }); + }); +}); diff --git a/cli/src/runner/spawnModelPreflight.ts b/cli/src/runner/spawnModelPreflight.ts new file mode 100644 index 0000000000..77f27c1a70 --- /dev/null +++ b/cli/src/runner/spawnModelPreflight.ts @@ -0,0 +1,23 @@ +import type { AgentFlavor, SpawnModelValidation } from '@hapi/protocol'; +import { validateSpawnModelAgainstCatalog } from '@hapi/protocol'; +import { getCachedCursorModelIds } from '@/modules/common/cursorModels'; + +/** + * Cached-only model catalogs for the spawn preflight. Flavors without a cached + * catalog return [], which `validateSpawnModelAgainstCatalog` treats as + * "unknown" and never rejects. Add a flavor here only when its ids can be read + * without spawning a probe. + */ +function getCachedModelCatalog(agent: AgentFlavor): string[] { + if (agent === 'cursor') return getCachedCursorModelIds(); + return []; +} + +/** + * Fail-closed guard against spawning a child that would die at agent handshake + * because the requested model does not exist (e.g. `--agent cursor --model gpt-5`), + * leaving an archived session with no turns behind. + */ +export function checkSpawnModel(agent: AgentFlavor, model: string | null | undefined): SpawnModelValidation { + return validateSpawnModelAgainstCatalog(agent, model, getCachedModelCatalog(agent)); +} diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index b749647f24..88672b0289 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -193,7 +193,7 @@ export class RpcGateway { | { type: 'error' message: string - code?: 'agent_unavailable' | 'outside_workspace_roots' + code?: 'agent_unavailable' | 'model_unavailable' | 'outside_workspace_roots' agent?: AgentFlavor } > { @@ -228,7 +228,7 @@ export class RpcGateway { return { type: 'success', sessionId: obj.sessionId } } if (obj.type === 'error' && typeof obj.errorMessage === 'string') { - const code = obj.code === 'agent_unavailable' || obj.code === 'outside_workspace_roots' + const code = obj.code === 'agent_unavailable' || obj.code === 'model_unavailable' || obj.code === 'outside_workspace_roots' ? obj.code : undefined const unavailableAgent = typeof obj.agent === 'string' ? obj.agent as AgentFlavor : undefined diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index f0c0d132dc..df275baedc 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -133,7 +133,7 @@ export type SpawnResponse = | { type: 'error' message: string - code?: 'agent_unavailable' | 'runner_upgrade_required' | 'outside_workspace_roots' + code?: 'agent_unavailable' | 'model_unavailable' | 'runner_upgrade_required' | 'outside_workspace_roots' agent?: z.infer } diff --git a/shared/src/index.ts b/shared/src/index.ts index 4fa14330f9..8d4bf0861a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -17,6 +17,7 @@ export * from './socket' export * from './sessionSummary' export * from './sessionCitation' export * from './sessionExport' +export * from './spawnModelCatalog' export * from './piThinkingLevel' export * from './runnerCapabilities' export * from './agentConfig' diff --git a/shared/src/spawnModelCatalog.test.ts b/shared/src/spawnModelCatalog.test.ts new file mode 100644 index 0000000000..8f81e9a3d5 --- /dev/null +++ b/shared/src/spawnModelCatalog.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { validateSpawnModelAgainstCatalog } from './spawnModelCatalog'; + +const CURSOR_CATALOG = [ + 'auto', + 'composer-2.5[thinking]', + 'gpt-5.1[reasoning=high]', + 'cursor-grok-4.5-fast' +]; + +describe('validateSpawnModelAgainstCatalog', () => { + it('rejects a model the catalog does not contain and names the accepted ids', () => { + const result = validateSpawnModelAgainstCatalog('cursor', 'gpt-5', CURSOR_CATALOG); + + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toContain("Model 'gpt-5' is not in the cursor model catalog"); + // Cursor wire ids collapse to the base slug the user can actually pass. + expect(result.ok === false && result.message).toContain('Accepted: gpt-5.1, auto, composer-2.5, cursor-grok-4.5'); + }); + + it('accepts an exact catalog id', () => { + expect(validateSpawnModelAgainstCatalog('cursor', 'gpt-5.1[reasoning=high]', CURSOR_CATALOG)).toEqual({ ok: true }); + }); + + it('accepts a Cursor base slug for a parameterized wire id', () => { + expect(validateSpawnModelAgainstCatalog('cursor', 'composer-2.5', CURSOR_CATALOG)).toEqual({ ok: true }); + }); + + it('accepts a Cursor CLI sku whose base is in the catalog', () => { + expect(validateSpawnModelAgainstCatalog('cursor', 'cursor-grok-4.5-high', CURSOR_CATALOG)).toEqual({ ok: true }); + }); + + it('accepts a renamed Cursor base that the stale-model remap would resolve', () => { + expect(validateSpawnModelAgainstCatalog('cursor', 'grok-4.5', ['cursor-grok-4.5-fast'])).toEqual({ ok: true }); + }); + + it('matches catalog ids case-insensitively', () => { + expect(validateSpawnModelAgainstCatalog('cursor', 'Composer-2.5', CURSOR_CATALOG)).toEqual({ ok: true }); + }); + + it('leaves variant-level availability to the handshake', () => { + // Absence of a variant from a cached catalog is not proof it is gone: + // shared cliModelSkus rows are partial until a probe unions more in, and + // that probe is skipped while an ACP session holds the CLI lock. Rejecting + // here would block valid spawns on any mid-union or wire-only cache. + expect(validateSpawnModelAgainstCatalog('cursor', 'gpt-5.5-high-fast', ['gpt-5.5-medium'])).toEqual({ ok: true }); + expect(validateSpawnModelAgainstCatalog( + 'cursor', + 'claude-opus-4-8[thinking=false,effort=high]', + ['claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]'] + )).toEqual({ ok: true }); + }); + + it('accepts wildcard ids and no model at all', () => { + expect(validateSpawnModelAgainstCatalog('cursor', 'auto', CURSOR_CATALOG)).toEqual({ ok: true }); + expect(validateSpawnModelAgainstCatalog('cursor', 'Auto', CURSOR_CATALOG)).toEqual({ ok: true }); + expect(validateSpawnModelAgainstCatalog('cursor', 'default', CURSOR_CATALOG)).toEqual({ ok: true }); + // Bare ACP wire form the spawn path also reads as "let the agent pick". + expect(validateSpawnModelAgainstCatalog('cursor', 'default[]', CURSOR_CATALOG)).toEqual({ ok: true }); + expect(validateSpawnModelAgainstCatalog('cursor', undefined, CURSOR_CATALOG)).toEqual({ ok: true }); + expect(validateSpawnModelAgainstCatalog('cursor', ' ', CURSOR_CATALOG)).toEqual({ ok: true }); + }); + + it('never rejects when the machine could not enumerate a catalog', () => { + expect(validateSpawnModelAgainstCatalog('cursor', 'gpt-5', [])).toEqual({ ok: true }); + expect(validateSpawnModelAgainstCatalog('claude', 'claude-opus-5', [])).toEqual({ ok: true }); + }); + + it('does not apply Cursor sku-suffix stripping to other flavors', () => { + // agy ships `-` ids; `-high` is part of the id, not a sku suffix. + expect(validateSpawnModelAgainstCatalog('agy', 'gemini-3.7-flash-low', ['gemini-3.7-flash-high'])).toEqual({ + ok: false, + message: "Model 'gemini-3.7-flash-low' is not in the agy model catalog on this machine. Accepted: gemini-3.7-flash-high" + }); + }); + + it('lists near-miss ids before the rest of the catalog', () => { + const catalog = ['claude-opus-5', 'composer-2.5', 'gpt-5.2', 'gpt-5.5']; + const result = validateSpawnModelAgainstCatalog('cursor', 'gpt-5', catalog); + + expect(result.ok === false && result.message).toContain('Accepted: gpt-5.2, gpt-5.5, claude-opus-5, composer-2.5'); + }); + + it('truncates a long accepted list', () => { + const catalog = Array.from({ length: 20 }, (_, index) => `model-${index}`); + const result = validateSpawnModelAgainstCatalog('cursor', 'nope', catalog); + + expect(result.ok === false && result.message).toContain('… (20 total)'); + }); +}); diff --git a/shared/src/spawnModelCatalog.ts b/shared/src/spawnModelCatalog.ts new file mode 100644 index 0000000000..cdb8a39ebf --- /dev/null +++ b/shared/src/spawnModelCatalog.ts @@ -0,0 +1,110 @@ +import { cursorCliSkuBaseId, cursorModelBaseId, resolveCursorLegacyModelBase } from './cursorCliSku' +import type { AgentFlavor } from './modes' + +/** + * Ids that mean "let the agent pick"; never matched against a catalog. Matches + * the spawn path's own reading of them (see cursorAcpBackend / cursorModeConfig), + * which is case-insensitive and also accepts the bare wire form `default[]`. + */ +const WILDCARD_MODEL_IDS = new Set(['auto', 'default']) + +/** Keeps the rejection message readable when a catalog has dozens of skus. */ +const MAX_LISTED_MODELS = 12 + +export type SpawnModelValidation = + | { ok: true } + | { ok: false; message: string } + +/** + * Ids a catalog entry (or a requested model) should be compared under. Cursor + * ships the same model as an ACP wire id (`composer-2.5[thinking]`) and as CLI + * skus (`composer-2.5-high-fast`), so both collapse to their base slug. + */ +function catalogCandidates(agent: AgentFlavor, modelId: string): string[] { + const trimmed = modelId.trim().toLowerCase() + if (!trimmed) return [] + if (agent !== 'cursor') return [trimmed] + // Renamed bases (grok-4.5 → cursor-grok-4.5) still reach spawn from stale hub + // rows and mobile drafts, where cursorStaleModelRemap resolves them; the + // preflight must not reject what that remap would have fixed. + const bases = [trimmed, cursorModelBaseId(trimmed), cursorCliSkuBaseId(trimmed)] + return [...new Set([...bases, ...bases.map(resolveCursorLegacyModelBase)])].filter(Boolean) +} + +function isWildcardModelId(agent: AgentFlavor, modelId: string): boolean { + const trimmed = modelId.trim().toLowerCase() + const base = agent === 'cursor' ? cursorModelBaseId(trimmed) : trimmed + return WILDCARD_MODEL_IDS.has(trimmed) || WILDCARD_MODEL_IDS.has(base) +} + +export function buildSpawnModelCatalogIndex(agent: AgentFlavor, catalog: readonly string[]): Set { + const index = new Set() + for (const entry of catalog) { + for (const candidate of catalogCandidates(agent, entry)) index.add(candidate) + } + return index +} + +/** + * Near-misses first: a rejected `gpt-5` is most usefully answered with the + * `gpt-5.x` ids, which plain alphabetical order would truncate away. + * + * Cursor entries collapse to their base slug — a catalog of ACP wire ids would + * otherwise spend the whole list on `gpt-5-mini[fast=false]`-style variants of + * two or three models, and the base slug is itself accepted. + */ +function formatAcceptedModels(agent: AgentFlavor, requested: string, catalog: readonly string[]): string { + const prefix = requested.toLowerCase() + const display = (id: string): string => (agent === 'cursor' ? cursorCliSkuBaseId(id) : id) + const ids = [...new Set(catalog.map((id) => display(id.trim())).filter(Boolean))].sort() + const ranked = [ + ...ids.filter((id) => id.toLowerCase().startsWith(prefix)), + ...ids.filter((id) => !id.toLowerCase().startsWith(prefix)) + ] + const listed = ranked.slice(0, MAX_LISTED_MODELS).join(', ') + return ranked.length > MAX_LISTED_MODELS ? `${listed}, … (${ranked.length} total)` : listed +} + +/** + * Rejects a spawn model the machine's catalog for this flavor definitely does + * not contain, so the runner can fail before booting a child that would die at + * agent handshake and leave an archived session with no turns. + * + * The contract is deliberately **base-level**: a model is rejected only when its + * base appears nowhere in the catalog. Variant-level availability — an effort + * sku like `gpt-5.5-high-fast`, or a wire with contradictory params like + * `claude-opus-4-8[thinking=false,effort=high]` — stays the handshake's job, + * because a cached catalog is not authoritative about variants: + * + * - shared cliModelSkus rows are explicitly partial, unioned with a later + * probe (cursorModels: "unions shared partial cliModelSkus with probe + * results"), and that probe is skipped entirely while an ACP session holds + * the CLI lock, so a missing sku proves nothing; + * - a wire-only cache carries no sku naming at all, and a sku-only cache + * carries no param sets. + * + * Treating absence at variant level as proof would reject valid spawns on every + * machine whose cache is mid-union or wire-only — a worse failure than the + * handshake error it would pre-empt. + * + * An empty catalog likewise means "this machine could not enumerate models", not + * "no models exist" — never reject there, or every flavor without a probe would + * stop accepting valid ids. + */ +export function validateSpawnModelAgainstCatalog( + agent: AgentFlavor, + model: string | null | undefined, + catalog: readonly string[] +): SpawnModelValidation { + const requested = model?.trim() ?? '' + if (!requested || isWildcardModelId(agent, requested)) return { ok: true } + if (catalog.length === 0) return { ok: true } + + const index = buildSpawnModelCatalogIndex(agent, catalog) + if (catalogCandidates(agent, requested).some((candidate) => index.has(candidate))) return { ok: true } + + return { + ok: false, + message: `Model '${requested}' is not in the ${agent} model catalog on this machine. Accepted: ${formatAcceptedModels(agent, requested, catalog)}` + } +}