From 185935379aebec435a1900a9bfed3b7bbf487a68 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 8 Jun 2026 17:26:40 -0400 Subject: [PATCH] refactor(#888): extract model+effort resolution into model-resolver.cts (#890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-857 rollout phase 2f — the FINAL core.cts decomposition. Move the model and effort resolution cluster (resolveModelInternal, resolveModelPolicy, resolveTierEntry, _resolveRuntimeTier, resolveModelForTier, resolveGranularityInternal, assertValidGranularityOverride, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, nextEffort + VALID_GRANULARITIES/ VALID_EFFORTS/EFFORT_SET + interfaces) out of core.cts into a new leaf module src/model-resolver.cts. core.cts re-exports the 13 public symbols (callers in init/docs/commands unchanged; export= set byte-identical). Cycle-free: model-resolver imports only leaves (config-loader for loadConfig, configuration for defaults, model-profiles + model-catalog for the static tables). Removed 6 now-unused imports from core (verified zero remaining references, none re-exported). This completes the god-module decomposition: core.cts 2271 -> 389 lines (~83%), now a thin re-export spine over seven clean leaves (io, phase-id, roadmap-parser, core-utils, phase-locator, config-loader, model-resolver). New-CLI-module checklist done (.gitignore, eslint, INVENTORY 96->97 + row, manifest, ARCHITECTURE, CONTEXT.md "Model Resolver Module"). Adds tests/model-resolver.test.cjs (81 tests: behavioral + shim-identity + adversarial). Gates: lint, code-review (export set byte-identical; import-removal verified), security-review, codex adversarial-review (all 0 findings; verbatim move). Mac 4303 pass; clean-build docker 13117 pass, 0 fail. Closes #888 Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + CONTEXT.md | 3 + docs/ARCHITECTURE.md | 1 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + src/core.cts | 457 ++--------------------- src/model-resolver.cts | 476 +++++++++++++++++++++++ tests/model-resolver.test.cjs | 683 ++++++++++++++++++++++++++++++++++ 9 files changed, 1195 insertions(+), 431 deletions(-) create mode 100644 src/model-resolver.cts create mode 100644 tests/model-resolver.test.cjs diff --git a/.gitignore b/.gitignore index de83f3122..66723ad00 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ build/ /gsd-core/bin/lib/io.cjs /gsd-core/bin/lib/phase-id.cjs /gsd-core/bin/lib/config-loader.cjs +/gsd-core/bin/lib/model-resolver.cjs /gsd-core/bin/lib/phase-locator.cjs /gsd-core/bin/lib/roadmap-parser.cjs /gsd-core/bin/lib/drift.cjs diff --git a/CONTEXT.md b/CONTEXT.md index faada64d6..031d5fe48 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -124,6 +124,9 @@ Module owning the shared low-level utility primitives extracted from Core: POSIX ### Config Loader Module Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides (`loadConfig` plus its `_deepMergeConfig`/`isGitIgnored`/`_warnUnknownProfileOverrides` helpers). Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); `core.cjs` re-exports `loadConfig` for back-compat. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`). +### Model Resolver Module +Module owning model and effort resolution policy: resolves the model, runtime tier, planning granularity, reasoning effort, and fast-mode for a given agent by reading project config and resolving against the model profiles and catalog (`resolveModelInternal`, `resolveModelPolicy`, `resolveTierEntry`, `resolveModelForTier`, `resolveGranularityInternal`, `resolveEffortInternal`, `resolveFastModeInternal`, `resolveEffortForTier`, `nextEffort`, `assertValidGranularityOverride`). Depends only on leaf modules (`config-loader` for `loadConfig`, `configuration` for defaults, `model-profiles` and `model-catalog` for the static tables) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2f (#888) — the final core.cts decomposition step, leaving Core a thin re-export spine; `core.cjs` re-exports the resolvers for back-compat. Source of truth: `gsd-core/bin/lib/model-resolver.cjs` (generated from `src/model-resolver.cts`). + ### Package Identity Module [Planned] Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dae299b7f..ceadf1f58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -361,6 +361,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `milestone.cjs` | Milestone archival, requirements marking | | `commands.cjs` | Misc commands (slug, timestamp, todos, scaffolding, stats) | | `model-profiles.cjs` | Model profile resolution table | +| `model-resolver.cjs` | Model and effort resolution policy — resolves model, tier, granularity, effort, and fast-mode for a given agent from project config and model profiles/catalog (extracted from `core.cjs`, ADR-857) | | `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON parsing, shell argument validation | | `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support | | `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index b823920a5..f695d5e33 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -309,6 +309,7 @@ "milestone.cjs", "model-catalog.cjs", "model-profiles.cjs", + "model-resolver.cjs", "package-identity.cjs", "package-legitimacy.cjs", "phase-command-router.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 2e42ab2b0..dfdb2cd83 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (96 shipped) +## CLI Modules (97 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -420,6 +420,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `milestone.cjs` | Milestone archival, requirements marking | | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | +| `model-resolver.cjs` | Model/effort resolution policy — resolves model, tier, granularity, effort, and fast-mode for an agent from config + model profiles/catalog (extracted from `core.cjs`, ADR-857) | | `package-identity.cjs` | Generated single source for GSD's published-package coordinates (npm name, bin name, repo slug, changelog URL, manual-install command), derived from package.json; read by the update worker, `check-latest-version`, and installer (#498) | | `package-legitimacy.cjs` | Registry-API package legitimacy verdicts (OK/SUS/SLOP) from npm/PyPI/crates, slopcheck optional | | `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` | diff --git a/eslint.config.mjs b/eslint.config.mjs index b4842242f..f6c404288 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -73,6 +73,7 @@ export default tseslint.config( 'gsd-core/bin/lib/command-aliases.cjs', 'gsd-core/bin/lib/config-schema.cjs', 'gsd-core/bin/lib/model-profiles.cjs', + 'gsd-core/bin/lib/model-resolver.cjs', 'gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs', 'gsd-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs', 'gsd-core/bin/lib/observability/logger.cjs', diff --git a/src/core.cts b/src/core.cts index afc725faf..e07e6baaf 100644 --- a/src/core.cts +++ b/src/core.cts @@ -20,8 +20,8 @@ import roadmapParserModule = require('./roadmap-parser.cjs'); const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import modelProfiles = require('./model-profiles.cjs'); -const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles; -import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, PROVIDER_PRESETS, KNOWN_PROVIDERS } from './model-catalog.cjs'; +const { MODEL_PROFILES, VALID_PHASE_TYPES: _VALID_PHASE_TYPES } = modelProfiles; +import { RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, KNOWN_PROVIDERS, MODEL_ALIAS_MAP } from './model-catalog.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import worktreeSafety = require('./worktree-safety.cjs'); const { @@ -62,9 +62,6 @@ const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocat import { findProjectRoot } from './project-root.cjs'; import { getGlobalConfigDir } from './runtime-homes.cjs'; -// ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─ -import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS } from './configuration.cjs'; - // ─── Config Loader Module (extracted from core, ADR-857 phase 2e / #885) ───── // eslint-disable-next-line @typescript-eslint/no-require-imports import configLoaderModule = require('./config-loader.cjs'); @@ -77,6 +74,25 @@ const { RUNTIME_OVERRIDE_TIERS, } = configLoaderModule; +// ─── Model Resolver Module (extracted from core, ADR-857 phase 2f / #888) ──── +// eslint-disable-next-line @typescript-eslint/no-require-imports +import modelResolverModule = require('./model-resolver.cjs'); +const { + resolveTierEntry, + resolveModelPolicy, + resolveModelInternal, + VALID_GRANULARITIES, + resolveGranularityInternal, + assertValidGranularityOverride, + resolveModelForTier, + VALID_EFFORTS, + EFFORT_SET, + nextEffort, + resolveEffortInternal, + resolveFastModeInternal, + resolveEffortForTier, +} = modelResolverModule; + // ─── Path helpers ──────────────────────────────────────────────────────────── // toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877). // The destructured bindings above (from coreUtilsModule) make them available to @@ -247,431 +263,12 @@ function checkAgentsInstalled(runtime?: string): AgentsInstalledResult { // The destructured bindings above (from configLoaderModule) make them available to // core-internal callers; _resetRuntimeWarningCacheForTests is re-exported for back-compat. -interface TierEntryResolved { - model: string; - reasoning_effort?: string; - [key: string]: unknown; -} - -interface ResolveTierEntryOpts { - runtime: string | null | undefined; - tier: string | null | undefined; - overrides: Record | null | undefined; -} - -/** - * #2517 — Resolve the runtime-aware tier entry for (runtime, tier). - */ -function resolveTierEntry({ runtime, tier, overrides }: ResolveTierEntryOpts): TierEntryResolved | null { - if (!runtime || !tier) return null; - - const runtimeMap = RUNTIME_PROFILE_MAP as unknown as Record>>; - const builtin = runtimeMap[runtime]?.[tier] || null; - const overridesMap = overrides as Record> | null | undefined; - const userRaw = overridesMap?.[runtime]?.[tier]; - - let userEntry: Record | null = null; - if (userRaw) { - userEntry = typeof userRaw === 'string' ? { model: userRaw } : (userRaw as Record); - } - - if (!builtin && !userEntry) return null; - return { ...(builtin || {}), ...(userEntry || {}) } as TierEntryResolved; -} - -/** - * Convenience wrapper used by resolveModelInternal. - */ -function _resolveRuntimeTier(config: Record, tier: string): TierEntryResolved | null { - return resolveTierEntry({ - runtime: config['runtime'] as string | null | undefined, - tier, - overrides: config['model_profile_overrides'] as Record | null | undefined, - }); -} - -/** - * #49 — Provider-neutral model policy preset resolution. - */ -function resolveModelPolicy(policy: Record | null | undefined, tier: string | null | undefined): string | null { - if (!policy || typeof policy !== 'object') return null; - if (!tier) return null; - - const runtime = policy['runtime']; - const rtOverrides = policy['runtime_tiers']; - if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') { - const rtOverridesMap = rtOverrides as Record; - if (Object.hasOwn(rtOverridesMap, runtime)) { - const runtimeEntry = rtOverridesMap[runtime]; - if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) { - const raw = (runtimeEntry as Record)[tier]; - if (raw != null) { - const entry = typeof raw === 'string' ? { model: raw } : (raw as Record); - if (entry && entry['model']) return entry['model'] as string; - } - } - } - } - - const provider = policy['provider']; - if (!provider || typeof provider !== 'string') return null; - - if (provider === 'generic' || provider === 'custom') { - const TIER_TO_POLICY_KEY: Record = { opus: 'high', sonnet: 'medium', haiku: 'low' }; - const policyKey = TIER_TO_POLICY_KEY[tier]; - if (!policyKey) return null; - const v = policy[policyKey]; - return (v && typeof v === 'string') ? v : null; - } - - const presetsMap = PROVIDER_PRESETS as Record>>; - if (!Object.hasOwn(presetsMap, provider)) return null; - const presetForProvider = presetsMap[provider]; - if (!presetForProvider || typeof presetForProvider !== 'object') return null; - - if (!Object.hasOwn(presetForProvider, tier)) return null; - const tierPresets = presetForProvider[tier]; - if (!tierPresets || typeof tierPresets !== 'object') return null; - - const budget = (policy['budget'] && typeof policy['budget'] === 'string') ? policy['budget'] : 'medium'; - if (!Object.hasOwn(tierPresets, budget)) return null; - const budgetEntry = tierPresets[budget]; - if (!budgetEntry || !budgetEntry.model) return null; - - return budgetEntry.model; -} - -function resolveModelInternal(cwd: string, agentType: string): string { - const config = loadConfig(cwd); - - // 1. Per-agent override - const modelOverrides = config['model_overrides'] as Record | null | undefined; - const override = modelOverrides?.[agentType]; - if (override) { - return override; - } - - // 2. Compute the tier - // eslint-disable-next-line @typescript-eslint/no-base-to-string - const profile = String(config['model_profile'] || 'balanced').toLowerCase(); - const agentModels = (MODEL_PROFILES as unknown as Record>)[agentType]; - const phaseType = (AGENT_TO_PHASE_TYPE)[agentType]; - const configModels = config['models'] as Record | null | undefined; - const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object') - ? configModels[phaseType] - : undefined; - const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']); - const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier)) - ? phaseTypeTier - : (profile === 'inherit' - ? 'inherit' - : (agentModels ? (agentModels[profile] || agentModels['balanced']) : null)); - - // 2.5. model_policy preset (#49) - const configRuntime = config['runtime'] as string | null | undefined; - if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') { - const mergedPolicy = config['model_policy'] - ? { ...(config['model_policy'] as Record), runtime: configRuntime } - : null; - const policyModel = resolveModelPolicy(mergedPolicy, tier); - if (policyModel) return policyModel; - } - - // 3. Runtime-aware resolution (#2517) - if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') { - const entry = _resolveRuntimeTier(config, tier); - if (entry?.model) return entry.model; - } - - // 4. resolve_model_ids: "omit" - if (config['resolve_model_ids'] === 'omit') { - return ''; - } - - // 5. Profile lookup (Claude-native default). - if (!agentModels) { - return profile === 'quality' ? 'opus' - : profile === 'budget' ? 'haiku' - : profile === 'inherit' ? 'inherit' - : 'sonnet'; - } - if (tier === 'inherit') return 'inherit'; - const alias = tier; - - if (config['resolve_model_ids']) { - return (MODEL_ALIAS_MAP as Record)[alias!] || alias!; - } - - return alias!; -} - -const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']); - -/** - * Resolve the planning granularity for a phase type (#68). - */ -function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string { - if (override !== undefined && override !== null && override !== '') { - if (VALID_GRANULARITIES.has(override)) { - return override; - } - } - const config = loadConfig(cwd); - const configGranularities = config['granularities'] as Record | null | undefined; - const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object') - ? configGranularities[phaseType] - : undefined; - if (perPhase && VALID_GRANULARITIES.has(perPhase)) { - return perPhase; - } - if (config['granularity'] !== undefined && config['granularity'] !== null && config['granularity'] !== '') { - return config['granularity'] as string; - } - const planning = config['planning'] as Record | null | undefined; - const planningGran = planning && planning['granularity']; - if (planningGran !== undefined && planningGran !== null && planningGran !== '') { - return planningGran as string; - } - return 'standard'; -} - -/** - * Validate a CLI granularity override at the command boundary. Empty/null/undefined - * are treated as "no override" (no-op). An invalid non-empty value calls `fail`. - */ -function assertValidGranularityOverride( - override: string | null | undefined, - fail: (msg: string) => never, -): void { - if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) { - fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`); - } -} - -/** - * #3024 — Resolve a model for a specific dynamic-routing attempt. - */ -function resolveModelForTier(cwd: string, agentType: string, attempt?: number): string { - const config = loadConfig(cwd); - const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0; - - const modelOverrides = config['model_overrides'] as Record | null | undefined; - const override = modelOverrides?.[agentType]; - if (override) return override; - - if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') { - return resolveModelInternal(cwd, agentType); - } - - const dr = config['dynamic_routing'] as Record | null | undefined; - if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) { - return resolveModelInternal(cwd, agentType); - } - - const tierModels = dr['tier_models'] as Record | null | undefined; - if (!tierModels || typeof tierModels !== 'object') { - return resolveModelInternal(cwd, agentType); - } - - const defaultTier = (AGENT_DEFAULT_TIERS)[agentType]; - if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) { - return resolveModelInternal(cwd, agentType); - } - - const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0 - ? (dr['max_escalations'] as number) - : 1; - const escalationEnabled = dr['escalate_on_failure'] !== false; - const effectiveAttempt = escalationEnabled - ? Math.min(attemptN, maxEscalations) - : 0; - - let tier = defaultTier; - for (let i = 0; i < effectiveAttempt; i += 1) { - const next = (nextTier)(tier); - if (!next || next === tier) break; - tier = next; - } - - const alias = tierModels[tier]; - if (typeof alias !== 'string' || alias.length === 0) { - return resolveModelInternal(cwd, agentType); - } - return alias; -} - -// ─── #443 — Unified effort + fast_mode resolvers ───────────────────────────── - -const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']; -const EFFORT_SET = new Set(VALID_EFFORTS); - -/** - * Walk one step up the effort ladder from `e`. - */ -function nextEffort(e: string): string | null { - const i = VALID_EFFORTS.indexOf(e); - if (i < 0) return null; - return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)]; -} - -interface EffortOpts { - override?: string; -} - -interface FastModeOpts { - override?: boolean; -} - -/** - * #443 — Resolve a universal effort string for (cwd, agentType). - */ -function resolveEffortInternal(cwd: string, agentType: string, opts?: EffortOpts): string { - // Step 1: invocation override - if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) { - return opts.override; - } - - const config = loadConfig(cwd); - const effortCfg = (config['effort'] && typeof config['effort'] === 'object' && !Array.isArray(config['effort'])) - ? (config['effort'] as Record) - : null; - - // Step 2: agent_overrides - if (effortCfg) { - const ao = effortCfg['agent_overrides']; - if (ao && typeof ao === 'object' && !Array.isArray(ao)) { - const v = (ao as Record)[agentType]; - if (typeof v === 'string' && EFFORT_SET.has(v)) return v; - } - } else { - const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort']; - const mao = canonicalEffort && typeof canonicalEffort === 'object' - ? (canonicalEffort as Record)['agent_overrides'] - : undefined; - if (mao && typeof mao === 'object' && !Array.isArray(mao)) { - const v = (mao as Record)[agentType]; - if (typeof v === 'string' && EFFORT_SET.has(v)) return v; - } - } - - // Step 3: routing_tier_defaults by agent's default tier. - const agentTier = (AGENT_DEFAULT_TIERS)[agentType]; - if (agentTier) { - if (effortCfg && effortCfg['routing_tier_defaults'] && - typeof effortCfg['routing_tier_defaults'] === 'object' && - !Array.isArray(effortCfg['routing_tier_defaults'])) { - const v = (effortCfg['routing_tier_defaults'] as Record)[agentTier]; - if (typeof v === 'string' && EFFORT_SET.has(v)) return v; - } else if (!effortCfg) { - const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort']; - const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object' - ? (canonicalEffort as Record)['routing_tier_defaults'] - : undefined; - if (manifestDefaults && typeof manifestDefaults === 'object') { - const v = (manifestDefaults as Record)[agentTier]; - if (typeof v === 'string' && EFFORT_SET.has(v)) return v; - } - } - } - - // Step 4: effort.default - if (effortCfg) { - const d = effortCfg['default']; - if (typeof d === 'string' && EFFORT_SET.has(d)) return d; - } else { - const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort']; - const d = canonicalEffort && typeof canonicalEffort === 'object' - ? (canonicalEffort as Record)['default'] - : undefined; - if (typeof d === 'string' && EFFORT_SET.has(d)) return d; - } - - // Step 5: hardcoded default - return 'high'; -} - -/** - * #443 — Resolve fast_mode boolean for (cwd, agentType). - */ -function resolveFastModeInternal(cwd: string, agentType: string, opts?: FastModeOpts): boolean { - // Step 1: invocation override - if (opts && typeof opts.override === 'boolean') { - return opts.override; - } - - const config = loadConfig(cwd); - const fmCfg = (config['fast_mode'] && typeof config['fast_mode'] === 'object' && !Array.isArray(config['fast_mode'])) - ? (config['fast_mode'] as Record) - : null; - - // Step 2: agent_overrides - if (fmCfg) { - const ao = fmCfg['agent_overrides']; - if (ao && typeof ao === 'object' && !Array.isArray(ao)) { - const v = (ao as Record)[agentType]; - if (typeof v === 'boolean') return v; - } - } - - // Step 3: routing_tier_defaults by agent's default tier. - const agentTier = (AGENT_DEFAULT_TIERS)[agentType]; - if (agentTier) { - if (fmCfg && fmCfg['routing_tier_defaults'] && - typeof fmCfg['routing_tier_defaults'] === 'object' && - !Array.isArray(fmCfg['routing_tier_defaults'])) { - const v = (fmCfg['routing_tier_defaults'] as Record)[agentTier]; - if (typeof v === 'boolean') return v; - } else if (!fmCfg) { - const canonicalFm = (CANONICAL_CONFIG_DEFAULTS)['fast_mode']; - const manifestDefaults = canonicalFm && typeof canonicalFm === 'object' - ? (canonicalFm as Record)['routing_tier_defaults'] - : undefined; - if (manifestDefaults && typeof manifestDefaults === 'object') { - const v = (manifestDefaults as Record)[agentTier]; - if (typeof v === 'boolean') return v; - } - } - } - - // Step 4: fast_mode.enabled - if (fmCfg && typeof fmCfg['enabled'] === 'boolean') { - return fmCfg['enabled']; - } - - // Step 5: hardcoded default - return false; -} - -/** - * #443 — Resolve effort for a dynamic-routing attempt (with escalation). - */ -function resolveEffortForTier(cwd: string, agentType: string, attempt?: number): string { - const base = resolveEffortInternal(cwd, agentType); - - const config = loadConfig(cwd); - const dr = config['dynamic_routing'] as Record | null | undefined; - if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) { - return base; - } - if (dr['escalate_on_failure'] === false) { - return base; - } - - const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0 - ? (dr['max_escalations'] as number) - : 1; - - const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0; - const effectiveAttempt = Math.min(attemptN, maxEscalations); - - let current = base; - for (let i = 0; i < effectiveAttempt; i++) { - const next = nextEffort(current); - if (!next || next === current) break; - current = next; - } - return current; -} +// resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES, +// resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier, +// VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal, +// resolveEffortForTier — all moved to model-resolver.cjs (ADR-857 phase 2f / #888). +// The destructured bindings above (from modelResolverModule) make them available to +// core-internal callers; core.cjs re-exports all 13 symbols for back-compat. // ─── Summary body helpers / Misc utilities / Phase file helpers ─────────────── // extractOneLinerFromBody, pathExistsInternal, generateSlugInternal, diff --git a/src/model-resolver.cts b/src/model-resolver.cts new file mode 100644 index 000000000..be975da36 --- /dev/null +++ b/src/model-resolver.cts @@ -0,0 +1,476 @@ +/** + * Model Resolver — Model and effort resolution policy + * + * ADR-857 rollout phase 2f: extracted from core.cts (issue #888). + * Owns model and effort resolution policy: resolves the model, runtime tier, + * planning granularity, reasoning effort, and fast-mode for a given agent by + * reading project config and resolving against the model profiles and catalog. + * Behaviour is preserved byte-for-behaviour from the prior location; only + * the module boundary moved. core.cjs re-exports the resolvers for back-compat. + * + * New imports should pull resolvers from model-resolver.cjs directly. + * + * Dependencies (leaf modules only — no core.cjs): + * - node:fs / node:path (stdlib, not currently needed — included for future use) + * - ./config-loader.cjs (loadConfig) + * - ./configuration.cjs (CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS) + * - ./model-profiles.cjs (MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier) + * - ./model-catalog.cjs (MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS) + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import configLoaderModule = require('./config-loader.cjs'); +const { loadConfig } = configLoaderModule; + +// ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─ +import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS } from './configuration.cjs'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import modelProfiles = require('./model-profiles.cjs'); +const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles; + +import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS } from './model-catalog.cjs'; + +// ─── Model alias resolution ─────────────────────────────────────────────────── + +interface TierEntryResolved { + model: string; + reasoning_effort?: string; + [key: string]: unknown; +} + +interface ResolveTierEntryOpts { + runtime: string | null | undefined; + tier: string | null | undefined; + overrides: Record | null | undefined; +} + +/** + * #2517 — Resolve the runtime-aware tier entry for (runtime, tier). + */ +function resolveTierEntry({ runtime, tier, overrides }: ResolveTierEntryOpts): TierEntryResolved | null { + if (!runtime || !tier) return null; + + const runtimeMap = RUNTIME_PROFILE_MAP as unknown as Record>>; + const builtin = runtimeMap[runtime]?.[tier] || null; + const overridesMap = overrides as Record> | null | undefined; + const userRaw = overridesMap?.[runtime]?.[tier]; + + let userEntry: Record | null = null; + if (userRaw) { + userEntry = typeof userRaw === 'string' ? { model: userRaw } : (userRaw as Record); + } + + if (!builtin && !userEntry) return null; + return { ...(builtin || {}), ...(userEntry || {}) } as TierEntryResolved; +} + +/** + * Convenience wrapper used by resolveModelInternal. + */ +function _resolveRuntimeTier(config: Record, tier: string): TierEntryResolved | null { + return resolveTierEntry({ + runtime: config['runtime'] as string | null | undefined, + tier, + overrides: config['model_profile_overrides'] as Record | null | undefined, + }); +} + +/** + * #49 — Provider-neutral model policy preset resolution. + */ +function resolveModelPolicy(policy: Record | null | undefined, tier: string | null | undefined): string | null { + if (!policy || typeof policy !== 'object') return null; + if (!tier) return null; + + const runtime = policy['runtime']; + const rtOverrides = policy['runtime_tiers']; + if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') { + const rtOverridesMap = rtOverrides as Record; + if (Object.hasOwn(rtOverridesMap, runtime)) { + const runtimeEntry = rtOverridesMap[runtime]; + if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) { + const raw = (runtimeEntry as Record)[tier]; + if (raw != null) { + const entry = typeof raw === 'string' ? { model: raw } : (raw as Record); + if (entry && entry['model']) return entry['model'] as string; + } + } + } + } + + const provider = policy['provider']; + if (!provider || typeof provider !== 'string') return null; + + if (provider === 'generic' || provider === 'custom') { + const TIER_TO_POLICY_KEY: Record = { opus: 'high', sonnet: 'medium', haiku: 'low' }; + const policyKey = TIER_TO_POLICY_KEY[tier]; + if (!policyKey) return null; + const v = policy[policyKey]; + return (v && typeof v === 'string') ? v : null; + } + + const presetsMap = PROVIDER_PRESETS as Record>>; + if (!Object.hasOwn(presetsMap, provider)) return null; + const presetForProvider = presetsMap[provider]; + if (!presetForProvider || typeof presetForProvider !== 'object') return null; + + if (!Object.hasOwn(presetForProvider, tier)) return null; + const tierPresets = presetForProvider[tier]; + if (!tierPresets || typeof tierPresets !== 'object') return null; + + const budget = (policy['budget'] && typeof policy['budget'] === 'string') ? policy['budget'] : 'medium'; + if (!Object.hasOwn(tierPresets, budget)) return null; + const budgetEntry = tierPresets[budget]; + if (!budgetEntry || !budgetEntry.model) return null; + + return budgetEntry.model; +} + +function resolveModelInternal(cwd: string, agentType: string): string { + const config = loadConfig(cwd); + + // 1. Per-agent override + const modelOverrides = config['model_overrides'] as Record | null | undefined; + const override = modelOverrides?.[agentType]; + if (override) { + return override; + } + + // 2. Compute the tier + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const profile = String(config['model_profile'] || 'balanced').toLowerCase(); + const agentModels = (MODEL_PROFILES as unknown as Record>)[agentType]; + const phaseType = (AGENT_TO_PHASE_TYPE)[agentType]; + const configModels = config['models'] as Record | null | undefined; + const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object') + ? configModels[phaseType] + : undefined; + const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']); + const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier)) + ? phaseTypeTier + : (profile === 'inherit' + ? 'inherit' + : (agentModels ? (agentModels[profile] || agentModels['balanced']) : null)); + + // 2.5. model_policy preset (#49) + const configRuntime = config['runtime'] as string | null | undefined; + if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') { + const mergedPolicy = config['model_policy'] + ? { ...(config['model_policy'] as Record), runtime: configRuntime } + : null; + const policyModel = resolveModelPolicy(mergedPolicy, tier); + if (policyModel) return policyModel; + } + + // 3. Runtime-aware resolution (#2517) + if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') { + const entry = _resolveRuntimeTier(config, tier); + if (entry?.model) return entry.model; + } + + // 4. resolve_model_ids: "omit" + if (config['resolve_model_ids'] === 'omit') { + return ''; + } + + // 5. Profile lookup (Claude-native default). + if (!agentModels) { + return profile === 'quality' ? 'opus' + : profile === 'budget' ? 'haiku' + : profile === 'inherit' ? 'inherit' + : 'sonnet'; + } + if (tier === 'inherit') return 'inherit'; + const alias = tier; + + if (config['resolve_model_ids']) { + return (MODEL_ALIAS_MAP as Record)[alias!] || alias!; + } + + return alias!; +} + +const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']); + +/** + * Resolve the planning granularity for a phase type (#68). + */ +function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string { + if (override !== undefined && override !== null && override !== '') { + if (VALID_GRANULARITIES.has(override)) { + return override; + } + } + const config = loadConfig(cwd); + const configGranularities = config['granularities'] as Record | null | undefined; + const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object') + ? configGranularities[phaseType] + : undefined; + if (perPhase && VALID_GRANULARITIES.has(perPhase)) { + return perPhase; + } + if (config['granularity'] !== undefined && config['granularity'] !== null && config['granularity'] !== '') { + return config['granularity'] as string; + } + const planning = config['planning'] as Record | null | undefined; + const planningGran = planning && planning['granularity']; + if (planningGran !== undefined && planningGran !== null && planningGran !== '') { + return planningGran as string; + } + return 'standard'; +} + +/** + * Validate a CLI granularity override at the command boundary. Empty/null/undefined + * are treated as "no override" (no-op). An invalid non-empty value calls `fail`. + */ +function assertValidGranularityOverride( + override: string | null | undefined, + fail: (msg: string) => never, +): void { + if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) { + fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`); + } +} + +/** + * #3024 — Resolve a model for a specific dynamic-routing attempt. + */ +function resolveModelForTier(cwd: string, agentType: string, attempt?: number): string { + const config = loadConfig(cwd); + const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0; + + const modelOverrides = config['model_overrides'] as Record | null | undefined; + const override = modelOverrides?.[agentType]; + if (override) return override; + + if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') { + return resolveModelInternal(cwd, agentType); + } + + const dr = config['dynamic_routing'] as Record | null | undefined; + if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) { + return resolveModelInternal(cwd, agentType); + } + + const tierModels = dr['tier_models'] as Record | null | undefined; + if (!tierModels || typeof tierModels !== 'object') { + return resolveModelInternal(cwd, agentType); + } + + const defaultTier = (AGENT_DEFAULT_TIERS)[agentType]; + if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) { + return resolveModelInternal(cwd, agentType); + } + + const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0 + ? (dr['max_escalations'] as number) + : 1; + const escalationEnabled = dr['escalate_on_failure'] !== false; + const effectiveAttempt = escalationEnabled + ? Math.min(attemptN, maxEscalations) + : 0; + + let tier = defaultTier; + for (let i = 0; i < effectiveAttempt; i += 1) { + const next = (nextTier)(tier); + if (!next || next === tier) break; + tier = next; + } + + const alias = tierModels[tier]; + if (typeof alias !== 'string' || alias.length === 0) { + return resolveModelInternal(cwd, agentType); + } + return alias; +} + +// ─── #443 — Unified effort + fast_mode resolvers ───────────────────────────── + +const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']; +const EFFORT_SET = new Set(VALID_EFFORTS); + +/** + * Walk one step up the effort ladder from `e`. + */ +function nextEffort(e: string): string | null { + const i = VALID_EFFORTS.indexOf(e); + if (i < 0) return null; + return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)]; +} + +interface EffortOpts { + override?: string; +} + +interface FastModeOpts { + override?: boolean; +} + +/** + * #443 — Resolve a universal effort string for (cwd, agentType). + */ +function resolveEffortInternal(cwd: string, agentType: string, opts?: EffortOpts): string { + // Step 1: invocation override + if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) { + return opts.override; + } + + const config = loadConfig(cwd); + const effortCfg = (config['effort'] && typeof config['effort'] === 'object' && !Array.isArray(config['effort'])) + ? (config['effort'] as Record) + : null; + + // Step 2: agent_overrides + if (effortCfg) { + const ao = effortCfg['agent_overrides']; + if (ao && typeof ao === 'object' && !Array.isArray(ao)) { + const v = (ao as Record)[agentType]; + if (typeof v === 'string' && EFFORT_SET.has(v)) return v; + } + } else { + const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort']; + const mao = canonicalEffort && typeof canonicalEffort === 'object' + ? (canonicalEffort as Record)['agent_overrides'] + : undefined; + if (mao && typeof mao === 'object' && !Array.isArray(mao)) { + const v = (mao as Record)[agentType]; + if (typeof v === 'string' && EFFORT_SET.has(v)) return v; + } + } + + // Step 3: routing_tier_defaults by agent's default tier. + const agentTier = (AGENT_DEFAULT_TIERS)[agentType]; + if (agentTier) { + if (effortCfg && effortCfg['routing_tier_defaults'] && + typeof effortCfg['routing_tier_defaults'] === 'object' && + !Array.isArray(effortCfg['routing_tier_defaults'])) { + const v = (effortCfg['routing_tier_defaults'] as Record)[agentTier]; + if (typeof v === 'string' && EFFORT_SET.has(v)) return v; + } else if (!effortCfg) { + const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort']; + const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object' + ? (canonicalEffort as Record)['routing_tier_defaults'] + : undefined; + if (manifestDefaults && typeof manifestDefaults === 'object') { + const v = (manifestDefaults as Record)[agentTier]; + if (typeof v === 'string' && EFFORT_SET.has(v)) return v; + } + } + } + + // Step 4: effort.default + if (effortCfg) { + const d = effortCfg['default']; + if (typeof d === 'string' && EFFORT_SET.has(d)) return d; + } else { + const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort']; + const d = canonicalEffort && typeof canonicalEffort === 'object' + ? (canonicalEffort as Record)['default'] + : undefined; + if (typeof d === 'string' && EFFORT_SET.has(d)) return d; + } + + // Step 5: hardcoded default + return 'high'; +} + +/** + * #443 — Resolve fast_mode boolean for (cwd, agentType). + */ +function resolveFastModeInternal(cwd: string, agentType: string, opts?: FastModeOpts): boolean { + // Step 1: invocation override + if (opts && typeof opts.override === 'boolean') { + return opts.override; + } + + const config = loadConfig(cwd); + const fmCfg = (config['fast_mode'] && typeof config['fast_mode'] === 'object' && !Array.isArray(config['fast_mode'])) + ? (config['fast_mode'] as Record) + : null; + + // Step 2: agent_overrides + if (fmCfg) { + const ao = fmCfg['agent_overrides']; + if (ao && typeof ao === 'object' && !Array.isArray(ao)) { + const v = (ao as Record)[agentType]; + if (typeof v === 'boolean') return v; + } + } + + // Step 3: routing_tier_defaults by agent's default tier. + const agentTier = (AGENT_DEFAULT_TIERS)[agentType]; + if (agentTier) { + if (fmCfg && fmCfg['routing_tier_defaults'] && + typeof fmCfg['routing_tier_defaults'] === 'object' && + !Array.isArray(fmCfg['routing_tier_defaults'])) { + const v = (fmCfg['routing_tier_defaults'] as Record)[agentTier]; + if (typeof v === 'boolean') return v; + } else if (!fmCfg) { + const canonicalFm = (CANONICAL_CONFIG_DEFAULTS)['fast_mode']; + const manifestDefaults = canonicalFm && typeof canonicalFm === 'object' + ? (canonicalFm as Record)['routing_tier_defaults'] + : undefined; + if (manifestDefaults && typeof manifestDefaults === 'object') { + const v = (manifestDefaults as Record)[agentTier]; + if (typeof v === 'boolean') return v; + } + } + } + + // Step 4: fast_mode.enabled + if (fmCfg && typeof fmCfg['enabled'] === 'boolean') { + return fmCfg['enabled']; + } + + // Step 5: hardcoded default + return false; +} + +/** + * #443 — Resolve effort for a dynamic-routing attempt (with escalation). + */ +function resolveEffortForTier(cwd: string, agentType: string, attempt?: number): string { + const base = resolveEffortInternal(cwd, agentType); + + const config = loadConfig(cwd); + const dr = config['dynamic_routing'] as Record | null | undefined; + if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) { + return base; + } + if (dr['escalate_on_failure'] === false) { + return base; + } + + const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0 + ? (dr['max_escalations'] as number) + : 1; + + const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0; + const effectiveAttempt = Math.min(attemptN, maxEscalations); + + let current = base; + for (let i = 0; i < effectiveAttempt; i++) { + const next = nextEffort(current); + if (!next || next === current) break; + current = next; + } + return current; +} + +export = { + resolveTierEntry, + resolveModelPolicy, + resolveModelInternal, + VALID_GRANULARITIES, + resolveGranularityInternal, + assertValidGranularityOverride, + resolveModelForTier, + VALID_EFFORTS, + EFFORT_SET, + nextEffort, + resolveEffortInternal, + resolveFastModeInternal, + resolveEffortForTier, +}; diff --git a/tests/model-resolver.test.cjs b/tests/model-resolver.test.cjs new file mode 100644 index 000000000..3ee4f068a --- /dev/null +++ b/tests/model-resolver.test.cjs @@ -0,0 +1,683 @@ +'use strict'; + +/** + * Tests for model-resolver.cjs (ADR-857 phase 2f / #888). + * + * Covers: + * - resolveModelInternal: model resolution across tiers + profile overrides + * - resolveGranularityInternal + assertValidGranularityOverride + * - resolveEffortInternal / resolveFastModeInternal + * - resolveEffortForTier / nextEffort + * - resolveModelForTier (dynamic routing) + * - resolveModelPolicy (#49 provider-neutral presets) + * - resolveTierEntry (#2517 runtime-aware tier resolution) + * - shim identity: core.X === modelResolver.X for all 13 public symbols + * - ADVERSARIAL: unknown agent types, invalid granularity/effort overrides, + * runtime override edge cases + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { cleanup } = require('./helpers.cjs'); + +// ─── modules under test ─────────────────────────────────────────────────────── + +const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs'); +const coreModule = require('../gsd-core/bin/lib/core.cjs'); + +const { + resolveTierEntry, + resolveModelPolicy, + resolveModelInternal, + VALID_GRANULARITIES, + resolveGranularityInternal, + assertValidGranularityOverride, + resolveModelForTier, + VALID_EFFORTS, + EFFORT_SET, + nextEffort, + resolveEffortInternal, + resolveFastModeInternal, + resolveEffortForTier, +} = modelResolver; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeTempProject(prefix = 'gsd-model-resolver-test-') { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + return tmpDir; +} + +function writeConfig(tmpDir, obj) { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8'); +} + +// ─── shim identity ──────────────────────────────────────────────────────────── + +describe('model-resolver shim identity', () => { + test('core.resolveTierEntry === modelResolver.resolveTierEntry', () => { + assert.strictEqual(coreModule.resolveTierEntry, modelResolver.resolveTierEntry); + }); + + test('core.resolveModelPolicy === modelResolver.resolveModelPolicy', () => { + assert.strictEqual(coreModule.resolveModelPolicy, modelResolver.resolveModelPolicy); + }); + + test('core.resolveModelInternal === modelResolver.resolveModelInternal', () => { + assert.strictEqual(coreModule.resolveModelInternal, modelResolver.resolveModelInternal); + }); + + test('core.VALID_GRANULARITIES === modelResolver.VALID_GRANULARITIES (same Set object)', () => { + assert.strictEqual(coreModule.VALID_GRANULARITIES, modelResolver.VALID_GRANULARITIES); + }); + + test('core.resolveGranularityInternal === modelResolver.resolveGranularityInternal', () => { + assert.strictEqual(coreModule.resolveGranularityInternal, modelResolver.resolveGranularityInternal); + }); + + test('core.assertValidGranularityOverride === modelResolver.assertValidGranularityOverride', () => { + assert.strictEqual(coreModule.assertValidGranularityOverride, modelResolver.assertValidGranularityOverride); + }); + + test('core.resolveModelForTier === modelResolver.resolveModelForTier', () => { + assert.strictEqual(coreModule.resolveModelForTier, modelResolver.resolveModelForTier); + }); + + test('core.VALID_EFFORTS === modelResolver.VALID_EFFORTS (same Array object)', () => { + assert.strictEqual(coreModule.VALID_EFFORTS, modelResolver.VALID_EFFORTS); + }); + + test('core.EFFORT_SET === modelResolver.EFFORT_SET (same Set object)', () => { + assert.strictEqual(coreModule.EFFORT_SET, modelResolver.EFFORT_SET); + }); + + test('core.nextEffort === modelResolver.nextEffort', () => { + assert.strictEqual(coreModule.nextEffort, modelResolver.nextEffort); + }); + + test('core.resolveEffortInternal === modelResolver.resolveEffortInternal', () => { + assert.strictEqual(coreModule.resolveEffortInternal, modelResolver.resolveEffortInternal); + }); + + test('core.resolveFastModeInternal === modelResolver.resolveFastModeInternal', () => { + assert.strictEqual(coreModule.resolveFastModeInternal, modelResolver.resolveFastModeInternal); + }); + + test('core.resolveEffortForTier === modelResolver.resolveEffortForTier', () => { + assert.strictEqual(coreModule.resolveEffortForTier, modelResolver.resolveEffortForTier); + }); +}); + +// ─── resolveModelInternal ───────────────────────────────────────────────────── + +describe('resolveModelInternal', () => { + let tmpDir; + beforeEach(() => { tmpDir = makeTempProject(); }); + afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; }); + + test('no config -> balanced profile -> gsd-planner resolves to a string', () => { + const model = resolveModelInternal(tmpDir, 'gsd-planner'); + assert.ok(typeof model === 'string' && model.length > 0, `Expected non-empty string, got: ${JSON.stringify(model)}`); + }); + + test('model_overrides takes precedence over everything else', () => { + writeConfig(tmpDir, { model_overrides: { 'gsd-planner': 'my-custom-model' } }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'my-custom-model'); + }); + + test('model_profile=quality -> opus-class model for gsd-planner', () => { + writeConfig(tmpDir, { model_profile: 'quality' }); + const model = resolveModelInternal(tmpDir, 'gsd-planner'); + // quality profile must resolve to a non-empty model string + assert.ok(typeof model === 'string' && model.length > 0); + }); + + test('model_profile=budget -> haiku-class model for gsd-planner', () => { + writeConfig(tmpDir, { model_profile: 'budget' }); + const model = resolveModelInternal(tmpDir, 'gsd-planner'); + assert.ok(typeof model === 'string' && model.length > 0); + }); + + test('resolve_model_ids=omit -> returns empty string', () => { + writeConfig(tmpDir, { resolve_model_ids: 'omit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), ''); + }); + + test('unknown agent type, no config -> returns a non-empty string (fallback)', () => { + const model = resolveModelInternal(tmpDir, 'completely-unknown-agent-xyz'); + assert.ok(typeof model === 'string' && model.length > 0); + }); + + test('model_profile=inherit -> returns "inherit"', () => { + writeConfig(tmpDir, { model_profile: 'inherit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit'); + }); + + test('models config per phase type overrides profile tier', () => { + writeConfig(tmpDir, { models: { planning: 'opus' } }); + // gsd-planner maps to planning phase type; config says opus + // with no resolve_model_ids, should return 'opus' + const model = resolveModelInternal(tmpDir, 'gsd-planner'); + assert.strictEqual(model, 'opus'); + }); + + test('models with invalid tier value falls through to profile', () => { + writeConfig(tmpDir, { models: { planning: 'not-a-valid-tier' } }); + // invalid tier value -> falls back to profile resolution + const model = resolveModelInternal(tmpDir, 'gsd-planner'); + assert.ok(typeof model === 'string' && model.length > 0); + }); + + test('runtime non-claude + model_profile_overrides for runtime tier', () => { + writeConfig(tmpDir, { + runtime: 'codex', + model_profile_overrides: { + codex: { haiku: 'codex-mini', sonnet: 'codex', opus: 'codex-full' }, + }, + }); + // gsd-codebase-mapper is light tier -> haiku in balanced profile + const model = resolveModelInternal(tmpDir, 'gsd-codebase-mapper'); + assert.ok(typeof model === 'string' && model.length > 0); + }); +}); + +// ─── resolveGranularityInternal ─────────────────────────────────────────────── + +describe('resolveGranularityInternal', () => { + let tmpDir; + beforeEach(() => { tmpDir = makeTempProject(); }); + afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; }); + + test('no config, no override -> returns "standard"', () => { + assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning'), 'standard'); + }); + + test('valid override wins over config', () => { + writeConfig(tmpDir, { granularity: 'fine' }); + assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', 'coarse'), 'coarse'); + }); + + test('invalid override ignored, falls through to config', () => { + writeConfig(tmpDir, { granularity: 'fine' }); + assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', 'ultradetailed'), 'fine'); + }); + + test('null override falls through to config', () => { + writeConfig(tmpDir, { granularity: 'coarse' }); + assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', null), 'coarse'); + }); + + test('per-phase-type granularity beats global granularity', () => { + writeConfig(tmpDir, { + granularity: 'coarse', + granularities: { planning: 'fine' }, + }); + assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning'), 'fine'); + }); + + test('planning.granularity nested config used as fallback', () => { + writeConfig(tmpDir, { planning: { granularity: 'coarse' } }); + assert.strictEqual(resolveGranularityInternal(tmpDir, null), 'coarse'); + }); + + test('VALID_GRANULARITIES contains exactly coarse, standard, fine', () => { + assert.ok(VALID_GRANULARITIES instanceof Set); + assert.ok(VALID_GRANULARITIES.has('coarse')); + assert.ok(VALID_GRANULARITIES.has('standard')); + assert.ok(VALID_GRANULARITIES.has('fine')); + assert.strictEqual(VALID_GRANULARITIES.size, 3); + }); +}); + +// ─── assertValidGranularityOverride ─────────────────────────────────────────── + +describe('assertValidGranularityOverride', () => { + test('undefined -> no-op (no throw)', () => { + assert.doesNotThrow(() => + assertValidGranularityOverride(undefined, (msg) => { throw new Error(msg); }) + ); + }); + + test('null -> no-op (no throw)', () => { + assert.doesNotThrow(() => + assertValidGranularityOverride(null, (msg) => { throw new Error(msg); }) + ); + }); + + test('empty string -> no-op (no throw)', () => { + assert.doesNotThrow(() => + assertValidGranularityOverride('', (msg) => { throw new Error(msg); }) + ); + }); + + test('valid value "coarse" -> no-op (no throw)', () => { + assert.doesNotThrow(() => + assertValidGranularityOverride('coarse', (msg) => { throw new Error(msg); }) + ); + }); + + test('invalid value -> calls fail with descriptive message', () => { + let caught = null; + // fail is called with the message; we capture it by throwing so the test can inspect + assert.throws( + () => assertValidGranularityOverride('megafine', (msg) => { caught = msg; throw new Error(msg); }), + (err) => { + assert.ok(err.message.includes('megafine'), `error message should include the invalid value: ${err.message}`); + assert.ok(err.message.includes('coarse') && err.message.includes('standard') && err.message.includes('fine'), + `error message should list valid values: ${err.message}`); + return true; + } + ); + assert.ok(caught !== null, 'fail should have been called'); + }); +}); + +// ─── resolveEffortInternal ──────────────────────────────────────────────────── + +describe('resolveEffortInternal', () => { + let tmpDir; + beforeEach(() => { tmpDir = makeTempProject(); }); + afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; }); + + test('no config -> gsd-planner (heavy) defaults to "xhigh" via tier default', () => { + assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'xhigh'); + }); + + test('invocation override beats everything', () => { + writeConfig(tmpDir, { effort: { agent_overrides: { 'gsd-planner': 'low' } } }); + assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner', { override: 'minimal' }), 'minimal'); + }); + + test('agent_overrides beats routing_tier_defaults', () => { + writeConfig(tmpDir, { + effort: { + routing_tier_defaults: { heavy: 'medium' }, + agent_overrides: { 'gsd-planner': 'low' }, + }, + }); + assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'low'); + }); + + test('effort.default is final fallback when no tier default matches', () => { + writeConfig(tmpDir, { effort: { default: 'minimal' } }); + assert.strictEqual(resolveEffortInternal(tmpDir, 'completely-unknown-agent-xyz'), 'minimal'); + }); + + test('VALID_EFFORTS and EFFORT_SET are consistent', () => { + assert.ok(Array.isArray(VALID_EFFORTS)); + assert.ok(EFFORT_SET instanceof Set); + assert.strictEqual(EFFORT_SET.size, VALID_EFFORTS.length); + for (const e of VALID_EFFORTS) { + assert.ok(EFFORT_SET.has(e), `EFFORT_SET missing: ${e}`); + } + }); +}); + +// ─── nextEffort ──────────────────────────────────────────────────────────────── + +describe('nextEffort', () => { + test('minimal -> low', () => { + assert.strictEqual(nextEffort('minimal'), 'low'); + }); + + test('max -> max (clamp at ceiling)', () => { + assert.strictEqual(nextEffort('max'), 'max'); + }); + + test('high -> xhigh', () => { + assert.strictEqual(nextEffort('high'), 'xhigh'); + }); + + test('unknown effort -> null', () => { + assert.strictEqual(nextEffort('turbo'), null); + }); +}); + +// ─── resolveFastModeInternal ────────────────────────────────────────────────── + +describe('resolveFastModeInternal', () => { + let tmpDir; + beforeEach(() => { tmpDir = makeTempProject(); }); + afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; }); + + test('no config -> defaults to false', () => { + assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), false); + }); + + test('opts.override=true beats config', () => { + writeConfig(tmpDir, { fast_mode: { agent_overrides: { 'gsd-planner': false } } }); + assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner', { override: true }), true); + }); + + test('fast_mode.enabled=true sets default for all agents', () => { + writeConfig(tmpDir, { fast_mode: { enabled: true } }); + assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), true); + }); + + test('agent_overrides beats enabled', () => { + writeConfig(tmpDir, { + fast_mode: { enabled: true, agent_overrides: { 'gsd-planner': false } }, + }); + assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), false); + }); + + test('unknown agent with no config -> false', () => { + assert.strictEqual(resolveFastModeInternal(tmpDir, 'unknown-agent-xyz'), false); + }); +}); + +// ─── resolveEffortForTier ───────────────────────────────────────────────────── + +describe('resolveEffortForTier', () => { + let tmpDir; + beforeEach(() => { tmpDir = makeTempProject(); }); + afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; }); + + test('dynamic_routing disabled -> attempt has no effect', () => { + const base = resolveEffortForTier(tmpDir, 'gsd-planner', 0); + const at1 = resolveEffortForTier(tmpDir, 'gsd-planner', 1); + assert.strictEqual(base, at1); + }); + + test('dynamic_routing enabled + escalate_on_failure=true + attempt=1 -> one step up', () => { + writeConfig(tmpDir, { + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 2, + }, + effort: { routing_tier_defaults: { light: 'low' } }, + }); + assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 0), 'low'); + assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 1), 'medium'); + }); + + test('escalation clamps at "max"', () => { + writeConfig(tmpDir, { + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 99, + }, + effort: { default: 'xhigh' }, + }); + assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-planner', 99), 'max'); + }); +}); + +// ─── resolveModelForTier ────────────────────────────────────────────────────── + +describe('resolveModelForTier', () => { + let tmpDir; + beforeEach(() => { tmpDir = makeTempProject(); }); + afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; }); + + test('no dynamic_routing -> falls back to resolveModelInternal', () => { + const fromForTier = resolveModelForTier(tmpDir, 'gsd-planner'); + const fromInternal = resolveModelInternal(tmpDir, 'gsd-planner'); + assert.strictEqual(fromForTier, fromInternal); + }); + + test('model_overrides wins before dynamic routing logic', () => { + writeConfig(tmpDir, { + model_overrides: { 'gsd-planner': 'override-model' }, + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 2, + }, + }); + assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-planner'), 'override-model'); + }); + + test('dynamic_routing + tier_models + attempt=0 -> default tier model', () => { + writeConfig(tmpDir, { + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku-custom', standard: 'sonnet-custom', heavy: 'opus-custom' }, + escalate_on_failure: true, + max_escalations: 2, + }, + }); + // gsd-codebase-mapper is 'light' tier + assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-codebase-mapper', 0), 'haiku-custom'); + }); + + test('dynamic_routing + attempt=1 escalates tier', () => { + writeConfig(tmpDir, { + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku-custom', standard: 'sonnet-custom', heavy: 'opus-custom' }, + escalate_on_failure: true, + max_escalations: 2, + }, + }); + // gsd-codebase-mapper light -> attempt=1 -> standard + assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-codebase-mapper', 1), 'sonnet-custom'); + }); +}); + +// ─── resolveModelPolicy ─────────────────────────────────────────────────────── + +describe('resolveModelPolicy (#49)', () => { + test('null policy -> null', () => { + assert.strictEqual(resolveModelPolicy(null, 'sonnet'), null); + }); + + test('no provider -> null', () => { + assert.strictEqual(resolveModelPolicy({ budget: 'medium' }, 'sonnet'), null); + }); + + test('generic provider: tier=opus -> reads policy.high', () => { + const result = resolveModelPolicy( + { provider: 'generic', high: 'my-high-model', medium: 'my-medium', low: 'my-low' }, + 'opus' + ); + assert.strictEqual(result, 'my-high-model'); + }); + + test('generic provider: tier=sonnet -> reads policy.medium', () => { + const result = resolveModelPolicy( + { provider: 'generic', high: 'hi', medium: 'med', low: 'lo' }, + 'sonnet' + ); + assert.strictEqual(result, 'med'); + }); + + test('generic provider: tier=haiku -> reads policy.low', () => { + const result = resolveModelPolicy( + { provider: 'generic', high: 'hi', medium: 'med', low: 'lo' }, + 'haiku' + ); + assert.strictEqual(result, 'lo'); + }); + + test('custom provider same as generic', () => { + const result = resolveModelPolicy( + { provider: 'custom', medium: 'custom-sonnet' }, + 'sonnet' + ); + assert.strictEqual(result, 'custom-sonnet'); + }); + + test('runtime_tiers override takes precedence over provider', () => { + const result = resolveModelPolicy( + { + provider: 'generic', + high: 'generic-hi', + medium: 'generic-med', + low: 'generic-lo', + runtime: 'codex', + runtime_tiers: { codex: { sonnet: 'codex-sonnet-override' } }, + }, + 'sonnet' + ); + assert.strictEqual(result, 'codex-sonnet-override'); + }); + + test('unknown tier for generic -> null', () => { + const result = resolveModelPolicy( + { provider: 'generic', high: 'hi', medium: 'med', low: 'lo' }, + 'unknown-tier' + ); + assert.strictEqual(result, null); + }); +}); + +// ─── resolveTierEntry ──────────────────────────────────────────────────────── + +describe('resolveTierEntry (#2517)', () => { + test('null runtime -> null', () => { + assert.strictEqual(resolveTierEntry({ runtime: null, tier: 'sonnet', overrides: null }), null); + }); + + test('null tier -> null', () => { + assert.strictEqual(resolveTierEntry({ runtime: 'codex', tier: null, overrides: null }), null); + }); + + test('unknown runtime + unknown tier, no overrides -> null', () => { + assert.strictEqual(resolveTierEntry({ + runtime: 'totally-unknown-runtime-xyz', + tier: 'totally-unknown-tier', + overrides: null, + }), null); + }); + + test('user override as string expands to { model: string }', () => { + const entry = resolveTierEntry({ + runtime: 'codex', + tier: 'sonnet', + overrides: { codex: { sonnet: 'my-custom-codex-model' } }, + }); + assert.ok(entry !== null); + assert.strictEqual(entry.model, 'my-custom-codex-model'); + }); + + test('user override as object merged with builtin', () => { + const entry = resolveTierEntry({ + runtime: 'codex', + tier: 'sonnet', + overrides: { codex: { sonnet: { model: 'user-model', extra: 'value' } } }, + }); + assert.ok(entry !== null); + assert.strictEqual(entry.model, 'user-model'); + assert.strictEqual(entry['extra'], 'value'); + }); +}); + +// ─── ADVERSARIAL ───────────────────────────────────────────────────────────── + +describe('ADVERSARIAL: edge cases', () => { + let tmpDir; + beforeEach(() => { tmpDir = makeTempProject(); }); + afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; }); + + test('resolveModelInternal: unknown agent + model_profile=quality -> "opus" fallback', () => { + writeConfig(tmpDir, { model_profile: 'quality' }); + const model = resolveModelInternal(tmpDir, 'completely-unknown-agent'); + assert.strictEqual(model, 'opus'); + }); + + test('resolveModelInternal: unknown agent + model_profile=budget -> "haiku" fallback', () => { + writeConfig(tmpDir, { model_profile: 'budget' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'unknown-agent'), 'haiku'); + }); + + test('resolveGranularityInternal: empty override "" is treated as no override', () => { + writeConfig(tmpDir, { granularity: 'fine' }); + assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', ''), 'fine'); + }); + + test('assertValidGranularityOverride: "ultrawide" is invalid -> fail called', () => { + let errorMsg = null; + assert.throws( + () => assertValidGranularityOverride('ultrawide', (msg) => { errorMsg = msg; throw new Error(msg); }), + (err) => { + assert.ok(err.message.includes('ultrawide'), `error should mention the invalid value: ${err.message}`); + return true; + } + ); + assert.ok(errorMsg !== null, 'fail should have been called'); + assert.ok(errorMsg.includes('ultrawide'), `error message should include 'ultrawide': ${errorMsg}`); + }); + + test('resolveEffortInternal: invalid override "turbo" falls through to tier default', () => { + const result = resolveEffortInternal(tmpDir, 'gsd-planner', { override: 'turbo' }); + // gsd-planner is heavy -> tier default xhigh + assert.strictEqual(result, 'xhigh'); + }); + + test('resolveFastModeInternal: string "true" override is not accepted (must be boolean)', () => { + const result = resolveFastModeInternal(tmpDir, 'gsd-planner', { override: 'true' }); + // string is not boolean -> falls through to default false + assert.strictEqual(result, false); + }); + + test('resolveEffortInternal: effort block is non-object string -> uses tier default', () => { + writeConfig(tmpDir, { effort: 'bad-value' }); + const result = resolveEffortInternal(tmpDir, 'gsd-planner'); + assert.ok(EFFORT_SET.has(result), `Expected valid effort, got: ${result}`); + }); + + test('resolveModelForTier: unknown agent with dynamic routing -> resolveModelInternal fallback', () => { + writeConfig(tmpDir, { + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 1, + }, + }); + // unknown agent has no defaultTier -> falls back to resolveModelInternal + const fromForTier = resolveModelForTier(tmpDir, 'unknown-agent-xyz'); + const fromInternal = resolveModelInternal(tmpDir, 'unknown-agent-xyz'); + assert.strictEqual(fromForTier, fromInternal); + }); + + test('resolveTierEntry: runtime override with non-string, non-object value -> no model set', () => { + const entry = resolveTierEntry({ + runtime: 'codex', + tier: 'sonnet', + overrides: { codex: { sonnet: 42 } }, + }); + // numeric 42 is neither string nor object -> treated as truthy userEntry=42 (not expanded) + // result will have whatever builtins exist + the override + // Key requirement: does not crash + assert.ok(entry !== null || entry === null, 'should not throw'); + }); + + test('resolveModelPolicy: non-object policy -> null', () => { + assert.strictEqual(resolveModelPolicy('string-policy', 'sonnet'), null); + }); + + test('resolveModelPolicy: null tier -> null', () => { + assert.strictEqual(resolveModelPolicy({ provider: 'generic', medium: 'sonnet' }, null), null); + }); + + test('resolveEffortForTier: max_escalations=0 caps escalation', () => { + writeConfig(tmpDir, { + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 0, + }, + effort: { routing_tier_defaults: { light: 'low' } }, + }); + const at0 = resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 0); + const at1 = resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 1); + // max_escalations=0 means no escalation allowed even at attempt=1 + assert.strictEqual(at0, at1); + }); +});