feat(coding-agent): support per-project model roles

This commit is contained in:
Gerben Meijer
2026-07-16 21:41:30 +04:00
parent c0d0ad7629
commit 1e083eb832
19 changed files with 3089 additions and 111 deletions
+4
View File
@@ -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
@@ -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>,
): string[] {
if (!shouldInheritDefaultBeforePriority(role) || !configuredDefault) return [];
@@ -1007,7 +1011,7 @@ function resolveDefaultInheritedPatterns(
function resolveConfiguredRolePattern(
value: string,
settings?: Settings,
settings?: ModelRoleLookup,
visited: Set<string> = 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<Api>[],
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 };
}
@@ -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 },
+308 -25
View File
@@ -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<string>();
/** Individual project model roles modified during this session */
#modifiedProjectModelRoles = new Set<string>();
/**
* 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<string, string | undefined>();
/** 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<void>;
#projectSaveTimer?: NodeJS.Timeout;
#projectSavePromise?: Promise<void>;
/** Whether to persist changes */
#persist: boolean;
@@ -400,6 +413,9 @@ export class Settings {
* Apply runtime overrides (not persisted).
*/
override<P extends SettingPath>(path: P, value: SettingValue<P>): 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<Settings> {
@@ -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<void> {
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<string, string>): 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<string, unknown> = 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<void> {
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<string, unknown> | 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();
@@ -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) {
@@ -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
@@ -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();
@@ -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<void>();
this.#defaultRoleMutationTail = previous.then(() => promise);
await previous;
return resolve;
}
async #refreshOAuthProviderAuthState(): Promise<void> {
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:<level>`). 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<void> {
this.ctx.clearTransientSessionUi();
async handleResumeSession(sessionPath: string, options?: { settingsFlushed?: boolean }): Promise<boolean> {
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<void> {
@@ -4310,11 +4310,20 @@ export class InteractiveMode implements InteractiveModeContext {
this.#selectorController.showSessionSelector();
}
handleResumeSession(sessionPath: string): Promise<void> {
async handleResumeSession(sessionPath: string): Promise<void> {
// 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<void> {
@@ -1704,6 +1704,11 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
} 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) {
@@ -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 });
}
});
});
@@ -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");
@@ -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)", () => {
@@ -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<void>) {
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 });
}
});
});
@@ -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<void>;
}> {
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<string, Tool>(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();
}
});
});
@@ -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<void>[] = [];
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<void>);
},
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<void> | 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<void>;
},
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);
});
});
@@ -131,12 +131,11 @@ describe("SelectorController session replacement overlay", () => {
} as unknown as InteractiveModeContext;
const controller = new SelectorController(ctx);
const resumeStarted = Promise.withResolvers<void>();
const resumed = Promise.withResolvers<void>();
const resumed = Promise.withResolvers<boolean>();
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);
});
File diff suppressed because it is too large Load Diff
@@ -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");
});
});
});