diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0e753ced7..d70340f3c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an opt-in per-project model role storage mode with global fallback from the model selector. + ## [17.0.1] - 2026-07-16 ### Changed diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 11c04307d..ead9cf281 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -897,6 +897,10 @@ export function parseModelPattern( const DEFAULT_MODEL_ROLE = "default"; const MODEL_ROLE_ALIAS_PREFIXES = [MODEL_ROLE_ALIAS_PREFIX, LEGACY_MODEL_ROLE_ALIAS_PREFIX]; +export interface ModelRoleLookup { + getModelRole(role: ModelRole | string): string | undefined; +} + function isModelRole(role: string): role is ModelRole { return (MODEL_ROLE_IDS as string[]).includes(role); } @@ -913,7 +917,7 @@ function modelRoleAliasPrefixLength(value: string): number | undefined { return MODEL_ROLE_ALIAS_PREFIXES.find(prefix => value.startsWith(prefix))?.length; } -function getModelRoleAlias(value: string, settings?: Settings): string | undefined { +function getModelRoleAlias(value: string, settings?: ModelRoleLookup): string | undefined { const normalized = value.trim(); const prefixLength = modelRoleAliasPrefixLength(normalized); if (prefixLength === undefined) return undefined; @@ -969,7 +973,7 @@ function resolveDefaultInheritedPatterns( role: ModelRole, configuredDefault: string | undefined, roleDefaults: string[], - settings: Settings | undefined, + settings: ModelRoleLookup | undefined, visited: Set, ): string[] { if (!shouldInheritDefaultBeforePriority(role) || !configuredDefault) return []; @@ -1007,7 +1011,7 @@ function resolveDefaultInheritedPatterns( function resolveConfiguredRolePattern( value: string, - settings?: Settings, + settings?: ModelRoleLookup, visited: Set = new Set(), ): string[] | undefined { const normalized = value.trim(); @@ -1044,7 +1048,7 @@ function resolveConfiguredRolePattern( /** * Expand a role alias like "@smol" to the configured model string. */ -export function expandRoleAlias(value: string, settings?: Settings): string { +export function expandRoleAlias(value: string, settings?: ModelRoleLookup): string { const normalized = value.trim(); if (normalized === DEFAULT_MODEL_ROLE) { return settings?.getModelRole("default") ?? value; @@ -1054,7 +1058,10 @@ export function expandRoleAlias(value: string, settings?: Settings): string { return resolved ?? value; } -export function resolveConfiguredModelPatterns(value: string | string[] | undefined, settings?: Settings): string[] { +export function resolveConfiguredModelPatterns( + value: string | string[] | undefined, + settings?: ModelRoleLookup, +): string[] { const patterns = normalizeModelPatternList(value); return patterns.flatMap(pattern => { const resolved = resolveConfiguredRolePattern(pattern, settings); @@ -1138,7 +1145,7 @@ export interface ResolvedModelRoleValue { export function resolveModelRoleValue( roleValue: string | undefined, availableModels: Model[], - options?: { settings?: Settings; matchPreferences?: ModelMatchPreferences }, + options?: { settings?: Settings; roleLookup?: ModelRoleLookup; matchPreferences?: ModelMatchPreferences }, ): ResolvedModelRoleValue { if (!roleValue) { return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; @@ -1149,7 +1156,7 @@ export function resolveModelRoleValue( return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; } - const effectivePatterns = resolveConfiguredModelPatterns(normalized, options?.settings); + const effectivePatterns = resolveConfiguredModelPatterns(normalized, options?.roleLookup ?? options?.settings); if (!effectivePatterns || effectivePatterns.length === 0) { return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; } diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 62b78bf20..c672ec8f0 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -67,6 +67,8 @@ import { // Schema Definition Types // ═══════════════════════════════════════════════════════════════════════════ +export type ModelRoleStorage = "global" | "project"; + export type SettingTab = | "appearance" | "model" @@ -509,6 +511,30 @@ export const SETTINGS_SCHEMA = { disabledExtensions: { type: "array", default: EMPTY_STRING_ARRAY }, + modelRoleStorage: { + type: "enum", + values: ["global", "project"] as const, + default: "global", + ui: { + tab: "model", + group: "Prompt", + label: "Model Role Storage", + description: "Where model selector role assignments are saved", + options: [ + { + value: "global", + label: "Global", + description: "Save role models in the active profile config (current behavior)", + }, + { + value: "project", + label: "Per-project", + description: "Save project role models in .omp/config.yml; missing project roles use global defaults", + }, + ], + }, + }, + modelRoles: { type: "record", default: EMPTY_STRING_RECORD }, modelTags: { type: "record", default: EMPTY_MODEL_TAGS_RECORD }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 22bbf3897..658147ff0 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -27,6 +27,7 @@ import { setWorktreesDir, } from "@oh-my-pi/pi-utils"; import { JSONC, YAML } from "bun"; +import { invalidate as invalidateCapabilityFsCache } from "../capability/fs"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; import type { ModelRole } from "../config/model-roles"; import { loadCapability } from "../discovery"; @@ -250,6 +251,16 @@ export class Settings { /** Paths modified during this session (for partial save) */ #modified = new Set(); + /** Individual project model roles modified during this session */ + #modifiedProjectModelRoles = new Set(); + /** + * Original process-wide model-role overrides captured before a project edit + * temporarily replaced them via `#updateRuntimeModelRoleOverride`. Restored + * on `reloadForCwd` / `cloneForCwd` so destination projects never inherit the + * source-project value. Maps role → original override value (`undefined` + * when the role had no runtime override). + */ + #savedRuntimeModelRoleOverrides = new Map(); /** Legacy `lastChangelogVersion` captured from config.yml during migration (now a marker file). */ #legacyLastChangelogVersion?: string; @@ -257,6 +268,8 @@ export class Settings { /** Pending save (debounced) */ #saveTimer?: NodeJS.Timeout; #savePromise?: Promise; + #projectSaveTimer?: NodeJS.Timeout; + #projectSavePromise?: Promise; /** Whether to persist changes */ #persist: boolean; @@ -400,6 +413,9 @@ export class Settings { * Apply runtime overrides (not persisted). */ override

(path: P, value: SettingValue

): void { + if (path === "modelRoles") { + this.#savedRuntimeModelRoleOverrides.clear(); + } const prev = this.get(path); const segments = path.split("."); setByPath(this.#overrides, segments, value); @@ -411,6 +427,9 @@ export class Settings { * Clear a runtime override. */ clearOverride(path: SettingPath): void { + if (path === "modelRoles") { + this.#savedRuntimeModelRoleOverrides.clear(); + } const prev = this.get(path); const segments = path.split("."); let current = this.#overrides; @@ -443,12 +462,22 @@ export class Settings { clearTimeout(this.#saveTimer); this.#saveTimer = undefined; } + if (this.#projectSaveTimer) { + clearTimeout(this.#projectSaveTimer); + this.#projectSaveTimer = undefined; + } if (this.#savePromise) { await this.#savePromise; } + if (this.#projectSavePromise) { + await this.#projectSavePromise; + } if (this.#modified.size > 0) { await this.#saveNow(); } + if (this.#modifiedProjectModelRoles.size > 0) { + await this.#saveProjectNow(); + } } async cloneForCwd(cwd: string): Promise { @@ -463,7 +492,7 @@ export class Settings { cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project); cloned.#configFiles = [...this.#configFiles]; cloned.#configOverlay = structuredClone(this.#configOverlay); - cloned.#overrides = structuredClone(this.#overrides); + cloned.#overrides = this.#buildOriginalOverrides(); cloned.#rebuildMerged(); cloned.#fireAllHooks(); return cloned; @@ -484,6 +513,8 @@ export class Settings { async reloadForCwd(cwd: string): Promise { const normalized = path.normalize(cwd); if (normalized === this.#cwd) return; + await this.flush(); + this.#restoreRuntimeModelRoleOverrides(); const prevModelRoles = this.get("modelRoles"); this.#cwd = normalized; if (this.#persist) { @@ -602,35 +633,168 @@ export class Settings { return roles; } + #modelRoleLayerOwns(layer: RawSettings, role: ModelRole | string): boolean { + const value = getByPath(layer, ["modelRoles"]); + if (!isRecord(value)) return false; + return Object.hasOwn(value, role); + } + + /** + * Set the full `modelRoles` map on the runtime override layer without + * routing through the public {@link override} method. Internal callers + * (project edits, global fallback updates) use this so they can control + * capture invalidation independently of the whole-map replacement + * semantics that `override("modelRoles", …)` carries. + */ + #setRuntimeModelRoleOverrides(next: Record): void { + const prev = this.get("modelRoles"); + setByPath(this.#overrides, ["modelRoles"], next); + this.#rebuildMerged(); + this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev); + } + + #updateRuntimeModelRoleOverride(role: ModelRole | string, modelId: string | undefined): void { + const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]); + if (!isRecord(runtimeOverrides) || !Object.hasOwn(runtimeOverrides, role)) return; + + const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides); + if (modelId === undefined) { + delete nextRuntimeOverride[role]; + } else { + nextRuntimeOverride[role] = modelId; + } + this.#setRuntimeModelRoleOverrides(nextRuntimeOverride); + } + + /** + * Capture the original process-wide override for `role` the first time a + * project edit temporarily replaces it, so the original can be restored on + * cwd changes. Subsequent edits in the same cwd must not overwrite the + * first captured value. + */ + #captureRuntimeModelRoleOverride(role: ModelRole | string): void { + if (this.#savedRuntimeModelRoleOverrides.has(role)) return; + const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]); + if (!isRecord(runtimeOverrides) || !Object.hasOwn(runtimeOverrides, role)) return; + this.#savedRuntimeModelRoleOverrides.set(role, this.#modelRolesFromLayer(this.#overrides)[role]); + } + + /** + * Restore original process-wide model-role overrides that were temporarily + * replaced by project edits, mutating `#overrides` in place without + * rebuilding. All remaining captures are valid because superseding + * operations (late `overrideModelRoles`, global-mode `setModelRole`, + * whole-map `override`/`clearOverride`) invalidate the affected captures + * at the point of supersession. Caller is responsible for `#rebuildMerged()`. + */ + #restoreRuntimeModelRoleOverrides(): void { + if (this.#savedRuntimeModelRoleOverrides.size === 0) return; + const runtimeRoles = getByPath(this.#overrides, ["modelRoles"]); + if (!isRecord(runtimeRoles)) { + this.#savedRuntimeModelRoleOverrides.clear(); + return; + } + for (const [role, originalValue] of this.#savedRuntimeModelRoleOverrides) { + if (originalValue === undefined) { + delete runtimeRoles[role]; + } else { + runtimeRoles[role] = originalValue; + } + } + this.#savedRuntimeModelRoleOverrides.clear(); + } + + /** + * Produce a deep copy of `#overrides` with original process-wide model-role + * overrides restored, for use by {@link cloneForCwd}. All remaining + * captures are valid (see {@link #restoreRuntimeModelRoleOverrides}). + * Does not mutate the current instance's `#overrides`. + */ + #buildOriginalOverrides(): RawSettings { + if (this.#savedRuntimeModelRoleOverrides.size === 0) { + return structuredClone(this.#overrides); + } + const overrides = structuredClone(this.#overrides); + const runtimeRoles = getByPath(overrides, ["modelRoles"]); + if (!isRecord(runtimeRoles)) return overrides; + for (const [role, originalValue] of this.#savedRuntimeModelRoleOverrides) { + if (originalValue === undefined) { + delete runtimeRoles[role]; + } else { + runtimeRoles[role] = originalValue; + } + } + return overrides; + } + + #setProjectModelRoleValue(role: ModelRole | string, modelId: string | null): void { + const prev = this.get("modelRoles"); + const projectRoles = getByPath(this.#project, ["modelRoles"]); + const current: Record = isRecord(projectRoles) ? { ...projectRoles } : {}; + current[role] = modelId; + setByPath(this.#project, ["modelRoles"], current); + this.#modifiedProjectModelRoles.add(role); + this.#rebuildMerged(); + this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev); + this.#queueProjectSave(); + } + /** * Set a model role (helper for modelRoles record). Passing `undefined` * clears the role from the persisted record and any runtime override. + * + * In project storage mode, when a project edit has temporarily replaced + * the process-wide runtime override for `role` and that override is still + * active (the runtime slot currently matches the project value), the + * global-layer write must not rewrite that runtime slot — otherwise the + * global fallback would immediately shadow the still-configured project + * role. The global layer is still persisted; only the runtime override is + * left untouched. The guard is precise so that a later clear, a late + * `overrideModelRoles`, or a storage-mode transition does not leave a + * stale skip in place. */ setModelRole(role: ModelRole | string, modelId: string | undefined): void { const current = this.#modelRolesFromLayer(this.#global); - const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]); - const updateRuntimeOverride = - !!runtimeOverrides && - typeof runtimeOverrides === "object" && - !Array.isArray(runtimeOverrides) && - Object.hasOwn(runtimeOverrides, role); - if (modelId === undefined) { delete current[role]; } else { current[role] = modelId; } this.set("modelRoles", current); - - if (updateRuntimeOverride) { - const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides); - if (modelId === undefined) { - delete nextRuntimeOverride[role]; - } else { - nextRuntimeOverride[role] = modelId; - } - this.override("modelRoles", nextRuntimeOverride); + if (this.isProjectModelRoleRuntimeOverrideActive(role)) { + return; } + this.#savedRuntimeModelRoleOverrides.delete(role); + this.#updateRuntimeModelRoleOverride(role, modelId); + } + + /** + * Whether `role`'s runtime override slot currently holds the temporary + * project-scoped value installed by a prior `setProjectModelRole`. Returns + * `false` when storage is not project-mode, no capture exists, or the + * project role was cleared. With explicit provenance invalidation, a + * surviving capture implies no external supersession occurred. + */ + isProjectModelRoleRuntimeOverrideActive(role: ModelRole | string): boolean { + if (this.get("modelRoleStorage") !== "project") return false; + if (!this.#savedRuntimeModelRoleOverrides.has(role)) return false; + return !!this.getProjectModelRole(role); + } + /** + * Set a model role in the current project's settings layer. + */ + setProjectModelRole(role: ModelRole | string, modelId: string): void { + this.#setProjectModelRoleValue(role, modelId); + this.#captureRuntimeModelRoleOverride(role); + this.#updateRuntimeModelRoleOverride(role, modelId); + } + /** + * Clear a model role from the current project's settings layer. + */ + clearProjectModelRole(role: ModelRole | string): void { + this.#setProjectModelRoleValue(role, null); + this.#captureRuntimeModelRoleOverride(role); + this.#updateRuntimeModelRoleOverride(role, undefined); } /** @@ -641,6 +805,49 @@ export class Settings { if (!isRecord(roles)) return undefined; return modelRoleValueFromUnknown(roles[role]); } + /** + * Get a model role from only the global settings layer. + */ + getGlobalModelRole(role: ModelRole | string): string | undefined { + const modelId = this.#modelRolesFromLayer(this.#global)[role]; + return modelId || undefined; + } + + /** + * Get a model role from only the current project settings layer. + */ + getProjectModelRole(role: ModelRole | string): string | undefined { + const modelId = this.#modelRolesFromLayer(this.#project)[role]; + return modelId || undefined; + } + + /** + * Report which layer actually supplies the effective model role across + * full merge precedence (runtime override → config overlay → project → + * global → default). Unlike {@link getModelRoleSource}, this accounts + * for runtime and config-overlay layers and detects ownership by key + * presence rather than normalized value, so a `null` tombstone in the + * overlay or runtime layer correctly blocks lower layers. The project + * layer is checked through {@link #projectSettingsForMerge} because a + * project null is a cleared value (falls back to global), not a + * tombstone. + */ + getModelRoleProvenance(role: ModelRole | string): "runtime" | "overlay" | "project" | "global" | "default" { + if (this.#modelRoleLayerOwns(this.#overrides, role)) return "runtime"; + if (this.#modelRoleLayerOwns(this.#configOverlay, role)) return "overlay"; + if (this.#modelRoleLayerOwns(this.#projectSettingsForMerge(), role)) return "project"; + if (this.#modelRoleLayerOwns(this.#global, role)) return "global"; + return "default"; + } + + /** + * Get the persisted layer supplying a model role (project/global/default only). + */ + getModelRoleSource(role: ModelRole | string): "project" | "global" | "default" { + if (this.getProjectModelRole(role)) return "project"; + if (this.getGlobalModelRole(role)) return "global"; + return "default"; + } /** * Get all model roles (helper for modelRoles record). @@ -668,9 +875,10 @@ export class Settings { for (const [role, modelId] of Object.entries(roles)) { if (modelId) { next[role] = modelId; + this.#savedRuntimeModelRoleOverrides.delete(role); } } - this.override("modelRoles", next); + this.#setRuntimeModelRoleOverrides(next); } /** @@ -778,6 +986,11 @@ export class Settings { merged = this.#deepMerge(merged, item.data as RawSettings); } } + const nativeProject = await this.#loadYaml(path.join(this.#cwd, ".omp", "config.yml")); + const nativeModelRoles = getByPath(nativeProject, ["modelRoles"]); + if (nativeModelRoles !== undefined) { + merged = this.#deepMerge(merged, { modelRoles: nativeModelRoles }); + } return this.#migrateRawSettings(merged); } catch { return {}; @@ -1304,14 +1517,20 @@ export class Settings { if (!this.#persist || !this.#configPath) return; // Debounce: wait 100ms for more changes - if (this.#saveTimer) { - clearTimeout(this.#saveTimer); - } + clearTimeout(this.#saveTimer); this.#saveTimer = setTimeout(() => { this.#saveTimer = undefined; - this.#saveNow().catch(err => { - logger.warn("Settings: background save failed", { error: String(err) }); - }); + const savePromise = this.#saveNow(); + this.#savePromise = savePromise; + savePromise + .catch(err => { + logger.warn("Settings: background save failed", { error: String(err) }); + }) + .finally(() => { + if (this.#savePromise === savePromise) { + this.#savePromise = undefined; + } + }); }, 100); } @@ -1348,13 +1567,77 @@ export class Settings { this.#rebuildMerged(); } + #queueProjectSave(): void { + if (!this.#persist) return; + + clearTimeout(this.#projectSaveTimer); + this.#projectSaveTimer = setTimeout(() => { + this.#projectSaveTimer = undefined; + const savePromise = this.#saveProjectNow(); + this.#projectSavePromise = savePromise; + savePromise + .catch(err => { + logger.warn("Settings: background project save failed", { error: String(err) }); + }) + .finally(() => { + if (this.#projectSavePromise === savePromise) { + this.#projectSavePromise = undefined; + } + }); + }, 100); + } + + async #saveProjectNow(): Promise { + if (!this.#persist || this.#modifiedProjectModelRoles.size === 0) return; + + const projectConfigPath = path.join(this.#cwd, ".omp", "config.yml"); + const modifiedModelRoles = [...this.#modifiedProjectModelRoles]; + this.#modifiedProjectModelRoles.clear(); + + try { + await fs.promises.mkdir(path.dirname(projectConfigPath), { recursive: true }); + await withFileLock(projectConfigPath, async () => { + const projectSettings = await this.#loadYaml(projectConfigPath); + + const projectRoles = getByPath(this.#project, ["modelRoles"]); + for (const role of modifiedModelRoles) { + const value = isRecord(projectRoles) ? projectRoles[role] : undefined; + setByPath(projectSettings, ["modelRoles", role], value); + } + + await Bun.write(projectConfigPath, YAML.stringify(projectSettings, null, 2)); + }); + invalidateCapabilityFsCache(projectConfigPath); + } catch (error) { + for (const role of modifiedModelRoles) { + this.#modifiedProjectModelRoles.add(role); + } + throw error; + } + + this.#rebuildMerged(); + } // ───────────────────────────────────────────────────────────────────────── // Utilities // ───────────────────────────────────────────────────────────────────────── + #projectSettingsForMerge(): RawSettings { + const projectRoles = getByPath(this.#project, ["modelRoles"]); + if (!isRecord(projectRoles)) return this.#project; + + let filteredRoles: Record | undefined; + for (const role in projectRoles) { + if (!Object.hasOwn(projectRoles, role) || modelRoleValueFromUnknown(projectRoles[role]) !== undefined) + continue; + filteredRoles ??= { ...projectRoles }; + delete filteredRoles[role]; + } + return filteredRoles ? { ...this.#project, modelRoles: filteredRoles } : this.#project; + } + #rebuildMerged(): void { - this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project); + this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#projectSettingsForMerge()); this.#merged = this.#deepMerge(this.#merged, this.#configOverlay); this.#merged = this.#deepMerge(this.#merged, this.#overrides); this.#resolvedCache.clear(); diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index d662f8ed8..c74b13c44 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -28,6 +28,7 @@ import { visibleWidth, } from "@oh-my-pi/pi-tui"; import type { ModelRegistry } from "../../config/model-registry"; +import { type ModelRoleLookup, type ResolvedModelRoleValue, resolveModelRoleValue } from "../../config/model-resolver"; import { getKnownRoleIds, getRoleInfo } from "../../config/model-roles"; import type { Settings } from "../../config/settings"; import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking"; @@ -75,11 +76,19 @@ export interface ScopedModelItem { thinkingLevel?: string; } +export type ModelRoleSelectionScope = "global" | "project"; + export interface ModelHubCallbacks { /** Persist a role assignment. */ - onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void; + onAssign: ( + model: Model, + role: string, + thinkingLevel: ConfiguredThinkingLevel | undefined, + selector: string, + scope?: ModelRoleSelectionScope, + ) => void; /** Clear a configured role back to auto-selection. */ - onUnassign: (role: string) => void; + onUnassign: (role: string, scope?: ModelRoleSelectionScope) => void; /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */ onFallbackChainChange?: (role: string, chain: string[]) => void; /** Locked provider activation: forward to the /login flow. */ @@ -111,18 +120,20 @@ interface StripChip { /** Pre-styled label body (without selection decoration). */ styled: string; role?: string; - action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "thinking"; + action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "scope" | "thinking"; thinkingLevel?: ConfiguredThinkingLevel; + scope?: ModelRoleSelectionScope; } type StripState = | { - kind: "role" | "thinking"; + kind: "role" | "scope" | "thinking"; item: ModelBrowserItem; role?: string; + scope?: ModelRoleSelectionScope; chips: StripChip[]; index: number; - /** Where to land when a thinking strip closes. */ + /** Where to land when a scope or thinking strip closes. */ returnToRoles: boolean; } | { @@ -556,6 +567,11 @@ export class ModelHubComponent implements Component { this.#tui.requestRender(); } + /** Re-sync after an asynchronous callback finishes mutating settings. */ + refreshAfterExternalMutation(): void { + this.#refreshAfterMutation(); + } + /** * Recompute per-provider match counts for the active query. Providers * without matches gray out and the scope hop skips them; a provider scope @@ -745,23 +761,55 @@ export class ModelHubComponent implements Component { this.#openRoleStrip(item); } + #roleForScope(role: string, scope: ModelRoleSelectionScope): ResolvedModelRoleValue { + const roleValue = + scope === "project" ? this.#settings.getProjectModelRole(role) : this.#settings.getGlobalModelRole(role); + const allModels = + this.#scopedModels.length > 0 ? this.#scopedModels.map(scoped => scoped.model) : this.#registry.getAll(); + const roleLookup: ModelRoleLookup = { + getModelRole: scopedRole => + scope === "project" + ? (this.#settings.getProjectModelRole(scopedRole) ?? this.#settings.getGlobalModelRole(scopedRole)) + : this.#settings.getGlobalModelRole(scopedRole), + }; + return resolveModelRoleValue(roleValue, allModels, { settings: this.#settings, roleLookup }); + } + + #thinkingLevelForScope(role: string, scope: ModelRoleSelectionScope): ConfiguredThinkingLevel { + const resolved = this.#roleForScope(role, scope); + return resolved.explicitThinkingLevel ? (resolved.thinkingLevel ?? ThinkingLevel.Inherit) : ThinkingLevel.Inherit; + } + /** Persist `role → item`, preserving a still-supported thinking level, then open the thinking strip. */ - #assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean): void { + #assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean, scope?: ModelRoleSelectionScope): void { + if (this.#settings.get("modelRoleStorage") === "project" && scope === undefined) { + this.#openScopeStrip(item, role, returnToRoles); + return; + } + const current = this.#roles[role]; let level: ConfiguredThinkingLevel = ThinkingLevel.Inherit; - if (current && !current.autoSelected) { - const supported = this.#thinkingOptionsFor(item.model); - level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit; + if (this.#settings.get("modelRoleStorage") === "project" && scope !== undefined) { + level = this.#thinkingLevelForScope(role, scope); + } else if (current && !current.autoSelected) { + level = current.thinkingLevel; } - this.#callbacks.onAssign(item.model, role, level, item.selector); + const supported = this.#thinkingOptionsFor(item.model); + if (!supported.includes(level)) level = ThinkingLevel.Inherit; + this.#callbacks.onAssign(item.model, role, level, item.selector, scope); this.#refreshAfterMutation(); - this.#openThinkingStrip(item, role, returnToRoles); + this.#openThinkingStrip(item, role, returnToRoles, scope); } #unassignRole(role: string): void { const assignment = this.#roles[role]; if (!assignment || assignment.autoSelected) return; - this.#callbacks.onUnassign(role); + if (this.#settings.get("modelRoleStorage") === "project") { + const source = this.#settings.getModelRoleSource(role); + this.#callbacks.onUnassign(role, source === "default" ? undefined : source); + } else { + this.#callbacks.onUnassign(role); + } this.#refreshAfterMutation(); } @@ -771,24 +819,32 @@ export class ModelHubComponent implements Component { #openRoleStrip(item: ModelBrowserItem): void { const chips: StripChip[] = []; + const scopedStorage = this.#settings.get("modelRoleStorage") === "project"; + const scopes: readonly ModelRoleSelectionScope[] = scopedStorage ? ["project", "global"] : ["global"]; for (const role of this.#visibleRoleIds()) { const info = getRoleInfo(role, this.#settings); const assignment = this.#roles[role]; - const assignedHere = - !!assignment && - !assignment.autoSelected && - assignment.model.provider === item.model.provider && - assignment.model.id === item.model.id; - const label = (info.tag ?? info.name ?? role).toLowerCase(); - chips.push({ - label, - styled: assignedHere - ? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) + - theme.fg("dim", ` ${theme.status.success}`) - : theme.fg(info.color ?? "muted", label), - role, - action: assignedHere ? "unassign" : "assign", - }); + for (const scope of scopes) { + const scopedModel = scopedStorage + ? this.#roleForScope(role, scope).model + : assignment && !assignment.autoSelected + ? assignment.model + : undefined; + const assignedHere = + !!scopedModel && scopedModel.provider === item.model.provider && scopedModel.id === item.model.id; + const roleLabel = (info.tag ?? info.name ?? role).toLowerCase(); + const label = scopedStorage ? `${scope} ${roleLabel}` : roleLabel; + chips.push({ + label, + styled: assignedHere + ? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) + + theme.fg("dim", ` ${theme.status.success}`) + : theme.fg(info.color ?? "muted", label), + role, + scope, + action: assignedHere ? "unassign" : "assign", + }); + } } chips.push({ label: `fallbacks:${item.model.id}`, @@ -804,9 +860,25 @@ export class ModelHubComponent implements Component { this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false }; } - #openThinkingStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void { + #openScopeStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void { + const chips: StripChip[] = [ + { label: "project", styled: theme.fg("accent", "project"), action: "scope", scope: "project" }, + { label: "global", styled: theme.fg("muted", "global"), action: "scope", scope: "global" }, + ]; + this.#strip = { kind: "scope", item, role, chips, index: 0, returnToRoles }; + } + + #openThinkingStrip( + item: ModelBrowserItem, + role: string, + returnToRoles: boolean, + scope?: ModelRoleSelectionScope, + ): void { const options = this.#thinkingOptionsFor(item.model); - const current = this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit; + const current = + this.#settings.get("modelRoleStorage") === "project" && scope !== undefined + ? this.#thinkingLevelForScope(role, scope) + : (this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit); const chips: StripChip[] = options.map(level => { const label = getConfiguredThinkingLevelMetadata(level).label; const glyph = thinkingLevelGlyph(level); @@ -822,6 +894,7 @@ export class ModelHubComponent implements Component { kind: "thinking", item, role, + scope, chips, index: preselect >= 0 ? preselect : 0, returnToRoles, @@ -832,7 +905,7 @@ export class ModelHubComponent implements Component { const strip = this.#strip; this.#strip = null; this.#chipRanges = []; - if (strip?.kind === "thinking" && strip.returnToRoles) { + if ((strip?.kind === "scope" || strip?.kind === "thinking") && strip.returnToRoles) { this.#setActiveEntry("roles"); this.#focus = "list"; } @@ -847,12 +920,16 @@ export class ModelHubComponent implements Component { case "assign": if (chip.role) { this.#strip = null; - this.#assignRole(strip.item, chip.role, false); + this.#assignRole(strip.item, chip.role, false, chip.scope); } return; case "unassign": if (chip.role) { - this.#callbacks.onUnassign(chip.role); + if (this.#settings.get("modelRoleStorage") === "project") { + this.#callbacks.onUnassign(chip.role, chip.scope); + } else { + this.#callbacks.onUnassign(chip.role); + } this.#refreshAfterMutation(); } this.#closeStrip(); @@ -869,9 +946,21 @@ export class ModelHubComponent implements Component { this.#closeStrip(); this.#startAssignFallback(`${strip.item.model.provider}/*`, null); return; + case "scope": + if (strip.role && chip.scope) { + this.#strip = null; + this.#assignRole(strip.item, strip.role, strip.returnToRoles, chip.scope); + } + return; case "thinking": if (strip.role && chip.thinkingLevel !== undefined) { - this.#callbacks.onAssign(strip.item.model, strip.role, chip.thinkingLevel, strip.item.selector); + this.#callbacks.onAssign( + strip.item.model, + strip.role, + chip.thinkingLevel, + strip.item.selector, + strip.scope, + ); this.#refreshAfterMutation(); } this.#closeStrip(); @@ -1305,13 +1394,20 @@ export class ModelHubComponent implements Component { if (printable === "t") { const assignment = role ? this.#roles[role] : undefined; if (role && assignment) { + const source = + this.#settings.get("modelRoleStorage") === "project" + ? this.#settings.getModelRoleSource(role) + : "default"; + const scope = source === "project" || source === "global" ? source : undefined; + const scopedModel = scope ? this.#roleForScope(role, scope).model : assignment.model; + if (!scopedModel) return; const item: ModelBrowserItem = { - provider: assignment.model.provider, - id: assignment.model.id, - model: assignment.model, - selector: `${assignment.model.provider}/${assignment.model.id}`, + provider: scopedModel.provider, + id: scopedModel.id, + model: scopedModel, + selector: `${scopedModel.provider}/${scopedModel.id}`, }; - this.#openThinkingStrip(item, role, true); + this.#openThinkingStrip(item, role, true, scope); } return; } @@ -1770,9 +1866,9 @@ export class ModelHubComponent implements Component { if (strip.kind === "roleName") { return "Enter create + pick model · Esc cancel"; } - return strip.kind === "role" - ? "←/→ choose · Enter assign/clear · Esc cancel" - : "←/→ thinking level · Enter apply · Esc keep"; + if (strip.kind === "role") return "←/→ choose · Enter assign/clear · Esc cancel"; + if (strip.kind === "scope") return "←/→ save scope · Enter choose · Esc cancel"; + return "←/→ thinking level · Enter apply · Esc keep"; } if (this.#assigning !== null) { switch (this.#assigning.kind) { diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index f9421b1a3..95f3bf1b9 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -879,6 +879,10 @@ export class SessionSelectorComponent extends Container { lockInput(): void { this.#inputLocked = true; } + /** Re-enable input after a failed resume so the user can pick again. */ + unlockInput(): void { + this.#inputLocked = false; + } /** * Dispose the session list explicitly: while the delete-confirmation dialog diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 5a8b7a715..4c71b0693 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -993,6 +993,12 @@ export class CommandController { return; } } + try { + await this.ctx.settings.flush(); + } catch (err) { + this.ctx.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`); + return; + } try { await this.ctx.sessionManager.moveTo(resolvedPath); @@ -1000,7 +1006,6 @@ export class CommandController { this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`); return; } - await this.ctx.applyCwdChange(resolvedPath); this.ctx.updateEditorBorderColor(); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 8c3c17b47..9a90b6df3 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -13,7 +13,11 @@ import { saveWatchdogConfigFile, } from "../../advisor"; import { reset as resetCapabilities } from "../../capability"; -import { formatModelSelectorValue, resolveAdvisorRoleSelection } from "../../config/model-resolver"; +import { + formatModelSelectorValue, + resolveAdvisorRoleSelection, + resolveModelRoleValue, +} from "../../config/model-resolver"; import { getRoleInfo } from "../../config/model-roles"; import { settings } from "../../config/settings"; import { disableProvider, enableProvider } from "../../discovery"; @@ -46,7 +50,12 @@ import { type ResetUsageAccount, toResetUsageAccounts, } from "../../slash-commands/helpers/reset-usage"; -import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; +import { + AUTO_THINKING, + type ConfiguredThinkingLevel, + concreteThinkingLevel, + parseConfiguredThinkingLevel, +} from "../../thinking"; import { isImageProviderPreference, isSearchProviderId, @@ -68,7 +77,7 @@ import { ExtensionDashboard } from "../components/extensions"; import { HistorySearchComponent } from "../components/history-search"; import { LoginDialogComponent } from "../components/login-dialog"; import { LogoutAccountSelectorComponent } from "../components/logout-account-selector"; -import { ModelHubComponent } from "../components/model-hub"; +import { ModelHubComponent, type ModelRoleSelectionScope } from "../components/model-hub"; import { ModelPickerComponent } from "../components/model-picker"; import { OAuthSelectorComponent } from "../components/oauth-selector"; import { PluginSelectorComponent } from "../components/plugin-selector"; @@ -87,6 +96,15 @@ const MANUAL_LOGIN_PROMPT = "Paste the authorization code (or full redirect URL) export class SelectorController { constructor(private ctx: InteractiveModeContext) {} + #defaultRoleMutationTail = Promise.resolve(); + + async #acquireDefaultRoleMutation(): Promise<() => void> { + const previous = this.#defaultRoleMutationTail; + const { promise, resolve } = Promise.withResolvers(); + this.#defaultRoleMutationTail = previous.then(() => promise); + await previous; + return resolve; + } async #refreshOAuthProviderAuthState(): Promise { const oauthProviders = getOAuthProviders(); @@ -710,55 +728,169 @@ export class SelectorController { this.ctx.session.modelRegistry, this.ctx.session.scopedModels, { - onAssign: async (model, role, thinkingLevel, selector) => { + onAssign: async (model, role, thinkingLevel, selector, scope?: ModelRoleSelectionScope) => { + const releaseDefaultMutation = role === "default" ? await this.#acquireDefaultRoleMutation() : undefined; + const configuredStorage = this.ctx.settings.get("modelRoleStorage"); + const targetScope = configuredStorage === "project" ? (scope ?? "project") : "global"; // `auto` is session-global: never baked into a per-role model value // (it can't round-trip through `model:`). Apply it to the session // separately and persist via `defaultThinkingLevel`. const isAuto = thinkingLevel === AUTO_THINKING; const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel; const selectorValue = selector ?? `${model.provider}/${model.id}`; + const scopeLabel = + configuredStorage === "project" ? `${targetScope === "project" ? "Project" : "Global"} ` : ""; + const defaultStatusLabel = configuredStorage === "project" ? `${scopeLabel}default` : "Default"; try { if (role === "default") { - const { switched } = await this.ctx.session.setModel(model, role, { - selector, - thinkingLevel: isAuto ? ThinkingLevel.Inherit : concreteThinking, - persist: true, - currentContextTokens, - }); - if (isAuto) { - if (switched) { - this.ctx.session.setThinkingLevel(AUTO_THINKING, true); - } else { + const effectiveProvenance = this.ctx.settings.getModelRoleProvenance("default"); + const shadowedGlobal = + configuredStorage === "project" && + targetScope === "global" && + (effectiveProvenance === "project" || + effectiveProvenance === "overlay" || + (effectiveProvenance === "runtime" && + this.ctx.settings.isProjectModelRoleRuntimeOverrideActive("default"))); + const shadowedProject = + configuredStorage === "project" && + targetScope === "project" && + effectiveProvenance === "overlay"; + if (shadowedGlobal) { + this.ctx.settings.setModelRole( + "default", + formatModelSelectorValue(selectorValue, concreteThinking), + ); + if (isAuto) { this.ctx.settings.set("defaultThinkingLevel", AUTO_THINKING); } - } else if (switched && concreteThinking && concreteThinking !== ThinkingLevel.Inherit) { - this.ctx.session.setThinkingLevel(concreteThinking); - } - if (switched) { + } else if (shadowedProject) { + this.ctx.settings.setProjectModelRole( + "default", + formatModelSelectorValue(selectorValue, concreteThinking), + ); + if (isAuto) { + this.ctx.settings.set("defaultThinkingLevel", AUTO_THINKING); + } + } else { + const { switched } = await this.ctx.session.setModel(model, role, { + selector, + thinkingLevel: isAuto ? ThinkingLevel.Inherit : concreteThinking, + persist: targetScope === "global", + currentContextTokens, + }); + if (!switched) return; + if (targetScope === "project") { + this.ctx.settings.setProjectModelRole( + "default", + formatModelSelectorValue(selectorValue, concreteThinking), + ); + } + if (isAuto) { + this.ctx.session.setThinkingLevel(AUTO_THINKING, true); + } else if (concreteThinking && concreteThinking !== ThinkingLevel.Inherit) { + this.ctx.session.setThinkingLevel(concreteThinking); + } this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); } - this.ctx.showStatus(`Default model: ${selector ?? model.id}`); + this.ctx.showStatus(`${defaultStatusLabel} model: ${selector ?? model.id}`); } else { // Other roles (smol, slow, custom): update settings, not the current model. - this.ctx.settings.setModelRole(role, formatModelSelectorValue(selectorValue, concreteThinking)); + const modelRoleValue = formatModelSelectorValue(selectorValue, concreteThinking); + if (targetScope === "project") { + this.ctx.settings.setProjectModelRole(role, modelRoleValue); + } else { + this.ctx.settings.setModelRole(role, modelRoleValue); + } if (isAuto) { this.ctx.session.setThinkingLevel(AUTO_THINKING, true); } const roleInfo = getRoleInfo(role, settings); - this.ctx.showStatus(`${roleInfo?.name ?? role} model: ${selector ?? model.id}`); + this.ctx.showStatus(`${scopeLabel}${roleInfo?.name ?? role} model: ${selector ?? model.id}`); } } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); + } finally { + releaseDefaultMutation?.(); + hub?.refreshAfterExternalMutation(); } }, - onUnassign: role => { + onUnassign: async (role, scope?: ModelRoleSelectionScope) => { + const releaseDefaultMutation = role === "default" ? await this.#acquireDefaultRoleMutation() : undefined; + const configuredStorage = this.ctx.settings.get("modelRoleStorage"); + const targetScope = configuredStorage === "project" ? (scope ?? "project") : "global"; + const scopeLabel = + configuredStorage === "project" ? `${targetScope === "project" ? "Project" : "Global"} ` : ""; try { - this.ctx.settings.setModelRole(role, undefined); + const previousEffectiveRoleValue = + role === "default" ? this.ctx.settings.getModelRole("default") : undefined; + if (targetScope === "project") { + this.ctx.settings.clearProjectModelRole(role); + } else { + this.ctx.settings.setModelRole(role, undefined); + } const roleInfo = getRoleInfo(role, settings); - this.ctx.showStatus(`${roleInfo?.name ?? role} role cleared — auto-selection applies`); + this.ctx.showStatus(`${scopeLabel}${roleInfo?.name ?? role} role cleared — auto-selection applies`); + // Clearing either persisted scope can also remove a captured + // runtime override. When that changes the effective default, + // resolve the newly exposed persisted layer and switch the live + // session without writing it back to global settings. Overlay + // and runtime provenance remain authoritative and session-neutral. + if (role === "default") { + const fallbackRoleValue = this.ctx.settings.getModelRole("default"); + const fallbackProvenance = this.ctx.settings.getModelRoleProvenance("default"); + const exposesPersistedFallback = + fallbackProvenance === "project" || fallbackProvenance === "global"; + if ( + fallbackRoleValue && + fallbackRoleValue !== previousEffectiveRoleValue && + exposesPersistedFallback + ) { + const scopedModels = this.ctx.session.scopedModels.map(sm => sm.model); + const availableModels = + scopedModels.length > 0 ? scopedModels : this.ctx.session.getAvailableModels(); + const resolved = resolveModelRoleValue(fallbackRoleValue, availableModels, { + settings: this.ctx.settings, + }); + if (resolved.model) { + const fallbackModel = resolved.model; + const isAuto = resolved.thinkingLevel === AUTO_THINKING; + let concreteThinking = concreteThinkingLevel(resolved.thinkingLevel); + let isAutoFromDefault = false; + if (!resolved.explicitThinkingLevel && !concreteThinking) { + const defaultLevel = parseConfiguredThinkingLevel( + this.ctx.settings.get("defaultThinkingLevel"), + ); + if (defaultLevel === AUTO_THINKING) { + isAutoFromDefault = true; + } else if (defaultLevel) { + concreteThinking = defaultLevel; + } + } + const effectiveIsAuto = isAuto || isAutoFromDefault; + const { switched } = await this.ctx.session.setModel(fallbackModel, "default", { + persist: false, + thinkingLevel: effectiveIsAuto + ? ThinkingLevel.Inherit + : (concreteThinking ?? ThinkingLevel.Inherit), + currentContextTokens, + }); + if (!switched) return; + if (effectiveIsAuto) { + this.ctx.session.setThinkingLevel(AUTO_THINKING, true); + } else if (concreteThinking && concreteThinking !== ThinkingLevel.Inherit) { + this.ctx.session.setThinkingLevel(concreteThinking); + } + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorBorderColor(); + } + } + } } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); + } finally { + releaseDefaultMutation?.(); + hub?.refreshAfterExternalMutation(); } }, onFallbackChainChange: (role, chain) => { @@ -1124,10 +1256,16 @@ export class SelectorController { sessions, async (session: SessionInfo) => { selector.lockInput(); + let keepOpen = false; try { - await this.handleResumeSession(session.path); + const success = await this.handleResumeSession(session.path); + if (!success) { + keepOpen = true; + selector.unlockInput(); + this.ctx.ui.requestRender(); + } } finally { - done(); + if (!keepOpen) done(); } }, () => { @@ -1205,13 +1343,23 @@ export class SelectorController { return true; } - async handleResumeSession(sessionPath: string): Promise { - this.ctx.clearTransientSessionUi(); - + async handleResumeSession(sessionPath: string, options?: { settingsFlushed?: boolean }): Promise { const previousCwd = this.ctx.sessionManager.getCwd(); + // Flush pending settings writes before switching sessions so a save + // failure leaves the session, process project dir, and Settings in the + // source scope — the switch below mutates the SessionManager cwd. + if (!options?.settingsFlushed) { + try { + await this.ctx.settings.flush(); + } catch (err) { + this.ctx.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + } // Switch session via AgentSession (emits hook and tool session events). The // SessionManager adopts the resumed session's own cwd when it differs. await this.ctx.session.switchSession(sessionPath); + this.ctx.clearTransientSessionUi(); const newCwd = this.ctx.sessionManager.getCwd(); const movedProject = normalizePathForComparison(newCwd) !== normalizePathForComparison(previousCwd); if (movedProject) { @@ -1226,6 +1374,7 @@ export class SelectorController { this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session"); + return true; } async handleSessionDeleteCommand(): Promise { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 27bb59275..7e2e24641 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -4310,11 +4310,20 @@ export class InteractiveMode implements InteractiveModeContext { this.#selectorController.showSessionSelector(); } - handleResumeSession(sessionPath: string): Promise { + async handleResumeSession(sessionPath: string): Promise { + // Flush pending settings writes *before* disposing controllers or resetting + // observers: a save failure must leave the session, process project dir, + // and Settings in the source scope with all UI intact. + try { + await this.settings.flush(); + } catch (err) { + this.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`); + return; + } this.#btwController.dispose(); this.#omfgController.dispose(); this.resetObserverRegistry(); - return this.#selectorController.handleResumeSession(sessionPath); + await this.#selectorController.handleResumeSession(sessionPath, { settingsFlushed: true }); } handleSessionDeleteCommand(): Promise { diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 424662761..3c1a526a7 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1704,6 +1704,11 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ } catch { return usage(`Directory does not exist: ${resolvedPath}`, runtime); } + try { + await runtime.settings.flush(); + } catch (err) { + return usage(`Failed to save pending settings: ${errorMessage(err)}`, runtime); + } try { await runtime.sessionManager.moveTo(resolvedPath); } catch (err) { diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index fe92f3b91..a65d4923d 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -1135,3 +1135,43 @@ describe("wave 5 — adapters and polish", () => { } }); }); + +describe("/move preflight flush", () => { + it("aborts text-mode /move when pending settings flush fails", async () => { + const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-acp-move-")); + try { + const { output, fakeSessionManager, runtime } = createRuntime(); + spyOn(runtime.settings, "flush").mockRejectedValue(new Error("disk full")); + + const result = await executeAcpBuiltinSlashCommand(`/move ${targetDir}`, runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("disk full"); + expect(fakeSessionManager!._movedTo).toBeUndefined(); + } finally { + await fs.rm(targetDir, { recursive: true, force: true }); + } + }); + + it("completes text-mode /move when flush succeeds", async () => { + const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-acp-move-ok-")); + const originalProjectDir = process.cwd(); + try { + const { output, fakeSessionManager, runtime } = createRuntime(); + let flushed = false; + spyOn(runtime.settings, "flush").mockImplementation(async () => { + flushed = true; + }); + + const result = await executeAcpBuiltinSlashCommand(`/move ${targetDir}`, runtime); + + expect(result).toEqual({ consumed: true }); + expect(flushed).toBe(true); + expect(fakeSessionManager!._movedTo).toBe(targetDir); + expect(output[0]).toContain("Moved to"); + } finally { + setProjectDir(originalProjectDir); + await fs.rm(targetDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index 553a14178..87b510089 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeAll, describe, expect, type Mock, test, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import { stripVTControlCharacters } from "node:util"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; @@ -208,6 +211,24 @@ describe("ModelHub", () => { expect(defaultRow).not.toContain("inherit"); expect(smolRow).toContain("auto"); }); + test("thinking-only edits preserve the model and scope from the persisted role layer", () => { + const storedModel = makeModel("test", "global-role-model"); + const effectiveModel = makeModel("test", "runtime-role-model"); + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setModelRole("default", `${storedModel.provider}/${storedModel.id}`); + settings.overrideModelRoles({ default: `${effectiveModel.provider}/${effectiveModel.id}` }); + const { hub, onAssign } = createHub({ models: [storedModel, effectiveModel], scoped: true, settings }); + + hub.handleInput(UP); // All models → Roles. + hub.handleInput("\n"); // Dive into role rows on DEFAULT. + hub.handleInput("t"); + hub.handleInput("\x1b[C"); // Inherit → off. + hub.handleInput("\n"); + + expect(onAssign.mock.calls[0]?.[0]).toBe(storedModel); + expect(onAssign.mock.calls[0]?.[1]).toBe("default"); + expect(onAssign.mock.calls[0]?.[4]).toBe("global"); + }); test("x clears a configured role back to auto-selection", () => { const model = makeModel("test", "worker-model"); @@ -343,6 +364,8 @@ describe("ModelHub", () => { const strip = footerLine(hub.render(220)); expect(strip).toContain("default"); expect(strip).toContain("retry-fallback"); + expect(strip).not.toContain("project default"); + expect(strip).not.toContain("global default"); hub.handleInput("\n"); // assign to default (first chip) expect(onAssign).toHaveBeenCalledTimes(1); @@ -351,6 +374,7 @@ describe("ModelHub", () => { expect(call?.[1]).toBe("default"); expect(call?.[2]).toBe(ThinkingLevel.Inherit); expect(call?.[3]).toBe("openai/gpt-5.5"); + expect(call?.[4]).toBe("global"); // The thinking strip follows immediately, scoped to the model's // real ladder: gpt-5.5 tops out at xhigh — no invented max tier. @@ -359,6 +383,185 @@ describe("ModelHub", () => { expect(thinking).toContain("xhigh"); expect(thinking).not.toContain("max"); }); + test("project storage exposes project and global role actions with callback scopes", () => { + const model = makeModel("test", "scoped-role-model"); + const settings = Settings.isolated({ modelRoleStorage: "project" }); + const projectHarness = createHub({ models: [model], scoped: true, settings }); + + projectHarness.hub.handleInput("\n"); + const projectStrip = footerLine(projectHarness.hub.render(220)); + expect(projectStrip).toContain("project default"); + expect(projectStrip).toContain("global default"); + projectHarness.hub.handleInput("\n"); + expect(projectHarness.onAssign.mock.calls[0]?.[4]).toBe("project"); + + const globalHarness = createHub({ models: [model], scoped: true, settings }); + globalHarness.hub.handleInput("\n"); + globalHarness.hub.handleInput(DOWN); + globalHarness.hub.handleInput("\n"); + expect(globalHarness.onAssign.mock.calls[0]?.[4]).toBe("global"); + }); + test("shadowed global assignments unassign from the global chip", () => { + const globalModel = makeModel("test", "a-global-role-model"); + const projectModel = makeModel("test", "z-project-role-model"); + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setModelRole("default", `${globalModel.provider}/${globalModel.id}`); + settings.setProjectModelRole("default", `${projectModel.provider}/${projectModel.id}`); + const { hub, onAssign, onUnassign } = createHub({ + models: [globalModel, projectModel], + scoped: true, + settings, + }); + + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput(DOWN); // Effective project model → shadowed global model. + hub.handleInput("\n"); + hub.handleInput(DOWN); // Project default → global default. + hub.handleInput("\n"); + + expect(onUnassign).toHaveBeenCalledWith("default", "global"); + expect(onAssign).not.toHaveBeenCalled(); + }); + test("overlay tombstones do not hide stored scoped default assignments", async () => { + const model = makeModel("test", "claude-haiku-4.5"); + const selector = `${model.provider}/${model.id}`; + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-model-hub-")); + const cwd = path.join(root, "project"); + const agentDir = path.join(root, "agent"); + const overlayPath = path.join(root, "overlay.yml"); + + try { + await Bun.write( + path.join(agentDir, "config.yml"), + `modelRoleStorage: project\nmodelRoles:\n default: ${selector}\n smol: ${selector}\n`, + ); + await Bun.write( + path.join(cwd, ".omp", "config.yml"), + `modelRoles:\n default: ${selector}\n smol: ${selector}\n`, + ); + await Bun.write(overlayPath, "modelRoles:\n default: null\n smol: null\n"); + const settings = await Settings.loadReadOnly({ cwd, agentDir, configFiles: [overlayPath] }); + expect(settings.getModelRole("default")).toBeUndefined(); + expect(settings.getGlobalModelRole("default")).toBe(selector); + expect(settings.getProjectModelRole("default")).toBe(selector); + + const projectDefault = createHub({ models: [model], scoped: true, settings }); + expect(normalize(projectDefault.hub.render(220))).toContain("○smol"); + projectDefault.hub.handleInput("\n"); + projectDefault.hub.handleInput("\n"); + expect(projectDefault.onUnassign).toHaveBeenCalledWith("default", "project"); + expect(projectDefault.onAssign).not.toHaveBeenCalled(); + + const globalDefault = createHub({ models: [model], scoped: true, settings }); + globalDefault.hub.handleInput("\n"); + globalDefault.hub.handleInput(DOWN); + globalDefault.hub.handleInput("\n"); + expect(globalDefault.onUnassign).toHaveBeenCalledWith("default", "global"); + expect(globalDefault.onAssign).not.toHaveBeenCalled(); + + const projectAutoSelected = createHub({ models: [model], scoped: true, settings }); + projectAutoSelected.hub.handleInput("\n"); + projectAutoSelected.hub.handleInput(DOWN); + projectAutoSelected.hub.handleInput(DOWN); + projectAutoSelected.hub.handleInput("\n"); + expect(projectAutoSelected.onUnassign).toHaveBeenCalledWith("smol", "project"); + expect(projectAutoSelected.onAssign).not.toHaveBeenCalled(); + + const globalAutoSelected = createHub({ models: [model], scoped: true, settings }); + globalAutoSelected.hub.handleInput("\n"); + globalAutoSelected.hub.handleInput(DOWN); + globalAutoSelected.hub.handleInput(DOWN); + globalAutoSelected.hub.handleInput(DOWN); + globalAutoSelected.hub.handleInput("\n"); + expect(globalAutoSelected.onUnassign).toHaveBeenCalledWith("smol", "global"); + expect(globalAutoSelected.onAssign).not.toHaveBeenCalled(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + test("auto-selected roles remain assignable when the selected scope has no stored role", () => { + const model = makeModel("test", "claude-haiku-4.5"); + const settings = Settings.isolated({ modelRoleStorage: "project" }); + const { hub, onAssign, onUnassign } = createHub({ models: [model], scoped: true, settings }); + expect(normalize(hub.render(220))).toContain("○smol"); + + hub.handleInput("\n"); + hub.handleInput(DOWN); + hub.handleInput(DOWN); + hub.handleInput("\n"); + + expect(onAssign.mock.calls[0]?.[1]).toBe("smol"); + expect(onAssign.mock.calls[0]?.[4]).toBe("project"); + expect(onUnassign).not.toHaveBeenCalled(); + }); + + test("global assignments preserve thinking from the global role instead of the project override", () => { + const configuredModel = getBundledModel("openai", "gpt-5.5"); + const targetModel = getBundledModel("openai", "gpt-5.6"); + if (!configuredModel || !targetModel) { + throw new Error("Expected bundled OpenAI models for scoped thinking test"); + } + const selector = `${configuredModel.provider}/${configuredModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setModelRole("smol", `${selector}:low,missing/unavailable:high`); + settings.setModelRole("default", "@smol"); + settings.setProjectModelRole("smol", `${selector}:high`); + settings.setProjectModelRole("default", "@smol"); + const { hub, onAssign } = createHub({ models: [configuredModel, targetModel], scoped: true, settings }); + + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput(DOWN); // Effective configured model → assignment target. + hub.handleInput("\n"); + hub.handleInput(DOWN); // Project default → global default. + hub.handleInput("\n"); + + expect(onAssign.mock.calls[0]?.[2]).toBe(ThinkingLevel.Low); + expect(onAssign.mock.calls[0]?.[4]).toBe("global"); + hub.handleInput("\n"); // Reapply the preselected global thinking level. + expect(onAssign.mock.calls[1]?.[2]).toBe(ThinkingLevel.Low); + expect(onAssign.mock.calls[1]?.[4]).toBe("global"); + }); + test("project-scope alias falls back to the global role when the project role is absent", () => { + const configuredModel = getBundledModel("openai", "gpt-5.5"); + const targetModel = getBundledModel("openai", "gpt-5.6"); + if (!configuredModel || !targetModel) { + throw new Error("Expected bundled OpenAI models for project alias fallback test"); + } + const selector = `${configuredModel.provider}/${configuredModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project" }); + // Global smol selects a concrete model with :low plus an unavailable + // fallback — the alias must resolve to this, not built-in priority. + settings.setModelRole("smol", `${selector}:low,missing/unavailable:high`); + // Global default also points at @smol — another project/effective + // conflict that would expose merged-resolution contamination if the + // alias lookup consulted merged settings instead of project-first. + settings.setModelRole("default", "@smol"); + // Project default is @smol; project smol is absent — the alias must + // fall back to the global smol, not built-in priority defaults. + settings.setProjectModelRole("default", "@smol"); + + // Assignment thinking: the preserved level comes from the global + // smol fallback (:low), not built-in priority defaults (Inherit). + const assignHub = createHub({ models: [configuredModel, targetModel], scoped: true, settings }); + assignHub.hub.handleInput("\t"); // Sidebar → model list. + assignHub.hub.handleInput(DOWN); // gpt-5.5 → gpt-5.6. + assignHub.hub.handleInput("\n"); // Open the role strip for gpt-5.6. + assignHub.hub.handleInput("\n"); // Assign to "project default" (first chip). + expect(assignHub.onAssign).toHaveBeenCalledTimes(1); + expect(assignHub.onAssign.mock.calls[0]?.[1]).toBe("default"); + expect(assignHub.onAssign.mock.calls[0]?.[2]).toBe(ThinkingLevel.Low); + expect(assignHub.onAssign.mock.calls[0]?.[4]).toBe("project"); + + // Chip classification: on gpt-5.5, the project default chip is + // "assigned here" because @smol falls back to global smol → gpt-5.5. + const classifyHub = createHub({ models: [configuredModel, targetModel], scoped: true, settings }); + classifyHub.hub.handleInput("\t"); // Sidebar → model list. + classifyHub.hub.handleInput("\n"); // Open the role strip for gpt-5.5. + classifyHub.hub.handleInput("\n"); // Select "project default" (first chip). + expect(classifyHub.onUnassign).toHaveBeenCalledWith("default", "project"); + expect(classifyHub.onAssign).not.toHaveBeenCalled(); + }); test("renders max as a real final tier on max-capable models (gpt-5.6)", () => { const model = getBundledModel("openai", "gpt-5.6"); diff --git a/packages/coding-agent/test/modes/components/session-selector-mouse.test.ts b/packages/coding-agent/test/modes/components/session-selector-mouse.test.ts index 1247683d2..54e1f1ddb 100644 --- a/packages/coding-agent/test/modes/components/session-selector-mouse.test.ts +++ b/packages/coding-agent/test/modes/components/session-selector-mouse.test.ts @@ -90,7 +90,7 @@ describe("SessionSelectorComponent mouse", () => { expect(picked?.id).toBe("cccc"); }); - it("ignores follow-up keys while the host resumes a selected session", () => { + it("ignores follow-up keys while locked, then accepts a retry after unlock", () => { const session = makeSession("aaaa", "Alpha session"); let selections = 0; let cancellations = 0; @@ -111,6 +111,9 @@ describe("SessionSelectorComponent mouse", () => { expect(selections).toBe(0); expect(cancellations).toBe(0); + selector.unlockInput(); + selector.handleInput("\n"); + expect(selections).toBe(1); }); it("ignores a click on the pinned footer (never resumes a hidden session)", () => { diff --git a/packages/coding-agent/test/modes/controllers/move-command.test.ts b/packages/coding-agent/test/modes/controllers/move-command.test.ts index e6bf15a20..81af90f23 100644 --- a/packages/coding-agent/test/modes/controllers/move-command.test.ts +++ b/packages/coding-agent/test/modes/controllers/move-command.test.ts @@ -6,7 +6,7 @@ import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/c import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; -function createMoveContext(sourceDir: string) { +function createMoveContext(sourceDir: string, settingsFlush?: () => Promise) { const state = { cwd: sourceDir, movedTo: undefined as string | undefined }; const present = vi.fn(); const applyCwdChange = vi.fn(async (cwd: string) => { @@ -22,6 +22,9 @@ function createMoveContext(sourceDir: string) { }), dropSession: vi.fn(async () => {}), }, + settings: { + flush: vi.fn(settingsFlush ?? (async () => {})), + }, showHookCustom: vi.fn(), showHookConfirm: vi.fn(), showError: vi.fn(), @@ -64,4 +67,26 @@ describe("CommandController /move", () => { await fs.rm(targetDir, { recursive: true, force: true }); } }); + + it("aborts /move when pending settings flush fails, leaving cwd untouched", async () => { + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-source-")); + const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-target-")); + try { + const { ctx, state } = createMoveContext(sourceDir, async () => { + throw new Error("disk full"); + }); + const controller = new CommandController(ctx); + + await controller.handleMoveCommand(targetDir); + + expect(ctx.showError).toHaveBeenCalledWith(expect.stringContaining("disk full")); + expect(ctx.sessionManager.moveTo).not.toHaveBeenCalled(); + expect(ctx.applyCwdChange).not.toHaveBeenCalled(); + expect(state.movedTo).toBeUndefined(); + expect(state.cwd).toBe(sourceDir); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + await fs.rm(targetDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/coding-agent/test/modes/controllers/resume-outer-preflight.test.ts b/packages/coding-agent/test/modes/controllers/resume-outer-preflight.test.ts new file mode 100644 index 000000000..cfa334f36 --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/resume-outer-preflight.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import * as path from "node:path"; +import * as core from "@oh-my-pi/pi-agent-core"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { createTools, type Tool } from "@oh-my-pi/pi-coding-agent/tools"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +beforeAll(async () => { + await initTheme(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function createMode(opts: { flushFails?: boolean } = {}): Promise<{ + mode: InteractiveMode; + session: AgentSession; + cleanup: () => Promise; +}> { + resetSettingsForTest(); + const tempDir = TempDir.createSync("@pi-resume-outer-"); + await Settings.init({ inMemory: true, cwd: tempDir.path() }); + const settings = Settings.isolated({ "compaction.enabled": false }); + + const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + const modelRegistry = new ModelRegistry(authStorage); + const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); + + const initialTools = await createTools( + { cwd: tempDir.path(), hasUI: false, getSessionFile: () => null, getSessionSpawns: () => "*", settings }, + ["read"], + ); + const toolRegistry = new Map(initialTools.map(tool => [tool.name, tool] as const)); + const session = new AgentSession({ + agent: new core.Agent({ + initialState: { model, systemPrompt: ["Test"], tools: initialTools, messages: [] }, + }), + sessionManager: SessionManager.create(tempDir.path(), tempDir.path()), + settings, + modelRegistry, + toolRegistry, + rebuildSystemPrompt: async () => ({ systemPrompt: ["Test"] }), + }); + const mode = new InteractiveMode(session, "test"); + vi.spyOn(mode, "addMessageToChat").mockReturnValue([]); + vi.spyOn(mode, "ensureLoadingAnimation").mockImplementation(() => {}); + mode.ui.requestRender = vi.fn(); + + // Make settings.flush fail or succeed as configured. + vi.spyOn(mode.settings, "flush").mockImplementation(async () => { + if (opts.flushFails) throw new Error("disk full"); + }); + + return { + mode, + session, + cleanup: async () => { + resetSettingsForTest(); + await tempDir.remove(); + }, + }; +} + +describe("InteractiveMode.handleResumeSession outer preflight flush", () => { + it("aborts before disposing controllers or resetting observers when flush fails", async () => { + const { mode, cleanup } = await createMode({ flushFails: true }); + try { + const resetSpy = vi.spyOn(mode, "resetObserverRegistry"); + const switchSpy = vi.spyOn(mode.session, "switchSession").mockResolvedValue(true); + const showErrorSpy = vi.spyOn(mode, "showError"); + + await mode.handleResumeSession("/tmp/some-session.jsonl"); + + expect(mode.settings.flush).toHaveBeenCalled(); + expect(showErrorSpy).toHaveBeenCalledWith(expect.stringContaining("disk full")); + expect(resetSpy).not.toHaveBeenCalled(); + expect(switchSpy).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } + }); + + it("disposes controllers and delegates to SelectorController with settingsFlushed on success", async () => { + const { mode, session, cleanup } = await createMode({ flushFails: false }); + try { + const resetSpy = vi.spyOn(mode, "resetObserverRegistry"); + const switchSpy = vi.spyOn(session, "switchSession").mockResolvedValue(true); + + await mode.handleResumeSession("/tmp/some-session.jsonl"); + + expect(mode.settings.flush).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalled(); + expect(switchSpy).toHaveBeenCalledWith("/tmp/some-session.jsonl"); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/resume-preflight.test.ts b/packages/coding-agent/test/modes/controllers/resume-preflight.test.ts new file mode 100644 index 000000000..c9d177d2b --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/resume-preflight.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as SessionSelector from "@oh-my-pi/pi-coding-agent/modes/components/session-selector"; +import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-listing"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; + +beforeAll(async () => { + await initTheme(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function createResumeContext(opts: { flushFails?: boolean; sourceCwd?: string } = {}) { + const sourceCwd = opts.sourceCwd ?? "/tmp/source-project"; + const state = { cwd: sourceCwd }; + const switchSession = vi.fn(async () => true); + const applyCwdChange = vi.fn(async () => {}); + const editor = {}; + let selector: SessionSelector.SessionSelectorComponent | undefined; + const hide = vi.fn(); + const setFocus = vi.fn(); + const flush = vi.fn(async () => { + if (opts.flushFails) throw new Error("disk full"); + }); + const ctx = { + session: { switchSession }, + sessionManager: { getCwd: () => state.cwd, getSessionDir: () => "/tmp" }, + settings: { flush }, + clearTransientSessionUi: vi.fn(), + applyCwdChange, + updateEditorBorderColor: vi.fn(), + renderInitialMessages: vi.fn(), + reloadTodos: vi.fn(async () => {}), + showStatus: vi.fn(), + showError: vi.fn(), + statusLine: { invalidate: vi.fn(), resetActiveTime: vi.fn() }, + ui: { + requestRender: vi.fn(), + setFocus, + terminal: { rows: 24 }, + showOverlay: vi.fn((component: unknown) => { + selector = component as SessionSelector.SessionSelectorComponent; + return { hide, setHidden: vi.fn(), isHidden: () => false }; + }), + }, + editor, + editorContainer: { children: [editor], clear: vi.fn(), addChild: vi.fn() }, + } as unknown as InteractiveModeContext; + return { ctx, switchSession, applyCwdChange, state, editor, hide, setFocus, flush, getSelector: () => selector }; +} + +describe("SelectorController.handleResumeSession preflight flush", () => { + it("aborts resume and returns false when flush fails, leaving session untouched", async () => { + const { ctx, switchSession, applyCwdChange } = createResumeContext({ flushFails: true }); + const controller = new SelectorController(ctx); + + const result = await controller.handleResumeSession("/tmp/some-session.jsonl"); + + expect(result).toBe(false); + expect(ctx.showError).toHaveBeenCalledWith(expect.stringContaining("disk full")); + expect(ctx.clearTransientSessionUi).not.toHaveBeenCalled(); + expect(switchSession).not.toHaveBeenCalled(); + expect(applyCwdChange).not.toHaveBeenCalled(); + expect(ctx.showStatus).not.toHaveBeenCalled(); + }); + + it("proceeds and returns true when flush succeeds", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-resume-preflight-")); + try { + const { ctx, switchSession, applyCwdChange, state } = createResumeContext({ sourceCwd: tmpDir }); + const targetCwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-resume-target-")); + switchSession.mockImplementation(async () => { + state.cwd = targetCwd; + return true; + }); + const controller = new SelectorController(ctx); + + const result = await controller.handleResumeSession("/tmp/some-session.jsonl"); + + expect(result).toBe(true); + expect(ctx.settings.flush).toHaveBeenCalled(); + expect(ctx.clearTransientSessionUi).toHaveBeenCalled(); + expect(switchSession).toHaveBeenCalledWith("/tmp/some-session.jsonl"); + expect(applyCwdChange).toHaveBeenCalledWith(targetCwd); + expect(ctx.showError).not.toHaveBeenCalled(); + expect(ctx.showStatus).toHaveBeenCalled(); + + await fs.rm(targetCwd, { recursive: true, force: true }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("skips flush when settingsFlushed option is true", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-resume-preflight-skip-")); + try { + const { ctx, switchSession, state } = createResumeContext({ sourceCwd: tmpDir }); + const targetCwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-resume-target-skip-")); + switchSession.mockImplementation(async () => { + state.cwd = targetCwd; + return true; + }); + const controller = new SelectorController(ctx); + + const result = await controller.handleResumeSession("/tmp/some-session.jsonl", { settingsFlushed: true }); + + expect(result).toBe(true); + expect(ctx.settings.flush).not.toHaveBeenCalled(); + expect(switchSession).toHaveBeenCalled(); + await fs.rm(targetCwd, { recursive: true, force: true }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("keeps the selector open and unlocked for retry when the settings flush fails", async () => { + const session: SessionInfo = { + path: "/tmp/canceled-picker-resume.jsonl", + id: "canceled-picker-resume", + cwd: "/tmp", + title: "Canceled picker resume", + created: new Date("2026-01-01T00:00:00Z"), + modified: new Date("2026-01-02T00:00:00Z"), + messageCount: 1, + size: 1, + firstMessage: "first", + allMessagesText: "first", + }; + vi.spyOn(SessionManager, "list").mockResolvedValue([session]); + const OriginalSelector = SessionSelector.SessionSelectorComponent; + const selectionPromises: Promise[] = []; + vi.spyOn(SessionSelector, "SessionSelectorComponent").mockImplementation( + (( + sessions: SessionInfo[], + onSelect: (session: SessionInfo) => void, + onCancel: () => void, + onExit: () => void, + options: SessionSelector.SessionSelectorOptions, + ) => + new OriginalSelector( + sessions, + selected => { + selectionPromises.push(onSelect(selected) as unknown as Promise); + }, + onCancel, + onExit, + options, + )) as never, + ); + const { ctx, switchSession, editor, hide, setFocus, flush, getSelector } = createResumeContext(); + flush.mockRejectedValueOnce(new Error("disk full")); + const controller = new SelectorController(ctx); + await controller.showSessionSelector(); + const selector = getSelector(); + expect(selector).toBeDefined(); + + selector!.handleInput("\n"); + expect(selectionPromises).toHaveLength(1); + await selectionPromises[0]; + + expect(hide).not.toHaveBeenCalled(); + expect(setFocus).not.toHaveBeenCalledWith(editor); + expect(switchSession).not.toHaveBeenCalled(); + + selector!.handleInput("\n"); + expect(selectionPromises).toHaveLength(2); + await selectionPromises[1]; + expect(switchSession).toHaveBeenCalledTimes(1); + expect(hide).toHaveBeenCalledTimes(1); + }); + + it("closes the selector and restores editor focus when switching rejects after preflight", async () => { + const session: SessionInfo = { + path: "/tmp/rejected-resume.jsonl", + id: "rejected-resume", + cwd: "/tmp", + title: "Rejected resume", + created: new Date("2026-01-01T00:00:00Z"), + modified: new Date("2026-01-02T00:00:00Z"), + messageCount: 1, + size: 1, + firstMessage: "first", + allMessagesText: "first", + }; + vi.spyOn(SessionManager, "list").mockResolvedValue([session]); + const OriginalSelector = SessionSelector.SessionSelectorComponent; + let selectionPromise: Promise | undefined; + vi.spyOn(SessionSelector, "SessionSelectorComponent").mockImplementation( + (( + sessions: SessionInfo[], + onSelect: (session: SessionInfo) => void, + onCancel: () => void, + onExit: () => void, + options: SessionSelector.SessionSelectorOptions, + ) => + new OriginalSelector( + sessions, + selected => { + selectionPromise = onSelect(selected) as unknown as Promise; + }, + onCancel, + onExit, + options, + )) as never, + ); + const { ctx, switchSession, editor, hide, setFocus, getSelector } = createResumeContext(); + const switchError = new Error("switch failed"); + switchSession.mockRejectedValue(switchError); + const controller = new SelectorController(ctx); + await controller.showSessionSelector(); + const selector = getSelector(); + expect(selector).toBeDefined(); + + selector!.handleInput("\n"); + expect(selectionPromise).toBeDefined(); + await expect(selectionPromise!).rejects.toBe(switchError); + + expect(ctx.settings.flush).toHaveBeenCalledTimes(1); + expect(switchSession).toHaveBeenCalledWith(session.path); + expect(hide).toHaveBeenCalledTimes(1); + expect(setFocus).toHaveBeenLastCalledWith(editor); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-overlay-focus.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-overlay-focus.test.ts index e3cc3d40d..9427a914d 100644 --- a/packages/coding-agent/test/modes/controllers/selector-controller-overlay-focus.test.ts +++ b/packages/coding-agent/test/modes/controllers/selector-controller-overlay-focus.test.ts @@ -131,12 +131,11 @@ describe("SelectorController session replacement overlay", () => { } as unknown as InteractiveModeContext; const controller = new SelectorController(ctx); const resumeStarted = Promise.withResolvers(); - const resumed = Promise.withResolvers(); + const resumed = Promise.withResolvers(); const handleResume = vi.spyOn(controller, "handleResumeSession").mockImplementation(() => { resumeStarted.resolve(); return resumed.promise; }); - await controller.showSessionSelector(); expect(selector).toBeDefined(); selector!.handleInput("\n"); @@ -152,7 +151,7 @@ describe("SelectorController session replacement overlay", () => { expect(handleResume).toHaveBeenCalledTimes(1); expect(hide).not.toHaveBeenCalled(); - resumed.resolve(); + resumed.resolve(true); await overlayHidden.promise; expect(hide).toHaveBeenCalledTimes(1); }); diff --git a/packages/coding-agent/test/selector-settings-side-effects.test.ts b/packages/coding-agent/test/selector-settings-side-effects.test.ts index b6f80ed11..abe7aa77f 100644 --- a/packages/coding-agent/test/selector-settings-side-effects.test.ts +++ b/packages/coding-agent/test/selector-settings-side-effects.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { stripVTControlCharacters } from "node:util"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; @@ -10,6 +13,7 @@ import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/mode import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import type { ResolvedRoleModel } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking"; +import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state"; let settingsState: SettingsTestState | undefined; @@ -152,6 +156,574 @@ describe("selector setting side effects", () => { hub.dispose(); } }); + it("routes project default assignments without persisting the global role", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const model = getBundledModel("openai", "gpt-5.6"); + if (!model) throw new Error("Expected bundled OpenAI model for selector test"); + const settings = Settings.isolated({ modelRoleStorage: "project" }); + const setModel = vi.fn(async () => ({ switched: true })); + const assignmentApplied = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.startsWith("Project default model:")) assignmentApplied.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model, + modelRegistry: { + getAll: () => [model], + getAvailable: () => [model], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows. + hub.handleInput("\n"); // Assign DEFAULT. + hub.handleInput("\n"); // Pick the scoped model. + hub.handleInput("\n"); // Save the assignment to the project. + await assignmentApplied.promise; + + expect(setModel).toHaveBeenCalledWith( + model, + "default", + expect.objectContaining({ + thinkingLevel: ThinkingLevel.Inherit, + persist: false, + }), + ); + expect(settings.getProjectModelRole("default")).toBe(`${model.provider}/${model.id}`); + expect(settings.getGlobalModelRole("default")).toBeUndefined(); + expect(showStatus).toHaveBeenCalledWith(`Project default model: ${model.provider}/${model.id}`); + } finally { + hub.dispose(); + } + }); + + it("edits a shadowed global default without switching the live project session", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const globalSelector = `${globalModel.provider}/${globalModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setProjectModelRole("default", projectSelector); + const setModel = vi.fn(async () => ({ switched: true })); + const assignmentApplied = Promise.withResolvers(); + const capturedRuntimeAssignmentApplied = Promise.withResolvers(); + let globalStatusCount = 0; + const showStatus = vi.fn((message: string) => { + if (!message.startsWith("Global default model:")) return; + globalStatusCount++; + if (globalStatusCount === 1) assignmentApplied.resolve(); + if (globalStatusCount === 2) capturedRuntimeAssignmentApplied.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, globalModel], + getAvailable: () => [projectModel, globalModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows. + hub.handleInput("\n"); // Assign DEFAULT. + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[B"); // Effective project model → new global fallback. + hub.handleInput("\n"); // Pick the global fallback model. + hub.handleInput("\x1b[B"); // Project scope → global scope. + hub.handleInput("\n"); + await assignmentApplied.promise; + + expect(setModel).not.toHaveBeenCalled(); + expect(settings.getGlobalModelRole("default")).toBe(globalSelector); + expect(settings.getProjectModelRole("default")).toBe(projectSelector); + expect(showStatus).toHaveBeenCalledWith(`Global default model: ${globalSelector}`); + + settings.overrideModelRoles({ default: globalSelector }); + settings.setProjectModelRole("default", projectSelector); + expect(settings.getModelRoleProvenance("default")).toBe("runtime"); + expect(settings.isProjectModelRoleRuntimeOverrideActive("default")).toBe(true); + + hub.handleInput("\x1b"); // Thinking strip → Roles. + hub.handleInput("\n"); // Assign DEFAULT again. + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[B"); // Effective project model → new global fallback. + hub.handleInput("\n"); // Pick the global fallback model. + hub.handleInput("\x1b[B"); // Project scope → global scope. + hub.handleInput("\n"); + await capturedRuntimeAssignmentApplied.promise; + + expect(setModel).not.toHaveBeenCalled(); + expect(settings.getGlobalModelRole("default")).toBe(globalSelector); + expect(settings.getModelRole("default")).toBe(projectSelector); + } finally { + hub.dispose(); + } + }); + + it("switches the live session when a global edit replaces a runtime override in project mode", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const globalSelector = `${globalModel.provider}/${globalModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setProjectModelRole("default", projectSelector); + // Simulate a CLI --model override: runtime override distinct from the project value. + settings.overrideModelRoles({ default: `anthropic/claude-sonnet-4-5` }); + const setModel = vi.fn(async () => ({ switched: true })); + const assignmentApplied = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.startsWith("Global default model:")) assignmentApplied.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, globalModel], + getAvailable: () => [projectModel, globalModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows. + hub.handleInput("\n"); // Assign DEFAULT. + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[B"); // Effective project model → new global fallback. + hub.handleInput("\n"); // Pick the global fallback model. + hub.handleInput("\x1b[B"); // Project scope → global scope. + hub.handleInput("\n"); + await assignmentApplied.promise; + + // The runtime override makes the global edit effective, so the live + // session must switch to the newly assigned global model. + expect(setModel).toHaveBeenCalledWith(globalModel, "default", expect.objectContaining({ persist: true })); + expect(settings.getProjectModelRole("default")).toBe(projectSelector); + expect(showStatus).toHaveBeenCalledWith(`Global default model: ${globalSelector}`); + } finally { + hub.dispose(); + } + }); + + it("switches a global edit when a byte-identical startup runtime override shadows the project default", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const globalSelector = `${globalModel.provider}/${globalModel.id}`; + const testDir = path.join(os.tmpdir(), `selector-runtime-identical-${Snowflake.next()}`); + const projectDir = path.join(testDir, "project"); + fs.mkdirSync(path.join(projectDir, ".omp"), { recursive: true }); + fs.writeFileSync(path.join(projectDir, ".omp", "config.yml"), `modelRoles:\n default: ${projectSelector}\n`); + + try { + const settings = await Settings.loadIsolated({ + cwd: projectDir, + agentDir: testDir, + inMemory: true, + overrides: { + modelRoleStorage: "project", + modelRoles: { default: projectSelector }, + }, + }); + expect(settings.getProjectModelRole("default")).toBe(projectSelector); + expect(settings.getModelRole("default")).toBe(projectSelector); + expect(settings.getModelRoleProvenance("default")).toBe("runtime"); + + let liveModel = projectModel; + const setModel = vi.fn(async () => { + liveModel = globalModel; + settings.setModelRole("default", globalSelector); + return { switched: true }; + }); + const assignmentApplied = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.startsWith("Global default model:")) assignmentApplied.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: liveModel, + modelRegistry: { + getAll: () => [projectModel, globalModel], + getAvailable: () => [projectModel, globalModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows. + hub.handleInput("\n"); // Assign DEFAULT. + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[B"); // Effective project model → new global default. + hub.handleInput("\n"); // Pick the global model. + hub.handleInput("\x1b[B"); // Project scope → global scope. + hub.handleInput("\n"); + await assignmentApplied.promise; + + expect(setModel).toHaveBeenCalledWith(globalModel, "default", expect.objectContaining({ persist: true })); + expect(liveModel).toBe(globalModel); + expect(settings.getGlobalModelRole("default")).toBe(globalSelector); + expect(settings.getProjectModelRole("default")).toBe(projectSelector); + expect(settings.getModelRole("default")).toBe(globalSelector); + expect(settings.getModelRoleProvenance("default")).toBe("runtime"); + expect(showStatus).toHaveBeenCalledWith(`Global default model: ${globalSelector}`); + } finally { + hub.dispose(); + } + } finally { + if (fs.existsSync(testDir)) removeSyncWithRetries(testDir); + } + }); + + it("persists project and global defaults shadowed by a config overlay without switching", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const overlayModel = getBundledModel("openai", "gpt-5.5"); + const projectModel = getBundledModel("openai", "gpt-5.6"); + if (!overlayModel || !projectModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const overlaySelector = `${overlayModel.provider}/${overlayModel.id}`; + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const testDir = path.join(os.tmpdir(), `selector-overlay-assignment-${Snowflake.next()}`); + const projectDir = path.join(testDir, "project"); + const overlayPath = path.join(testDir, "overlay.yml"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(overlayPath, `modelRoles:\n default: ${overlaySelector}\n`); + + try { + const settings = await Settings.loadIsolated({ + cwd: projectDir, + agentDir: testDir, + configFiles: [overlayPath], + overrides: { modelRoleStorage: "project" }, + }); + expect(settings.getModelRole("default")).toBe(overlaySelector); + expect(settings.getModelRoleProvenance("default")).toBe("overlay"); + + const setModel = vi.fn(async () => ({ switched: true })); + const projectAssignmentApplied = Promise.withResolvers(); + const autoApplied = Promise.withResolvers(); + const globalAssignmentApplied = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.startsWith("Project default model:")) projectAssignmentApplied.resolve(); + if ( + message.startsWith("Project default model:") && + settings.get("defaultThinkingLevel") === AUTO_THINKING + ) { + autoApplied.resolve(); + } + if (message.startsWith("Global default model:")) globalAssignmentApplied.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: overlayModel, + modelRegistry: { + getAll: () => [overlayModel, projectModel], + getAvailable: () => [overlayModel, projectModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: overlayModel }, { model: projectModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows. + hub.handleInput("\n"); // Assign DEFAULT. + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[B"); // Overlay model → hidden project default. + hub.handleInput("\n"); // Pick the project model. + hub.handleInput("\n"); // Save to project scope. + await projectAssignmentApplied.promise; + hub.handleInput("\x1b[C"); // Inherit → off. + hub.handleInput("\x1b[C"); // Off → auto. + hub.handleInput("\n"); + await autoApplied.promise; + expect(settings.get("defaultThinkingLevel")).toBe(AUTO_THINKING); + await settings.flush(); + + expect(settings.getProjectModelRole("default")).toBe(projectSelector); + expect(settings.getGlobalModelRole("default")).toBeUndefined(); + expect(settings.getModelRole("default")).toBe(overlaySelector); + expect(settings.getModelRoleProvenance("default")).toBe("overlay"); + expect(await Bun.file(path.join(projectDir, ".omp", "config.yml")).text()).toContain( + `default: ${projectSelector}`, + ); + expect(setModel).not.toHaveBeenCalled(); + expect(showStatus).toHaveBeenCalledWith(`Project default model: ${projectSelector}`); + + hub.handleInput("\x1b"); // Thinking strip → Roles. + hub.handleInput("\n"); // Assign DEFAULT again. + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[B"); // Overlay model → hidden project fallback. + hub.handleInput("\n"); // Pick the current project fallback. + hub.handleInput("\x1b[B"); // Project scope → global scope. + hub.handleInput("\n"); // Save the hidden global fallback. + await globalAssignmentApplied.promise; + + expect(settings.getGlobalModelRole("default")).toBe(projectSelector); + expect(settings.getModelRole("default")).toBe(overlaySelector); + expect(settings.getModelRoleProvenance("default")).toBe("overlay"); + expect(setModel).not.toHaveBeenCalled(); + } finally { + hub.dispose(); + } + } finally { + if (fs.existsSync(testDir)) removeSyncWithRetries(testDir); + } + }); + + it("switches the live default in global mode even when project settings retain an override", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const settings = Settings.isolated({}); + settings.setProjectModelRole("default", `${projectModel.provider}/${projectModel.id}`); + const setModel = vi.fn(async () => ({ switched: true })); + const assignmentApplied = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.startsWith("Default model:")) assignmentApplied.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, globalModel], + getAvailable: () => [projectModel, globalModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[B"); // Effective project model → new global default. + hub.handleInput("\n"); // Open the selected model's role strip. + hub.handleInput("\n"); // Assign DEFAULT in global-only mode. + await assignmentApplied.promise; + + expect(setModel).toHaveBeenCalledWith(globalModel, "default", expect.objectContaining({ persist: true })); + expect(showStatus).toHaveBeenCalledWith(`Default model: ${globalModel.provider}/${globalModel.id}`); + } finally { + hub.dispose(); + } + }); it("replaces malformed default retry fallback chains from the model selector action", async () => { const testTheme = await getThemeByName("dark"); @@ -315,4 +887,722 @@ describe("selector setting side effects", () => { expect(showModelCycleTrack).toHaveBeenCalledTimes(1); expect(showError).not.toHaveBeenCalled(); }); + + it("switches the live session to the global default when the project default is unassigned", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const globalSelector = `${globalModel.provider}/${globalModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setProjectModelRole("default", projectSelector); + settings.setModelRole("default", globalSelector); + + const setModel = vi.fn(async () => ({ switched: true })); + const setThinkingLevel = vi.fn(); + const statusInvalidate = vi.fn(); + const updateEditorBorderColor = vi.fn(); + const showError = vi.fn(); + const roleCleared = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.includes("role cleared")) roleCleared.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, globalModel], + getAvailable: () => [projectModel, globalModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel, + }, + statusLine: { invalidate: statusInvalidate }, + updateEditorBorderColor, + keybindings: { getKeys: () => [] }, + showStatus, + showError, + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows (scope → list focus). + hub.handleInput("\x7f"); // Backspace on DEFAULT to unassign. + await roleCleared.promise; + // The async setModel continuation needs a microtask to settle. + await Promise.resolve(); + + expect(settings.getProjectModelRole("default")).toBeUndefined(); + expect(settings.getGlobalModelRole("default")).toBe(globalSelector); + expect(setModel).toHaveBeenCalledWith(globalModel, "default", expect.objectContaining({ persist: false })); + expect(statusInvalidate).toHaveBeenCalled(); + expect(updateEditorBorderColor).toHaveBeenCalled(); + expect(showError).not.toHaveBeenCalled(); + } finally { + hub.dispose(); + } + }); + + it("switches to the project default when clearing a runtime-backed global default", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + const runtimeModel = getBundledModel("openai", "gpt-5.1"); + if (!projectModel || !globalModel || !runtimeModel) { + throw new Error("Expected bundled OpenAI models for selector test"); + } + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const globalSelector = `${globalModel.provider}/${globalModel.id}`; + const runtimeSelector = `${runtimeModel.provider}/${runtimeModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setProjectModelRole("default", projectSelector); + settings.setModelRole("default", globalSelector); + settings.overrideModelRoles({ default: runtimeSelector }); + + const switchCompleted = Promise.withResolvers(); + const setModel = vi.fn(async () => { + switchCompleted.resolve(); + return { switched: true }; + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: runtimeModel, + modelRegistry: { + getAll: () => [projectModel, globalModel, runtimeModel], + getAvailable: () => [projectModel, globalModel, runtimeModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }, { model: runtimeModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus: vi.fn(), + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[A"); // Runtime model → global fallback. + hub.handleInput("\n"); // Open scoped role chips. + hub.handleInput("\x1b[C"); // Project default → global default. + hub.handleInput("\n"); // Clear the global default. + await switchCompleted.promise; + + expect(settings.getGlobalModelRole("default")).toBeUndefined(); + expect(settings.getProjectModelRole("default")).toBe(projectSelector); + expect(settings.getModelRole("default")).toBe(projectSelector); + expect(setModel).toHaveBeenCalledWith(projectModel, "default", expect.objectContaining({ persist: false })); + } finally { + hub.dispose(); + } + }); + + it("serializes a later default edit behind a pending cleared-project fallback", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setProjectModelRole("default", `${projectModel.provider}/${projectModel.id}`); + settings.setModelRole("default", `${globalModel.provider}/${globalModel.id}`); + + const pendingFallback = Promise.withResolvers<{ switched: boolean }>(); + const supersedingEdit = Promise.withResolvers<{ switched: boolean }>(); + const supersedingEditStarted = Promise.withResolvers(); + let setModelCallCount = 0; + const setModel = vi.fn(() => { + setModelCallCount++; + if (setModelCallCount === 1) { + return pendingFallback.promise; + } + supersedingEditStarted.resolve(); + return supersedingEdit.promise; + }); + const roleCleared = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.includes("role cleared")) roleCleared.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, globalModel], + getAvailable: () => [projectModel, globalModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows. + hub.handleInput("\x7f"); // Clear project DEFAULT and begin its global fallback switch. + await roleCleared.promise; + + expect(setModel).toHaveBeenCalledTimes(1); + + hub.handleInput("\n"); // Start a later DEFAULT assignment. + hub.handleInput("\t"); // Sidebar → model list. + hub.handleInput("\x1b[A"); // Global fallback → project model. + hub.handleInput("\n"); // Pick the project model. + hub.handleInput("\n"); // Start the project-scoped default edit. + await Promise.resolve(); + + expect(setModel).toHaveBeenCalledTimes(1); + pendingFallback.resolve({ switched: false }); + await supersedingEditStarted.promise; + expect(setModel).toHaveBeenCalledTimes(2); + supersedingEdit.resolve({ switched: false }); + await Promise.resolve(); + } finally { + hub.dispose(); + } + }); + + it("does not switch the live session when unassigning a project default with no global fallback", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + if (!projectModel) throw new Error("Expected bundled OpenAI model for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setProjectModelRole("default", projectSelector); + + const setModel = vi.fn(async () => ({ switched: true })); + const showError = vi.fn(); + const roleCleared = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.includes("role cleared")) roleCleared.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel], + getAvailable: () => [projectModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError, + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows (scope → list focus). + hub.handleInput("\x7f"); // Backspace on DEFAULT to unassign. + await roleCleared.promise; + await Promise.resolve(); + + expect(settings.getProjectModelRole("default")).toBeUndefined(); + expect(settings.getGlobalModelRole("default")).toBeUndefined(); + expect(setModel).not.toHaveBeenCalled(); + expect(showError).not.toHaveBeenCalled(); + } finally { + hub.dispose(); + } + }); + + it("does not switch the live model when a --config overlay remains effective over the global default after the project default is unassigned", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + const overlayModel = getBundledModel("openai", "gpt-5.1"); + if (!projectModel || !globalModel || !overlayModel) + throw new Error("Expected bundled OpenAI models for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const globalSelector = `${globalModel.provider}/${globalModel.id}`; + const overlaySelector = `${overlayModel.provider}/${overlayModel.id}`; + + const testDir = path.join(os.tmpdir(), `selector-overlay-clear-${Snowflake.next()}`); + const projectDir = path.join(testDir, "project"); + const overlayPath = path.join(testDir, "overlay.yml"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(overlayPath, `modelRoles:\n default: ${overlaySelector}\n`); + + try { + const settings = await Settings.loadIsolated({ + cwd: projectDir, + agentDir: testDir, + inMemory: true, + configFiles: [overlayPath], + overrides: { modelRoleStorage: "project" }, + }); + settings.setModelRole("default", globalSelector); + settings.setProjectModelRole("default", projectSelector); + + // Sanity: the config overlay is authoritative over both the global and + // project layers in the merged view. + expect(settings.getGlobalModelRole("default")).toBe(globalSelector); + expect(settings.getProjectModelRole("default")).toBe(projectSelector); + expect(settings.getModelRole("default")).toBe(overlaySelector); + + const setModel = vi.fn(async () => ({ switched: true })); + const statusInvalidate = vi.fn(); + const updateEditorBorderColor = vi.fn(); + const showError = vi.fn(); + const roleCleared = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.includes("role cleared")) roleCleared.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, globalModel, overlayModel], + getAvailable: () => [projectModel, globalModel, overlayModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }, { model: overlayModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: statusInvalidate }, + updateEditorBorderColor, + keybindings: { getKeys: () => [] }, + showStatus, + showError, + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows (scope → list focus). + hub.handleInput("\x7f"); // Backspace on DEFAULT to unassign. + await roleCleared.promise; + await Promise.resolve(); + + expect(settings.getProjectModelRole("default")).toBeUndefined(); + expect(settings.getGlobalModelRole("default")).toBe(globalSelector); + // The config overlay remains authoritative after the clear. + expect(settings.getModelRole("default")).toBe(overlaySelector); + // The overlay is effective (distinct from the hidden global default), + // so the live model must NOT switch to either fallback — the overlay + // stays authoritative and no session-side persistence is warranted. + expect(setModel).not.toHaveBeenCalled(); + expect(showError).not.toHaveBeenCalled(); + } finally { + hub.dispose(); + } + } finally { + if (fs.existsSync(testDir)) removeSyncWithRetries(testDir); + } + }); + + it("does not switch the live model when a --config overlay byte-identical to the global default remains effective after the project default is unassigned", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const sharedModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !sharedModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const sharedSelector = `${sharedModel.provider}/${sharedModel.id}`; + + const testDir = path.join(os.tmpdir(), `selector-overlay-identical-${Snowflake.next()}`); + const projectDir = path.join(testDir, "project"); + const overlayPath = path.join(testDir, "overlay.yml"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(overlayPath, `modelRoles:\n default: ${sharedSelector}\n`); + + try { + const settings = await Settings.loadIsolated({ + cwd: projectDir, + agentDir: testDir, + inMemory: true, + configFiles: [overlayPath], + overrides: { modelRoleStorage: "project" }, + }); + settings.setModelRole("default", sharedSelector); + settings.setProjectModelRole("default", projectSelector); + + // Sanity: the config overlay and global layer carry the same raw value, + // but the overlay is the effective source in the merged view. + expect(settings.getGlobalModelRole("default")).toBe(sharedSelector); + expect(settings.getProjectModelRole("default")).toBe(projectSelector); + expect(settings.getModelRole("default")).toBe(sharedSelector); + expect(settings.getModelRoleProvenance("default")).toBe("overlay"); + + const setModel = vi.fn(async () => ({ switched: true })); + const showError = vi.fn(); + const roleCleared = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.includes("role cleared")) roleCleared.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, sharedModel], + getAvailable: () => [projectModel, sharedModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: sharedModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError, + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows (scope → list focus). + hub.handleInput("\x7f"); // Backspace on DEFAULT to unassign. + await roleCleared.promise; + await Promise.resolve(); + + expect(settings.getProjectModelRole("default")).toBeUndefined(); + expect(settings.getGlobalModelRole("default")).toBe(sharedSelector); + // The overlay is still effective with the same raw value as global. + expect(settings.getModelRole("default")).toBe(sharedSelector); + expect(settings.getModelRoleProvenance("default")).toBe("overlay"); + // Provenance is "overlay" (not "global"), so the live model must NOT + // switch even though the raw values are byte-identical. + expect(setModel).not.toHaveBeenCalled(); + expect(showError).not.toHaveBeenCalled(); + } finally { + hub.dispose(); + } + } finally { + if (fs.existsSync(testDir)) removeSyncWithRetries(testDir); + } + }); + + it("re-enables auto thinking from defaultThinkingLevel when the global default has no explicit thinking", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const globalSelector = `${globalModel.provider}/${globalModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project", defaultThinkingLevel: AUTO_THINKING }); + settings.setProjectModelRole("default", projectSelector); + settings.setModelRole("default", globalSelector); + + const setModel = vi.fn(async () => ({ switched: true })); + const setThinkingLevel = vi.fn((level: unknown, persist?: boolean) => { + if (level === AUTO_THINKING && persist) { + settings.set("defaultThinkingLevel", AUTO_THINKING); + } + }); + const roleCleared = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.includes("role cleared")) roleCleared.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, globalModel], + getAvailable: () => [projectModel, globalModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: projectModel }, { model: globalModel }], + getContextUsage: () => undefined, + setModel, + setThinkingLevel, + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); + hub.handleInput("\n"); + hub.handleInput("\x7f"); + await roleCleared.promise; + await Promise.resolve(); + + expect(setModel).toHaveBeenCalledWith( + globalModel, + "default", + expect.objectContaining({ persist: false, thinkingLevel: ThinkingLevel.Inherit }), + ); + expect(setThinkingLevel).toHaveBeenCalledWith(AUTO_THINKING, true); + } finally { + hub.dispose(); + } + }); + + it("resolves the global fallback from scoped models when nonempty", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const projectModel = getBundledModel("openai", "gpt-5.5"); + const globalModel = getBundledModel("openai", "gpt-5.6"); + if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test"); + + const projectSelector = `${projectModel.provider}/${projectModel.id}`; + const globalSelector = `${globalModel.provider}/${globalModel.id}`; + const settings = Settings.isolated({ modelRoleStorage: "project" }); + settings.setProjectModelRole("default", projectSelector); + settings.setModelRole("default", globalSelector); + + const setModel = vi.fn(async () => ({ switched: true })); + const roleCleared = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.includes("role cleared")) roleCleared.resolve(); + }); + // scopedModels contains ONLY projectModel; the global model is NOT in scopedModels. + const scopedModels = [{ model: projectModel }]; + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: projectModel, + modelRegistry: { + getAll: () => [projectModel, globalModel], + getAvailable: () => [projectModel, globalModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels, + getContextUsage: () => undefined, + setModel, + setThinkingLevel: vi.fn(), + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); + hub.handleInput("\n"); + hub.handleInput("\x7f"); + await roleCleared.promise; + await Promise.resolve(); + + // Global model is not in scopedModels, so resolveModelRoleValue cannot + // match it → setModel must NOT be called. + expect(setModel).not.toHaveBeenCalled(); + } finally { + hub.dispose(); + } + }); }); diff --git a/packages/coding-agent/test/settings-reload-cwd.test.ts b/packages/coding-agent/test/settings-reload-cwd.test.ts index 9c0ab8a94..6f40ec9de 100644 --- a/packages/coding-agent/test/settings-reload-cwd.test.ts +++ b/packages/coding-agent/test/settings-reload-cwd.test.ts @@ -1,11 +1,99 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getProjectAgentDir, removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; +import { YAML } from "bun"; import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state"; +it("defaults model role storage to global", () => { + expect(Settings.isolated({}).get("modelRoleStorage")).toBe("global"); +}); +it("applies project role mutations over active runtime overrides", () => { + const settings = Settings.isolated({}); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + settings.clearProjectModelRole("smol"); + expect(settings.getModelRole("smol")).toBeUndefined(); +}); + +it("reports effective model-role provenance across merge precedence", () => { + const settings = Settings.isolated({}); + // Absent → default. + expect(settings.getModelRoleProvenance("default")).toBe("default"); + + // Global only → global. + settings.setModelRole("default", "anthropic/global"); + expect(settings.getModelRoleProvenance("default")).toBe("global"); + + // Project overrides global → project. + settings.setProjectModelRole("default", "anthropic/project"); + expect(settings.getModelRoleProvenance("default")).toBe("project"); + + // Runtime override trumps all persisted layers → runtime. + settings.overrideModelRoles({ default: "anthropic/runtime" }); + expect(settings.getModelRoleProvenance("default")).toBe("runtime"); + + // Clearing the project role restores the pre-edit runtime override + // (captured and restored by clearProjectModelRole). Since the runtime + // override was set by overrideModelRoles and then the project edit + // captured+replaced it, clearing removes both the project value and + // the captured override, falling back to global. + settings.clearProjectModelRole("default"); + expect(settings.getModelRoleProvenance("default")).toBe("global"); +}); + +it("distinguishes runtime provenance from global when raw values are identical", () => { + const shared = "anthropic/claude-sonnet-4-5"; + const settings = Settings.isolated({}); + settings.setModelRole("default", shared); + // No runtime override → global provenance. + expect(settings.getModelRoleProvenance("default")).toBe("global"); + expect(settings.getModelRole("default")).toBe(shared); + + // Runtime override with the same raw value as global → runtime provenance. + settings.overrideModelRoles({ default: shared }); + expect(settings.getModelRoleProvenance("default")).toBe("runtime"); + expect(settings.getModelRole("default")).toBe(shared); +}); + +it("reports overlay provenance for a null tombstone that blocks the global fallback after project clear", async () => { + const testDir = path.join(os.tmpdir(), `provenance-null-${Snowflake.next()}`); + const projectDir = path.join(testDir, "project"); + const overlayPath = path.join(testDir, "overlay.yml"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(overlayPath, "modelRoles:\n default: null\n"); + try { + const settings = await Settings.loadIsolated({ + cwd: projectDir, + agentDir: testDir, + inMemory: true, + configFiles: [overlayPath], + overrides: { modelRoleStorage: "project" }, + }); + settings.setModelRole("default", "anthropic/global"); + settings.setProjectModelRole("default", "anthropic/project"); + + // The overlay null tombstone suppresses the role in the merged view. + expect(settings.getModelRole("default")).toBeUndefined(); + expect(settings.getModelRoleProvenance("default")).toBe("overlay"); + + // Clearing the project role must not expose the global layer: the + // overlay null is still the effective source. + settings.clearProjectModelRole("default"); + expect(settings.getProjectModelRole("default")).toBeUndefined(); + expect(settings.getGlobalModelRole("default")).toBe("anthropic/global"); + expect(settings.getModelRole("default")).toBeUndefined(); + expect(settings.getModelRoleProvenance("default")).toBe("overlay"); + } finally { + if (fs.existsSync(testDir)) removeSyncWithRetries(testDir); + } +}); + describe("Settings.reloadForCwd", () => { let settingsState: SettingsTestState | undefined; @@ -159,5 +247,411 @@ describe("Settings.reloadForCwd", () => { expect(settings.getCwd()).toBe(path.normalize(bareProject)); expect(settings.get("compaction.enabled")).toBe(true); }); + it("keeps failed project writes bound to their original cwd", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.setProjectModelRole("default", "anthropic/project"); + const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockRejectedValueOnce(new Error("simulated save failure")); + + try { + await expect(settings.reloadForCwd(bareProject)).rejects.toThrow("simulated save failure"); + expect(settings.getCwd()).toBe(path.normalize(startDir)); + expect(await Bun.file(path.join(bareProject, ".omp", "config.yml")).exists()).toBe(false); + } finally { + mkdirSpy.mockRestore(); + } + + await settings.flush(); + expect(YAML.parse(await Bun.file(path.join(startDir, ".omp", "config.yml")).text())).toEqual({ + modelRoles: { default: "anthropic/project" }, + }); + }); + + it("writes project model roles only to the project YAML", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + const projectConfigPath = path.join(startDir, ".omp", "config.yml"); + const globalConfigPath = path.join(agentDir, "config.yml"); + + settings.setProjectModelRole("default", "anthropic/claude-sonnet-4-5"); + await settings.flush(); + + expect(YAML.parse(await Bun.file(projectConfigPath).text())).toEqual({ + modelRoles: { default: "anthropic/claude-sonnet-4-5" }, + }); + expect(await Bun.file(globalConfigPath).exists()).toBe(false); + const reloaded = await Settings.loadIsolated({ cwd: startDir, agentDir }); + expect(reloaded.getProjectModelRole("default")).toBe("anthropic/claude-sonnet-4-5"); + }); + it("does not copy unedited roles from other project settings providers", async () => { + await Bun.write( + path.join(scopedProject, ".omp", "settings.json"), + JSON.stringify({ modelRoles: { default: "anthropic/external" } }), + ); + const settings = await Settings.init({ cwd: scopedProject, agentDir }); + + settings.setProjectModelRole("smol", "anthropic/project-smol"); + await settings.flush(); + + expect(YAML.parse(await Bun.file(path.join(scopedProject, ".omp", "config.yml")).text())).toEqual({ + modelRoles: { smol: "anthropic/project-smol" }, + }); + expect(settings.getProjectModelRole("default")).toBe("anthropic/external"); + }); + + it("reapplies only native model roles over normal project-provider precedence", async () => { + await Bun.write( + path.join(scopedProject, ".claude", "settings.json"), + JSON.stringify({ + compaction: { enabled: true }, + modelRoles: { default: "anthropic/claude" }, + }), + ); + await Bun.write( + path.join(scopedProject, ".omp", "config.yml"), + "compaction:\n enabled: false\nmodelRoles:\n default: anthropic/native\n", + ); + + const settings = await Settings.init({ cwd: scopedProject, agentDir }); + + expect(settings.getModelRole("default")).toBe("anthropic/native"); + expect(settings.getProjectModelRole("default")).toBe("anthropic/native"); + expect(settings.get("compaction.enabled")).toBe(true); + }); + + it("merges concurrent role writes under the project file lock", async () => { + const first = await Settings.loadIsolated({ cwd: startDir, agentDir }); + const second = await Settings.loadIsolated({ cwd: startDir, agentDir }); + first.setProjectModelRole("default", "anthropic/default"); + second.setProjectModelRole("smol", "anthropic/smol"); + + await Promise.all([first.flush(), second.flush()]); + + expect(YAML.parse(await Bun.file(path.join(startDir, ".omp", "config.yml")).text())).toEqual({ + modelRoles: { + default: "anthropic/default", + smol: "anthropic/smol", + }, + }); + }); + + it("reports project roles over global role fallbacks", async () => { + await Bun.write(path.join(agentDir, "config.yml"), "modelRoles:\n default: anthropic/global\n"); + await Bun.write(path.join(scopedProject, ".omp", "config.yml"), "modelRoles:\n default: anthropic/project\n"); + + const settings = await Settings.init({ cwd: scopedProject, agentDir }); + + expect(settings.getModelRole("default")).toBe("anthropic/project"); + expect(settings.getGlobalModelRole("default")).toBe("anthropic/global"); + expect(settings.getProjectModelRole("default")).toBe("anthropic/project"); + expect(settings.getModelRoleSource("default")).toBe("project"); + }); + + it("falls back to the global role after reloading a project without config", async () => { + await Bun.write(path.join(agentDir, "config.yml"), "modelRoles:\n default: anthropic/global\n"); + await Bun.write(path.join(scopedProject, ".omp", "config.yml"), "modelRoles:\n default: anthropic/project\n"); + const settings = await Settings.init({ cwd: scopedProject, agentDir }); + expect(settings.getModelRole("default")).toBe("anthropic/project"); + + await settings.reloadForCwd(bareProject); + + expect(settings.getModelRole("default")).toBe("anthropic/global"); + expect(settings.getProjectModelRole("default")).toBeUndefined(); + expect(settings.getModelRoleSource("default")).toBe("global"); + }); + it("keeps JSON-backed project roles cleared across later assignments and reload", async () => { + await Bun.write(path.join(agentDir, "config.yml"), "modelRoles:\n default: anthropic/global\n"); + await Bun.write( + path.join(scopedProject, ".omp", "settings.json"), + JSON.stringify({ modelRoles: { default: "anthropic/project" } }), + ); + const settings = await Settings.init({ cwd: scopedProject, agentDir }); + expect(settings.getModelRole("default")).toBe("anthropic/project"); + + settings.clearProjectModelRole("default"); + settings.setProjectModelRole("smol", "anthropic/project-smol"); + await settings.flush(); + + expect(settings.getModelRole("default")).toBe("anthropic/global"); + expect(YAML.parse(await Bun.file(path.join(scopedProject, ".omp", "config.yml")).text())).toEqual({ + modelRoles: { default: null, smol: "anthropic/project-smol" }, + }); + const reloaded = await Settings.loadIsolated({ cwd: scopedProject, agentDir }); + expect(reloaded.getModelRole("default")).toBe("anthropic/global"); + expect(reloaded.getProjectModelRole("default")).toBeUndefined(); + expect(reloaded.getProjectModelRole("smol")).toBe("anthropic/project-smol"); + }); + + it("restores original runtime override on reloadForCwd after project role edit", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime"); + + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime"); + }); + + it("retains the first original across multiple project role edits in the same cwd", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + + settings.setProjectModelRole("smol", "anthropic/project-1"); + settings.setProjectModelRole("smol", "anthropic/project-2"); + expect(settings.getModelRole("smol")).toBe("anthropic/project-2"); + + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime"); + }); + + it("cloneForCwd receives original runtime overrides after project role edit", async () => { + const settings = await Settings.loadIsolated({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + const cloned = await settings.cloneForCwd(bareProject); + expect(cloned.getModelRole("smol")).toBe("anthropic/runtime"); + // Source instance keeps the project-edited override for its current cwd. + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + }); + + it("does not delete a runtime override added after the project edit on reloadForCwd", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + // No runtime override initially — project edit captures nothing. + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + // A runtime override appears later (e.g. env/CLI reload). + settings.overrideModelRoles({ smol: "anthropic/runtime-late" }); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime-late"); + + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime-late"); + }); + + it("does not delete a runtime override added after the project edit on cloneForCwd", async () => { + const settings = await Settings.loadIsolated({ cwd: startDir, agentDir }); + settings.setProjectModelRole("smol", "anthropic/project"); + + settings.overrideModelRoles({ smol: "anthropic/runtime-late" }); + const cloned = await settings.cloneForCwd(bareProject); + expect(cloned.getModelRole("smol")).toBe("anthropic/runtime-late"); + }); + it("keeps project override effective when editing the shadowed global fallback in project mode", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.override("modelRoleStorage", "project"); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime"); + + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + // Editing the global fallback must persist the global layer without + // replacing the project-scoped runtime override. + settings.setModelRole("smol", "anthropic/global"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + expect(settings.getGlobalModelRole("smol")).toBe("anthropic/global"); + + // Reloading to a project without config restores the original runtime override. + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime"); + }); + + it("updates runtime override on global fallback edit in global storage mode", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + // modelRoleStorage defaults to "global". + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + // In global mode the existing behavior is preserved: the global edit + // rewrites the runtime override, so the effective role switches. + settings.setModelRole("smol", "anthropic/global"); + expect(settings.getModelRole("smol")).toBe("anthropic/global"); + expect(settings.getGlobalModelRole("smol")).toBe("anthropic/global"); + + // The global edit replaced the project edit in the runtime slot, so + // the captured original must NOT be restored — the global value survives. + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/global"); + }); + it("cloneForCwd restores original runtime override after project edit and global fallback edit", async () => { + const settings = await Settings.loadIsolated({ cwd: startDir, agentDir }); + settings.override("modelRoleStorage", "project"); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + settings.setModelRole("smol", "anthropic/global"); + + const cloned = await settings.cloneForCwd(bareProject); + expect(cloned.getModelRole("smol")).toBe("anthropic/runtime"); + // Source instance keeps the project-scoped override for its current cwd. + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + }); + it("updates runtime override after project clear and late runtime override on global edit", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.override("modelRoleStorage", "project"); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + settings.clearProjectModelRole("smol"); + expect(settings.getModelRole("smol")).toBeUndefined(); + + // A late runtime override replaces the cleared slot. + settings.overrideModelRoles({ smol: "anthropic/runtime-late" }); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime-late"); + + // Global edit must update the runtime override because the project + // role was cleared — the guard must not fire on the stale capture. + settings.setModelRole("smol", "anthropic/global"); + expect(settings.getModelRole("smol")).toBe("anthropic/global"); + expect(settings.getGlobalModelRole("smol")).toBe("anthropic/global"); + + // The global edit replaced the cleared slot, so the captured original + // must NOT be restored — the global value survives. + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/global"); + }); + + it("updates runtime override on global edit after switching from global to project storage", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + // Start in global mode — a global edit updates the runtime override. + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setModelRole("smol", "anthropic/global-1"); + expect(settings.getModelRole("smol")).toBe("anthropic/global-1"); + + // Switch to project storage — no project edit has captured anything, + // so a global edit must still update the runtime override. + settings.override("modelRoleStorage", "project"); + settings.setModelRole("smol", "anthropic/global-2"); + expect(settings.getModelRole("smol")).toBe("anthropic/global-2"); + expect(settings.getGlobalModelRole("smol")).toBe("anthropic/global-2"); + + // No capture was made (no project edit), so the runtime override + // was permanently updated by the global edits — reload keeps it. + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/global-2"); + }); + it("preserves a late runtime override on reloadForCwd when the project edit was superseded", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + settings.overrideModelRoles({ smol: "anthropic/runtime-late" }); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime-late"); + + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime-late"); + }); + it("preserves a late runtime override on cloneForCwd when the project edit was superseded", async () => { + const settings = await Settings.loadIsolated({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + + settings.overrideModelRoles({ smol: "anthropic/runtime-late" }); + const cloned = await settings.cloneForCwd(bareProject); + expect(cloned.getModelRole("smol")).toBe("anthropic/runtime-late"); + }); + it("restores the original runtime override on reloadForCwd after clearing the project role without a late override", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + settings.clearProjectModelRole("smol"); + expect(settings.getModelRole("smol")).toBeUndefined(); + + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime"); + }); + it("restores the original runtime override on cloneForCwd after clearing the project role without a late override", async () => { + const settings = await Settings.loadIsolated({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + + settings.clearProjectModelRole("smol"); + + const cloned = await settings.cloneForCwd(bareProject); + expect(cloned.getModelRole("smol")).toBe("anthropic/runtime"); + }); + it("preserves a same-valued late override on reloadForCwd", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + // A late override with the same value as the project edit must + // still invalidate the capture — value equality cannot be trusted. + settings.overrideModelRoles({ smol: "anthropic/project" }); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + }); + it("preserves a same-valued late override on cloneForCwd", async () => { + const settings = await Settings.loadIsolated({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + + settings.overrideModelRoles({ smol: "anthropic/project" }); + const cloned = await settings.cloneForCwd(bareProject); + expect(cloned.getModelRole("smol")).toBe("anthropic/project"); + }); + it("restores the late override C after A→project B→late C→project D on reloadForCwd", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + expect(settings.getModelRole("smol")).toBe("anthropic/project"); + + settings.overrideModelRoles({ smol: "anthropic/runtime-late" }); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime-late"); + + settings.setProjectModelRole("smol", "anthropic/project-2"); + expect(settings.getModelRole("smol")).toBe("anthropic/project-2"); + + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/runtime-late"); + }); + it("restores the late override C after A→project B→late C→project D on cloneForCwd", async () => { + const settings = await Settings.loadIsolated({ cwd: startDir, agentDir }); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + + settings.overrideModelRoles({ smol: "anthropic/runtime-late" }); + + settings.setProjectModelRole("smol", "anthropic/project-2"); + + const cloned = await settings.cloneForCwd(bareProject); + expect(cloned.getModelRole("smol")).toBe("anthropic/runtime-late"); + }); + it("preserves global supersession of a cleared project role on reloadForCwd", async () => { + const settings = await Settings.init({ cwd: startDir, agentDir }); + settings.override("modelRoleStorage", "project"); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + settings.clearProjectModelRole("smol"); + expect(settings.getModelRole("smol")).toBeUndefined(); + + // Global edit after clear supersedes the project edit. + settings.setModelRole("smol", "anthropic/global"); + expect(settings.getModelRole("smol")).toBe("anthropic/global"); + + await settings.reloadForCwd(bareProject); + expect(settings.getModelRole("smol")).toBe("anthropic/global"); + }); + it("preserves global supersession of a cleared project role on cloneForCwd", async () => { + const settings = await Settings.loadIsolated({ cwd: startDir, agentDir }); + settings.override("modelRoleStorage", "project"); + settings.overrideModelRoles({ smol: "anthropic/runtime" }); + settings.setProjectModelRole("smol", "anthropic/project"); + settings.clearProjectModelRole("smol"); + + settings.setModelRole("smol", "anthropic/global"); + + const cloned = await settings.cloneForCwd(bareProject); + expect(cloned.getModelRole("smol")).toBe("anthropic/global"); + }); }); });