From ec762602f124f5a325a0c0328888f5d75ba36df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=97=AD?= Date: Mon, 19 Oct 2026 12:38:42 +0800 Subject: [PATCH] feat(cli): show recent models in the model selector --- apps/cli/src/ui/interactive-mode.ts | 4 + .../cli/src/ui/view/dialogs/model-selector.ts | 37 ++++- apps/cli/test/model-selector.test.ts | 127 +++++++++++++++++- docs/recent-models.md | 16 +++ .../coding-agent/src/core/settings-manager.ts | 38 ++++++ .../coding-agent/test/recent-models.test.ts | 127 ++++++++++++++++++ 6 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 docs/recent-models.md create mode 100644 packages/coding-agent/test/recent-models.test.ts diff --git a/apps/cli/src/ui/interactive-mode.ts b/apps/cli/src/ui/interactive-mode.ts index 9b8fd289..564a2d0b 100644 --- a/apps/cli/src/ui/interactive-mode.ts +++ b/apps/cli/src/ui/interactive-mode.ts @@ -4202,6 +4202,7 @@ export class InteractiveMode { const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; this.showStatus(msg); } else { + this.settingsManager.recordRecentModel(result.model.provider, result.model.id); this.footer.invalidate(); this.updateEditorBorderColor(); const thinkingStr = @@ -4895,6 +4896,7 @@ export class InteractiveMode { if (model) { try { await this.session.setModel(model, { persist: false }); + this.settingsManager.recordRecentModel(model.provider, model.id); this.footer.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Model: ${model.id}`); @@ -5038,6 +5040,7 @@ export class InteractiveMode { const selectModel = async (model: Model, persist: boolean) => { try { await this.session.setModel(model, { persist }); + this.settingsManager.recordRecentModel(model.provider, model.id); this.updateAvailableProviderCount(); this.footer.invalidate(); this.updateEditorBorderColor(); @@ -5064,6 +5067,7 @@ export class InteractiveMode { initialSearchInput, (model) => selectModel(model, true), defaultProvider && defaultModel ? { provider: defaultProvider, id: defaultModel } : undefined, + this.settingsManager.getRecentModels(), ); return { component: selector, diff --git a/apps/cli/src/ui/view/dialogs/model-selector.ts b/apps/cli/src/ui/view/dialogs/model-selector.ts index d16267e6..75e6f8c5 100644 --- a/apps/cli/src/ui/view/dialogs/model-selector.ts +++ b/apps/cli/src/ui/view/dialogs/model-selector.ts @@ -66,6 +66,8 @@ export class ModelSelectorComponent extends Container implements Focusable { private scopedModels: ReadonlyArray; private defaultModel?: DefaultModelReference; private scope: ModelScope = "all"; + private readonly recentModels: ReadonlyArray; + private recentModelCount = 0; private scopeText?: Text; private scopeHintText?: Text; private readonly refreshAbortController = new AbortController(); @@ -82,6 +84,7 @@ export class ModelSelectorComponent extends Container implements Focusable { initialSearchInput?: string, onSelectAsDefault?: (model: Model) => void, defaultModel?: DefaultModelReference, + recentModels: ReadonlyArray = [], ) { super(); @@ -90,6 +93,7 @@ export class ModelSelectorComponent extends Container implements Focusable { this.modelRuntime = modelRuntime; this.scopedModels = scopedModels; this.defaultModel = defaultModel; + this.recentModels = recentModels; this.scope = scopedModels.length > 0 ? "scoped" : "all"; this.onSelectCallback = onSelect; this.onSelectAsDefaultCallback = onSelectAsDefault; @@ -166,7 +170,7 @@ export class ModelSelectorComponent extends Container implements Focusable { id: scoped.model.id, model: scoped.model, })); - this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels; + this.updateActiveModels(); this.filteredModels = this.activeModels; const currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model)); this.selectedIndex = @@ -259,7 +263,7 @@ export class ModelSelectorComponent extends Container implements Focusable { private setScope(scope: ModelScope): void { if (this.scope === scope) return; this.scope = scope; - this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels; + this.updateActiveModels(); const currentIndex = this.activeModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model)); this.selectedIndex = currentIndex >= 0 ? currentIndex : 0; this.filterModels(this.searchInput.getValue()); @@ -269,13 +273,15 @@ export class ModelSelectorComponent extends Container implements Focusable { } private filterModels(query: string): void { + query = query.trim(); if (query) { - const filtered = fuzzyFilter(this.activeModels, query, (item) => { + const models = this.scope === "scoped" ? this.scopedModelItems : this.allModels; + const filtered = fuzzyFilter(models, query, (item) => { const defaultText = this.isDefaultModel(item.model) ? " default" : ""; return `${getModelSelectorSearchText({ id: item.id, provider: item.provider, name: item.model.name })}${defaultText}`; }); if (this.isDefaultSearch(query)) { - const defaultItems = this.activeModels.filter((item) => this.isDefaultModel(item.model)); + const defaultItems = models.filter((item) => this.isDefaultModel(item.model)); const defaultKeys = new Set(defaultItems.map((item) => `${item.provider}\0${item.id}`)); this.filteredModels = [ ...defaultItems, @@ -294,6 +300,22 @@ export class ModelSelectorComponent extends Container implements Focusable { this.updateList(); } + private updateActiveModels(): void { + const models = this.scope === "scoped" ? this.scopedModelItems : this.allModels; + const available = new Map(models.map((item) => [`${item.provider}\0${item.id}`, item])); + const recent: ModelItem[] = []; + for (const reference of this.recentModels) { + const key = `${reference.provider}\0${reference.id}`; + const item = available.get(key); + if (!item) continue; + recent.push(item); + available.delete(key); + if (recent.length === 10) break; + } + this.recentModelCount = recent.length; + this.activeModels = [...recent, ...models.filter((item) => available.has(`${item.provider}\0${item.id}`))]; + } + private updateList(): void { this.listContainer.clear(); @@ -303,11 +325,18 @@ export class ModelSelectorComponent extends Container implements Focusable { Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible), ); const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length); + const showSections = !this.searchInput.getValue().trim() && this.recentModelCount > 0; // Show visible slice of filtered models for (let i = startIndex; i < endIndex; i++) { const item = this.filteredModels[i]; if (!item) continue; + if (showSections && (i === startIndex || i === this.recentModelCount)) { + if (i > startIndex) this.listContainer.addChild(new Spacer(1)); + const heading = + i < this.recentModelCount ? "Recent" : this.scope === "scoped" ? "Scoped models" : "All models"; + this.listContainer.addChild(new Text(theme.bold(theme.fg("accent", ` ${heading}`)), 0, 0)); + } const isSelected = i === this.selectedIndex; const isCurrent = modelsAreEqual(this.currentModel, item.model); diff --git a/apps/cli/test/model-selector.test.ts b/apps/cli/test/model-selector.test.ts index a6e29bee..04c9a0a9 100644 --- a/apps/cli/test/model-selector.test.ts +++ b/apps/cli/test/model-selector.test.ts @@ -1,9 +1,10 @@ -import type { TUI } from "@step-harness/pi-tui"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { ModelSelectorComponent } from "../src/ui/view/dialogs/model-selector.ts"; +import { setKeybindings, type TUI } from "@step-harness/pi-tui"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../../../packages/coding-agent/src/core/keybindings.ts"; import { initTheme } from "../../../packages/coding-agent/src/theme/theme.ts"; import { stripAnsi } from "../../../packages/coding-agent/src/utils/ansi.ts"; import { createHarness, type Harness } from "../../../packages/coding-agent/test/suite/harness.ts"; +import { ModelSelectorComponent } from "../src/ui/view/dialogs/model-selector.ts"; function createFakeTui(): TUI { return { requestRender: () => {} } as unknown as TUI; @@ -11,12 +12,16 @@ function createFakeTui(): TUI { describe("model selector", () => { let harness: Harness | undefined; + let selector: ModelSelectorComponent | undefined; beforeAll(() => { initTheme("dark"); }); + beforeEach(() => setKeybindings(new KeybindingsManager())); afterEach(() => { + selector?.dispose(); + selector = undefined; harness?.cleanup(); harness = undefined; }); @@ -31,7 +36,7 @@ describe("model selector", () => { ]), }); - const selector = new ModelSelectorComponent( + selector = new ModelSelectorComponent( createFakeTui(), harness.getModel(), harness.session.modelRuntime, @@ -41,8 +46,120 @@ describe("model selector", () => { ); await vi.waitFor(() => { - const rendered = stripAnsi(selector.render(120).join("\n")); + const rendered = stripAnsi(selector!.render(120).join("\n")); expect(rendered).toContain("Could not refresh 2 model catalogs (openai, anthropic); showing cached models."); }); }); + + it("shows recent models first once, with current/default badges and keyboard selection", async () => { + harness = await createHarness({ + models: [{ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta" }, { id: "gamma", name: "Gamma" }], + }); + const [alpha, beta, gamma] = harness.models; + const onSelect = vi.fn(); + selector = new ModelSelectorComponent( + createFakeTui(), beta, harness.session.modelRuntime, [], onSelect, () => {}, + undefined, undefined, beta, [gamma, beta, gamma, { provider: "missing", id: "missing" }], + ); + await vi.waitFor(() => expect(stripAnsi(selector!.render(120).join("\n"))).toContain("Model catalogs refreshed.")); + const rendered = stripAnsi(selector.render(120).join("\n")); + const rows = rendered.split("\n").filter((line) => line.includes(`[${alpha.provider}]`)); + expect(rows.map((line) => line.trim().replace(/^→\s*/, "").split(" [")[0])).toEqual([gamma.id, beta.id, alpha.id]); + expect(rendered).toContain("Recent"); + expect(rendered).toContain("All models"); + expect(rendered).toContain(`→ ${beta.id} [${beta.provider}] · default ✓`); + expect(rendered).not.toContain("missing"); + selector.handleInput("\x1b[A"); + selector.handleInput("\r"); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: gamma.id, provider: gamma.provider })); + }); + + it("hides sections while searching, preserves default search, and restores recents when cleared", async () => { + harness = await createHarness({ + models: [{ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta" }], + }); + const [alpha, beta] = harness.models; + const onDefault = vi.fn(); + selector = new ModelSelectorComponent( + createFakeTui(), alpha, harness.session.modelRuntime, [], () => {}, () => {}, + undefined, onDefault, beta, [beta], + ); + for (const char of "default") selector.handleInput(char); + const searched = stripAnsi(selector.render(120).join("\n")); + expect(searched).not.toContain("Recent"); + expect(searched).not.toContain("All models"); + expect(searched.split("\n").filter((line) => line.includes(`[${beta.provider}]`))).toHaveLength(1); + expect(searched).toContain(`→ ${beta.id}`); + for (const char of "default") selector.handleInput("\x7f"); + expect(stripAnsi(selector.render(120).join("\n"))).toContain("Recent"); + selector.handleInput("\x13"); + expect(onDefault).toHaveBeenCalledWith(expect.objectContaining({ id: beta.id })); + }); + + it("filters recent models to the active scope and restores them in All", async () => { + harness = await createHarness({ + models: [{ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta" }, { id: "gamma", name: "Gamma" }], + }); + const [alpha, beta, gamma] = harness.models; + selector = new ModelSelectorComponent( + createFakeTui(), beta, harness.session.modelRuntime, [{ model: beta }, { model: alpha }], + () => {}, () => {}, undefined, undefined, undefined, [gamma, alpha], + ); + const scoped = stripAnsi(selector.render(120).join("\n")); + expect(scoped).toContain("Recent"); + expect(scoped).toContain("Scoped models"); + expect(scoped).not.toContain(gamma.id); + selector.handleInput("\t"); + expect(stripAnsi(selector.render(120).join("\n"))).toContain(gamma.id); + selector.handleInput("\x1b[A"); + selector.handleInput("\x1b[A"); + expect(stripAnsi(selector.render(120).join("\n"))).toContain(`→ ${gamma.id}`); + }); + + it("does not show an empty Recent section", async () => { + harness = await createHarness(); + selector = new ModelSelectorComponent( + createFakeTui(), harness.getModel(), harness.session.modelRuntime, [], () => {}, () => {}, + undefined, undefined, undefined, [{ provider: "missing", id: "missing" }], + ); + expect(stripAnsi(selector.render(120).join("\n"))).not.toContain("Recent"); + }); + + it("keeps identical model IDs from different providers distinct", async () => { + harness = await createHarness({ models: [{ id: "shared", name: "Shared" }] }); + const model = harness.getModel(); + const other = { ...model, provider: "other" }; + vi.spyOn(harness.session.modelRuntime, "getAvailableSnapshot").mockReturnValue([model, other]); + const onSelect = vi.fn(); + selector = new ModelSelectorComponent( + createFakeTui(), model, harness.session.modelRuntime, [], onSelect, () => {}, + undefined, undefined, undefined, [other, model], + ); + const rows = stripAnsi(selector.render(120).join("\n")) + .split("\n") + .filter((line) => line.includes(`${model.id} [`)); + expect(rows).toHaveLength(2); + expect(rows[0]).toContain("[other]"); + selector.handleInput("\x1b[A"); + selector.handleInput("\r"); + expect(onSelect).toHaveBeenCalledWith(other); + }); + + it("scrolls across the Recent boundary without selecting headings", async () => { + harness = await createHarness({ + models: Array.from({ length: 15 }, (_, i) => ({ id: `model-${i}`, name: `Model ${i}` })), + }); + const onSelect = vi.fn(); + selector = new ModelSelectorComponent( + createFakeTui(), harness.models[0], harness.session.modelRuntime, [], onSelect, () => {}, + undefined, undefined, undefined, harness.models.slice(0, 10), + ); + for (let i = 0; i < 10; i++) selector.handleInput("\x1b[B"); + const rendered = stripAnsi(selector.render(120).join("\n")); + expect(rendered).toContain("Recent"); + expect(rendered).toContain("All models"); + expect(rendered).toContain("(11/15)"); + selector.handleInput("\r"); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "model-10" })); + }); }); diff --git a/docs/recent-models.md b/docs/recent-models.md new file mode 100644 index 00000000..87079d48 --- /dev/null +++ b/docs/recent-models.md @@ -0,0 +1,16 @@ +# Recent models + +The `/model` selector shows a **Recent** section above the existing model list when the search is empty and history contains models available in the active scope. Recent rows are removed from the remaining list. Search hides the sections and searches the original list once, preserving fuzzy ranking and default-model search. + +Successful interactive selections (Enter, Ctrl+S, exact `/model provider/id`, and model cycling) record a provider/id pair. Selecting the same pair again moves it to the front. The history keeps at most ten distinct pairs; identical IDs from different providers remain separate. Cancelling or a failed selection does not record history. Startup and session restoration do not rewrite it. + +History is stored as the global settings field `recentModels`, using Step's existing `config.toml` storage adapter (or the embedded host's settings storage). It is global-only, is validated when read, and concurrent sessions merge selections under the existing storage lock. No credentials or model objects are stored. Recent history does not change startup defaults or model cycling order. With `STEPCODE_CONFIG_PATH`, default selection still writes the external active model; history uses the wrapped global settings storage. + +The selector only receives history references. Settings persistence stays in `packages/coding-agent`; `packages/tui` is unchanged. Unavailable references are hidden rather than deleting offline history. Scoped mode filters Recent to scoped models and preserves the order of the remaining scoped models. + +## Reference + +Behavior was checked against OpenCode v1.2.0, not copied into StepCode: + +- [Model dialog](https://github.com/anomalyco/opencode/blob/v1.2.0/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx): Recent section, unavailable-model filtering, search without duplicated rows. +- [Local model state](https://github.com/anomalyco/opencode/blob/v1.2.0/packages/opencode/src/cli/cmd/tui/context/local.tsx): provider/model identity, move-to-front history, ten-entry limit. diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 99e09c0c..0dcb44f4 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -102,6 +102,7 @@ export interface Settings { lastChangelogVersion?: string; defaultProvider?: string; defaultModel?: string; + recentModels?: { provider: string; id: string }[]; defaultThinkingLevel?: ThinkingLevel; modelThinkingLevels?: Record; // per-model default thinking level overrides keyed by "provider/modelId" transport?: TransportSetting; // default: "auto" @@ -193,6 +194,23 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde export type SettingsScope = "global" | "project"; +function normalizeRecentModels(value: unknown): { provider: string; id: string }[] { + if (!Array.isArray(value)) return []; + const result: { provider: string; id: string }[] = []; + const seen = new Set(); + for (const entry of value) { + if (!isMergeableObject(entry)) continue; + const { provider, id } = entry; + if (typeof provider !== "string" || !provider.trim() || typeof id !== "string" || !id.trim()) continue; + const key = `${provider}\0${id}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ provider, id }); + if (result.length === 10) break; + } + return result; +} + export interface SettingsManagerCreateOptions { projectTrusted?: boolean; /** Project resource directory name. Defaults to Pi's configured value. */ @@ -738,6 +756,26 @@ export class SettingsManager { return this.settings.defaultModel; } + getRecentModels(): { provider: string; id: string }[] { + return normalizeRecentModels(this.globalSettings.recentModels); + } + + recordRecentModel(provider: string, id: string): void { + const model = { provider, id }; + this.globalSettings.recentModels = normalizeRecentModels([model, ...this.getRecentModels()]); + if (this.globalSettingsLoadError) return; + this.enqueueWrite("global", () => { + this.storage.withLock("global", (current) => { + const settings = current + ? SettingsManager.migrateSettings(JSON.parse(stripBom(current)) as Record) + : {}; + // Merge under the lock so another session's selections are not lost. + settings.recentModels = normalizeRecentModels([model, ...normalizeRecentModels(settings.recentModels)]); + return JSON.stringify(settings, null, 2); + }); + }); + } + setDefaultProvider(provider: string): void { this.globalSettings.defaultProvider = provider; this.markModified("defaultProvider"); diff --git a/packages/coding-agent/test/recent-models.test.ts b/packages/coding-agent/test/recent-models.test.ts new file mode 100644 index 00000000..ef0dc136 --- /dev/null +++ b/packages/coding-agent/test/recent-models.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts"; +import { readStepConfig, writeStepConfig } from "../src/step/config-toml.ts"; +import { createStepSettingsManager } from "../src/step/settings-manager.ts"; +import { decorateStepCodeSettingsManager, loadStepCodeConfig } from "../src/step/stepcode-config.ts"; + +describe("recent models", () => { + let directory: string | undefined; + afterEach(() => { + if (directory) rmSync(directory, { recursive: true, force: true }); + directory = undefined; + }); + + it("persists the ten most recent provider/model pairs without changing defaults", async () => { + const storage = new InMemorySettingsStorage(); + const manager = SettingsManager.fromStorage(storage); + manager.setDefaultModelAndProvider("default", "startup"); + for (let i = 0; i < 12; i++) manager.recordRecentModel("provider", `model-${i}`); + manager.recordRecentModel("other", "model-11"); + manager.recordRecentModel("provider", "model-10"); + await manager.flush(); + const restarted = SettingsManager.fromStorage(storage); + expect(restarted.getRecentModels()).toEqual([ + { provider: "provider", id: "model-10" }, + { provider: "other", id: "model-11" }, + { provider: "provider", id: "model-11" }, + ...Array.from({ length: 7 }, (_, i) => ({ provider: "provider", id: `model-${9 - i}` })), + ]); + expect(restarted.getDefaultProvider()).toBe("default"); + expect(restarted.getDefaultModel()).toBe("startup"); + }); + + it("merges selections from concurrent managers under the storage lock", async () => { + const storage = new InMemorySettingsStorage(); + const first = SettingsManager.fromStorage(storage); + const second = SettingsManager.fromStorage(storage); + first.recordRecentModel("provider", "first"); + await first.flush(); + second.recordRecentModel("provider", "second"); + await second.flush(); + expect(SettingsManager.fromStorage(storage).getRecentModels()).toEqual([ + { provider: "provider", id: "second" }, + { provider: "provider", id: "first" }, + ]); + }); + + it("ignores malformed history, deduplicates it, and returns a defensive copy", () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => + JSON.stringify({ + recentModels: [null, {}, { provider: "p", id: "m" }, { provider: "p", id: "m" }, { provider: "p", id: 1 }], + }), + ); + const manager = SettingsManager.fromStorage(storage); + const recent = manager.getRecentModels(); + expect(recent).toEqual([{ provider: "p", id: "m" }]); + recent[0].id = "changed"; + expect(manager.getRecentModels()).toEqual([{ provider: "p", id: "m" }]); + }); + + it("persists global history through the Step TOML decorator without altering other configuration", async () => { + directory = mkdtempSync(join(tmpdir(), "recent-models-")); + const paths = { global: join(directory, "global.toml"), project: join(directory, "project.toml") }; + const original = { + defaultProvider: "startup", + defaultModel: "default", + mcp_servers: { local: { command: "local-server" } }, + permissionPreset: "ask", + }; + writeStepConfig(paths.global, original); + writeStepConfig(paths.project, { recentModels: [{ provider: "project", id: "ignored" }] }); + const first = createStepSettingsManager(directory, undefined, { paths }); + const second = createStepSettingsManager(directory, undefined, { paths }); + first.recordRecentModel("provider", "first"); + await first.flush(); + second.recordRecentModel("provider", "second"); + second.setTheme("dark"); + await second.flush(); + const restarted = createStepSettingsManager(directory, undefined, { paths }); + expect(restarted.getRecentModels()).toEqual([ + { provider: "provider", id: "second" }, + { provider: "provider", id: "first" }, + ]); + expect(readStepConfig(paths.global)).toMatchObject(original); + expect(readStepConfig(paths.project)).toEqual({ recentModels: [{ provider: "project", id: "ignored" }] }); + expect([...first.drainErrors(), ...second.drainErrors(), ...restarted.drainErrors()]).toEqual([]); + }); + + it("keeps external active-model defaults separate from wrapped global history", async () => { + directory = mkdtempSync(join(tmpdir(), "recent-models-")); + const path = join(directory, "external.json"); + writeFileSync( + path, + JSON.stringify({ + activeModel: "alpha", + providers: { + custom: { + api: "openai-completions", + baseUrl: "https://example.invalid/v1", + models: [{ id: "alpha" }, { id: "beta" }], + }, + }, + }), + ); + const config = await loadStepCodeConfig({ STEPCODE_CONFIG_PATH: path }); + const paths = { global: join(directory, "global.toml"), project: join(directory, "project.toml") }; + const manager = decorateStepCodeSettingsManager( + createStepSettingsManager(directory, undefined, { paths }), + config!, + ); + manager.recordRecentModel("custom", "alpha"); + manager.setDefaultModelAndProvider("custom", "beta"); + manager.recordRecentModel("custom", "beta"); + await manager.flush(); + expect(manager.getDefaultModel()).toBe("beta"); + expect(JSON.parse(readFileSync(path, "utf8"))).toMatchObject({ activeModel: "beta" }); + expect(JSON.parse(readFileSync(path, "utf8"))).not.toHaveProperty("recentModels"); + expect(createStepSettingsManager(directory, undefined, { paths }).getRecentModels()).toEqual([ + { provider: "custom", id: "beta" }, + { provider: "custom", id: "alpha" }, + ]); + expect(manager.drainErrors()).toEqual([]); + }); +});