81bc0d4368
Disabling the Advisor from /settings persisted the setting but left the live Advisor runtime running until the session restarted. SelectorController.handleSettingChange had no case for "advisor.enabled", unlike other session-managed toggles (autoCompact, steeringMode, ...), so the change never reached session.setAdvisorEnabled — the same call /advisor off already uses to stop the runtime immediately.
1669 lines
63 KiB
TypeScript
1669 lines
63 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { stripVTControlCharacters } from "node:util";
|
|
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
|
|
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
|
|
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
|
|
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";
|
|
import type { ResolvedRoleModel } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking";
|
|
import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
|
|
import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state";
|
|
|
|
let settingsState: SettingsTestState | undefined;
|
|
|
|
beforeEach(async () => {
|
|
settingsState = beginSettingsTest();
|
|
await Settings.init({ inMemory: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreSettingsTestState(settingsState);
|
|
settingsState = undefined;
|
|
});
|
|
|
|
describe("selector setting side effects", () => {
|
|
it("refreshes the status line when git integration changes at runtime", () => {
|
|
const updateSettings = vi.fn();
|
|
const requestRender = vi.fn();
|
|
const controller = new SelectorController({
|
|
statusLine: { updateSettings },
|
|
ui: { requestRender },
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
Settings.instance.override("git.enabled", false);
|
|
controller.handleSettingChange("git.enabled", false);
|
|
|
|
expect(updateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
preset: Settings.instance.get("statusLine.preset"),
|
|
leftSegments: Settings.instance.get("statusLine.leftSegments"),
|
|
rightSegments: Settings.instance.get("statusLine.rightSegments"),
|
|
}),
|
|
);
|
|
// The setting-change side effect is a single render request — the lazy
|
|
// top-border provider rebuilds during paint (#4145).
|
|
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("invalidates the UI and requests a repaint when tui.tight changes", () => {
|
|
const invalidate = vi.fn();
|
|
const requestRender = vi.fn();
|
|
const controller = new SelectorController({
|
|
ui: { invalidate, requestRender },
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.handleSettingChange("tui.tight", true);
|
|
|
|
expect(invalidate).toHaveBeenCalledTimes(1);
|
|
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("applies memory backend changes to the live session", () => {
|
|
const applyMemoryBackend = vi.fn(async () => {});
|
|
const controller = new SelectorController({
|
|
session: { applyMemoryBackend },
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.handleSettingChange("memory.backend", "mnemopi");
|
|
|
|
expect(applyMemoryBackend).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("stops the live advisor runtime when advisor.enabled is turned off in /settings", () => {
|
|
const setAdvisorEnabled = vi.fn();
|
|
const invalidate = vi.fn();
|
|
const requestRender = vi.fn();
|
|
const controller = new SelectorController({
|
|
session: { setAdvisorEnabled },
|
|
statusLine: { invalidate },
|
|
ui: { requestRender },
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.handleSettingChange("advisor.enabled", false);
|
|
|
|
expect(setAdvisorEnabled).toHaveBeenCalledWith(false);
|
|
expect(invalidate).toHaveBeenCalledTimes(1);
|
|
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
for (const id of ["terminal.showImages", "showImages"]) {
|
|
for (const visible of [false, true]) {
|
|
it(`updates every image owner and rebuilds the transcript when ${id}=${visible}`, () => {
|
|
const setShowImages = vi.fn();
|
|
const setImagesVisible = vi.fn();
|
|
const clearInlineImages = vi.fn();
|
|
const resetDisplay = vi.fn();
|
|
const tool = Object.create(ToolExecutionComponent.prototype) as ToolExecutionComponent;
|
|
tool.setShowImages = setShowImages;
|
|
const assistant = Object.create(AssistantMessageComponent.prototype) as AssistantMessageComponent;
|
|
assistant.setImagesVisible = setImagesVisible;
|
|
const controller = new SelectorController({
|
|
chatContainer: { children: [tool, assistant] },
|
|
ui: { clearInlineImages, resetDisplay },
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.handleSettingChange(id, visible);
|
|
|
|
expect(setShowImages).toHaveBeenCalledWith(visible);
|
|
expect(setImagesVisible).toHaveBeenCalledWith(visible);
|
|
expect(clearInlineImages).toHaveBeenCalledTimes(visible ? 0 : 1);
|
|
expect(resetDisplay).toHaveBeenCalledTimes(1);
|
|
if (!visible) {
|
|
expect(clearInlineImages.mock.invocationCallOrder[0]).toBeLessThan(
|
|
resetDisplay.mock.invocationCallOrder[0],
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
it("clears stale default role thinking when auto is selected", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const previousModel = getBundledModel("openai", "gpt-5.5");
|
|
const nextModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!previousModel || !nextModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const settings = Settings.isolated({
|
|
defaultThinkingLevel: ThinkingLevel.High,
|
|
modelRoles: { default: `${previousModel.provider}/${previousModel.id}:high` },
|
|
});
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const autoApplied = Promise.withResolvers<void>();
|
|
const setThinkingLevel = vi.fn((level: ThinkingLevel | typeof AUTO_THINKING, persist: boolean) => {
|
|
if (level === AUTO_THINKING && persist) {
|
|
settings.set("defaultThinkingLevel", level);
|
|
autoApplied.resolve();
|
|
}
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: nextModel,
|
|
modelRegistry: {
|
|
getAll: () => [previousModel, nextModel],
|
|
getAvailable: () => [previousModel, nextModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: nextModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel,
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus: vi.fn(),
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as
|
|
| { handleInput(data: string): void; render(width: number): string[]; dispose(): void }
|
|
| undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows.
|
|
hub.handleInput("\n"); // Assign DEFAULT.
|
|
hub.handleInput("\n"); // Pick the scoped replacement model.
|
|
|
|
const levels = [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(nextModel)];
|
|
const highIndex = levels.indexOf(ThinkingLevel.High);
|
|
const autoIndex = levels.indexOf(AUTO_THINKING);
|
|
if (highIndex < autoIndex) throw new Error("Expected auto before high in the thinking strip");
|
|
for (let i = autoIndex; i < highIndex; i++) hub.handleInput("\x1b[D");
|
|
hub.handleInput("\n");
|
|
await autoApplied.promise;
|
|
|
|
expect(setModel).toHaveBeenLastCalledWith(
|
|
nextModel,
|
|
"default",
|
|
expect.objectContaining({
|
|
thinkingLevel: ThinkingLevel.Inherit,
|
|
persist: true,
|
|
}),
|
|
);
|
|
expect(setThinkingLevel).toHaveBeenLastCalledWith(AUTO_THINKING, true);
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
it("routes project default assignments without persisting the global role", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const model = getBundledModel("openai", "gpt-5.6");
|
|
if (!model) throw new Error("Expected bundled OpenAI model for selector test");
|
|
const settings = Settings.isolated({ modelRoleStorage: "project" });
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const assignmentApplied = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.startsWith("Project default model:")) assignmentApplied.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model,
|
|
modelRegistry: {
|
|
getAll: () => [model],
|
|
getAvailable: () => [model],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows.
|
|
hub.handleInput("\n"); // Assign DEFAULT.
|
|
hub.handleInput("\n"); // Pick the scoped model.
|
|
hub.handleInput("\n"); // Save the assignment to the project.
|
|
await assignmentApplied.promise;
|
|
|
|
expect(setModel).toHaveBeenCalledWith(
|
|
model,
|
|
"default",
|
|
expect.objectContaining({
|
|
thinkingLevel: ThinkingLevel.Inherit,
|
|
persist: false,
|
|
}),
|
|
);
|
|
expect(settings.getProjectModelRole("default")).toBe(`${model.provider}/${model.id}`);
|
|
expect(settings.getGlobalModelRole("default")).toBeUndefined();
|
|
expect(showStatus).toHaveBeenCalledWith(`Project default model: ${model.provider}/${model.id}`);
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("edits a shadowed global default without switching the live project session", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const globalSelector = `${globalModel.provider}/${globalModel.id}`;
|
|
const settings = Settings.isolated({ modelRoleStorage: "project" });
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const assignmentApplied = Promise.withResolvers<void>();
|
|
const capturedRuntimeAssignmentApplied = Promise.withResolvers<void>();
|
|
let globalStatusCount = 0;
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (!message.startsWith("Global default model:")) return;
|
|
globalStatusCount++;
|
|
if (globalStatusCount === 1) assignmentApplied.resolve();
|
|
if (globalStatusCount === 2) capturedRuntimeAssignmentApplied.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel],
|
|
getAvailable: () => [projectModel, globalModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows.
|
|
hub.handleInput("\n"); // Assign DEFAULT.
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[B"); // Effective project model → new global fallback.
|
|
hub.handleInput("\n"); // Pick the global fallback model.
|
|
hub.handleInput("\x1b[B"); // Project scope → global scope.
|
|
hub.handleInput("\n");
|
|
await assignmentApplied.promise;
|
|
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
expect(settings.getGlobalModelRole("default")).toBe(globalSelector);
|
|
expect(settings.getProjectModelRole("default")).toBe(projectSelector);
|
|
expect(showStatus).toHaveBeenCalledWith(`Global default model: ${globalSelector}`);
|
|
|
|
settings.overrideModelRoles({ default: globalSelector });
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
expect(settings.getModelRoleProvenance("default")).toBe("runtime");
|
|
expect(settings.isProjectModelRoleRuntimeOverrideActive("default")).toBe(true);
|
|
|
|
hub.handleInput("\x1b"); // Thinking strip → Roles.
|
|
hub.handleInput("\n"); // Assign DEFAULT again.
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[B"); // Effective project model → new global fallback.
|
|
hub.handleInput("\n"); // Pick the global fallback model.
|
|
hub.handleInput("\x1b[B"); // Project scope → global scope.
|
|
hub.handleInput("\n");
|
|
await capturedRuntimeAssignmentApplied.promise;
|
|
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
expect(settings.getGlobalModelRole("default")).toBe(globalSelector);
|
|
expect(settings.getModelRole("default")).toBe(projectSelector);
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("switches the live session when a global edit replaces a runtime override in project mode", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const globalSelector = `${globalModel.provider}/${globalModel.id}`;
|
|
const settings = Settings.isolated({ modelRoleStorage: "project" });
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
// Simulate a CLI --model override: runtime override distinct from the project value.
|
|
settings.overrideModelRoles({ default: `anthropic/claude-sonnet-4-5` });
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const assignmentApplied = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.startsWith("Global default model:")) assignmentApplied.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel],
|
|
getAvailable: () => [projectModel, globalModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows.
|
|
hub.handleInput("\n"); // Assign DEFAULT.
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[B"); // Effective project model → new global fallback.
|
|
hub.handleInput("\n"); // Pick the global fallback model.
|
|
hub.handleInput("\x1b[B"); // Project scope → global scope.
|
|
hub.handleInput("\n");
|
|
await assignmentApplied.promise;
|
|
|
|
// The runtime override makes the global edit effective, so the live
|
|
// session must switch to the newly assigned global model.
|
|
expect(setModel).toHaveBeenCalledWith(globalModel, "default", expect.objectContaining({ persist: true }));
|
|
expect(settings.getProjectModelRole("default")).toBe(projectSelector);
|
|
expect(showStatus).toHaveBeenCalledWith(`Global default model: ${globalSelector}`);
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("switches a global edit when a byte-identical startup runtime override shadows the project default", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const globalSelector = `${globalModel.provider}/${globalModel.id}`;
|
|
const testDir = path.join(os.tmpdir(), `selector-runtime-identical-${Snowflake.next()}`);
|
|
const projectDir = path.join(testDir, "project");
|
|
fs.mkdirSync(path.join(projectDir, ".omp"), { recursive: true });
|
|
fs.writeFileSync(path.join(projectDir, ".omp", "config.yml"), `modelRoles:\n default: ${projectSelector}\n`);
|
|
|
|
try {
|
|
const settings = await Settings.loadIsolated({
|
|
cwd: projectDir,
|
|
agentDir: testDir,
|
|
inMemory: true,
|
|
overrides: {
|
|
modelRoleStorage: "project",
|
|
modelRoles: { default: projectSelector },
|
|
},
|
|
});
|
|
expect(settings.getProjectModelRole("default")).toBe(projectSelector);
|
|
expect(settings.getModelRole("default")).toBe(projectSelector);
|
|
expect(settings.getModelRoleProvenance("default")).toBe("runtime");
|
|
|
|
let liveModel = projectModel;
|
|
const setModel = vi.fn(async () => {
|
|
liveModel = globalModel;
|
|
settings.setModelRole("default", globalSelector);
|
|
return { switched: true };
|
|
});
|
|
const assignmentApplied = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.startsWith("Global default model:")) assignmentApplied.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: liveModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel],
|
|
getAvailable: () => [projectModel, globalModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows.
|
|
hub.handleInput("\n"); // Assign DEFAULT.
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[B"); // Effective project model → new global default.
|
|
hub.handleInput("\n"); // Pick the global model.
|
|
hub.handleInput("\x1b[B"); // Project scope → global scope.
|
|
hub.handleInput("\n");
|
|
await assignmentApplied.promise;
|
|
|
|
expect(setModel).toHaveBeenCalledWith(globalModel, "default", expect.objectContaining({ persist: true }));
|
|
expect(liveModel).toBe(globalModel);
|
|
expect(settings.getGlobalModelRole("default")).toBe(globalSelector);
|
|
expect(settings.getProjectModelRole("default")).toBe(projectSelector);
|
|
expect(settings.getModelRole("default")).toBe(globalSelector);
|
|
expect(settings.getModelRoleProvenance("default")).toBe("runtime");
|
|
expect(showStatus).toHaveBeenCalledWith(`Global default model: ${globalSelector}`);
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
} finally {
|
|
if (fs.existsSync(testDir)) removeSyncWithRetries(testDir);
|
|
}
|
|
});
|
|
|
|
it("persists project and global defaults shadowed by a config overlay without switching", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const overlayModel = getBundledModel("openai", "gpt-5.5");
|
|
const projectModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!overlayModel || !projectModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const overlaySelector = `${overlayModel.provider}/${overlayModel.id}`;
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const testDir = path.join(os.tmpdir(), `selector-overlay-assignment-${Snowflake.next()}`);
|
|
const projectDir = path.join(testDir, "project");
|
|
const overlayPath = path.join(testDir, "overlay.yml");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
fs.writeFileSync(overlayPath, `modelRoles:\n default: ${overlaySelector}\n`);
|
|
|
|
try {
|
|
const settings = await Settings.loadIsolated({
|
|
cwd: projectDir,
|
|
agentDir: testDir,
|
|
configFiles: [overlayPath],
|
|
overrides: { modelRoleStorage: "project" },
|
|
});
|
|
expect(settings.getModelRole("default")).toBe(overlaySelector);
|
|
expect(settings.getModelRoleProvenance("default")).toBe("overlay");
|
|
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const projectAssignmentApplied = Promise.withResolvers<void>();
|
|
const autoApplied = Promise.withResolvers<void>();
|
|
const globalAssignmentApplied = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.startsWith("Project default model:")) projectAssignmentApplied.resolve();
|
|
if (
|
|
message.startsWith("Project default model:") &&
|
|
settings.get("defaultThinkingLevel") === AUTO_THINKING
|
|
) {
|
|
autoApplied.resolve();
|
|
}
|
|
if (message.startsWith("Global default model:")) globalAssignmentApplied.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: overlayModel,
|
|
modelRegistry: {
|
|
getAll: () => [overlayModel, projectModel],
|
|
getAvailable: () => [overlayModel, projectModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: overlayModel }, { model: projectModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows.
|
|
hub.handleInput("\n"); // Assign DEFAULT.
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[B"); // Overlay model → hidden project default.
|
|
hub.handleInput("\n"); // Pick the project model.
|
|
hub.handleInput("\n"); // Save to project scope.
|
|
await projectAssignmentApplied.promise;
|
|
hub.handleInput("\x1b[C"); // Inherit → off.
|
|
hub.handleInput("\x1b[C"); // Off → auto.
|
|
hub.handleInput("\n");
|
|
await autoApplied.promise;
|
|
expect(settings.get("defaultThinkingLevel")).toBe(AUTO_THINKING);
|
|
await settings.flush();
|
|
|
|
expect(settings.getProjectModelRole("default")).toBe(projectSelector);
|
|
expect(settings.getGlobalModelRole("default")).toBeUndefined();
|
|
expect(settings.getModelRole("default")).toBe(overlaySelector);
|
|
expect(settings.getModelRoleProvenance("default")).toBe("overlay");
|
|
expect(await Bun.file(path.join(projectDir, ".omp", "config.yml")).text()).toContain(
|
|
`default: ${projectSelector}`,
|
|
);
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
expect(showStatus).toHaveBeenCalledWith(`Project default model: ${projectSelector}`);
|
|
|
|
hub.handleInput("\x1b"); // Thinking strip → Roles.
|
|
hub.handleInput("\n"); // Assign DEFAULT again.
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[B"); // Overlay model → hidden project fallback.
|
|
hub.handleInput("\n"); // Pick the current project fallback.
|
|
hub.handleInput("\x1b[B"); // Project scope → global scope.
|
|
hub.handleInput("\n"); // Save the hidden global fallback.
|
|
await globalAssignmentApplied.promise;
|
|
|
|
expect(settings.getGlobalModelRole("default")).toBe(projectSelector);
|
|
expect(settings.getModelRole("default")).toBe(overlaySelector);
|
|
expect(settings.getModelRoleProvenance("default")).toBe("overlay");
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
} finally {
|
|
if (fs.existsSync(testDir)) removeSyncWithRetries(testDir);
|
|
}
|
|
});
|
|
|
|
it("switches the live default in global mode even when project settings retain an override", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const settings = Settings.isolated({});
|
|
settings.setProjectModelRole("default", `${projectModel.provider}/${projectModel.id}`);
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const assignmentApplied = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.startsWith("Default model:")) assignmentApplied.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel],
|
|
getAvailable: () => [projectModel, globalModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[B"); // Effective project model → new global default.
|
|
hub.handleInput("\n"); // Open the selected model's role strip.
|
|
hub.handleInput("\n"); // Assign DEFAULT in global-only mode.
|
|
await assignmentApplied.promise;
|
|
|
|
expect(setModel).toHaveBeenCalledWith(globalModel, "default", expect.objectContaining({ persist: true }));
|
|
expect(showStatus).toHaveBeenCalledWith(`Default model: ${globalModel.provider}/${globalModel.id}`);
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("replaces malformed default retry fallback chains from the model selector action", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const settings = Settings.isolated({});
|
|
settings.set("retry.fallbackChains", { default: "not-an-array" } as unknown as Record<string, string[]>);
|
|
const fallback = buildModel({
|
|
id: "retry-fallback-model",
|
|
name: "retry-fallback-model",
|
|
api: "ollama-chat",
|
|
baseUrl: "https://example.com",
|
|
reasoning: false,
|
|
provider: "test",
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128_000,
|
|
maxTokens: 1024,
|
|
});
|
|
const showStatus = vi.fn();
|
|
const showError = vi.fn();
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: undefined,
|
|
modelRegistry: {
|
|
getAll: () => [fallback],
|
|
getAvailable: () => [fallback],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: fallback }],
|
|
getContextUsage: () => undefined,
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError,
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as
|
|
| { handleInput(data: string): void; render(width: number): string[]; dispose(): void }
|
|
| undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\n");
|
|
const frame = stripVTControlCharacters(hub.render(220).join("\n"));
|
|
expect(frame).toContain("retry-fallback");
|
|
hub.handleInput("\x1b[D");
|
|
hub.handleInput("\n");
|
|
await Promise.resolve();
|
|
|
|
expect(showError).not.toHaveBeenCalled();
|
|
expect(settings.get("retry.fallbackChains")).toEqual({ default: ["test/retry-fallback-model"] });
|
|
expect(showStatus).toHaveBeenCalledWith("DEFAULT fallbacks: test/retry-fallback-model");
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("applies an @ quick role through the role-switch session API", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for quick-role picker test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const smol = buildModel({
|
|
id: "smol-model",
|
|
name: "smol-model",
|
|
api: "ollama-chat",
|
|
baseUrl: "https://example.com",
|
|
reasoning: false,
|
|
provider: "test",
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128_000,
|
|
maxTokens: 1024,
|
|
});
|
|
const slow = buildModel({
|
|
id: "slow-model",
|
|
name: "slow-model",
|
|
api: "ollama-chat",
|
|
baseUrl: "https://example.com",
|
|
reasoning: false,
|
|
provider: "test",
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128_000,
|
|
maxTokens: 1024,
|
|
});
|
|
const quickRoles: ResolvedRoleModel[] = [
|
|
{ role: "smol", model: smol, explicitThinkingLevel: false },
|
|
{ role: "slow", model: slow, explicitThinkingLevel: false },
|
|
];
|
|
const applyRoleModel = vi.fn(async () => {});
|
|
const setModelTemporary = vi.fn(async () => {});
|
|
const showModelCycleTrack = vi.fn();
|
|
const showError = vi.fn();
|
|
let picker: { handleInput(data: string): void } | undefined;
|
|
const settings = Settings.isolated({ cycleOrder: ["smol", "slow"] });
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
picker = component as { handleInput(data: string): void };
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: slow,
|
|
modelRegistry: {
|
|
getAll: () => [smol, slow],
|
|
getAvailable: () => [smol, slow],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
},
|
|
scopedModels: [{ model: smol }, { model: slow }],
|
|
getContextUsage: () => undefined,
|
|
getRoleModelCycle: () => ({ models: quickRoles, currentIndex: 1 }),
|
|
applyRoleModel,
|
|
setModelTemporary,
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showModelCycleTrack,
|
|
showError,
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector({ temporaryOnly: true });
|
|
if (!picker) throw new Error("Expected temporary model picker overlay");
|
|
picker.handleInput("@");
|
|
picker.handleInput("\n");
|
|
await Promise.resolve();
|
|
|
|
expect(applyRoleModel).toHaveBeenCalledWith(quickRoles[1]);
|
|
expect(setModelTemporary).not.toHaveBeenCalled();
|
|
expect(showModelCycleTrack).toHaveBeenCalledTimes(1);
|
|
expect(showError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("switches the live session to the global default when the project default is unassigned", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const globalSelector = `${globalModel.provider}/${globalModel.id}`;
|
|
const settings = Settings.isolated({ modelRoleStorage: "project" });
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
settings.setModelRole("default", globalSelector);
|
|
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const setThinkingLevel = vi.fn();
|
|
const statusInvalidate = vi.fn();
|
|
const updateEditorBorderColor = vi.fn();
|
|
const showError = vi.fn();
|
|
const roleCleared = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.includes("role cleared")) roleCleared.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel],
|
|
getAvailable: () => [projectModel, globalModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel,
|
|
},
|
|
statusLine: { invalidate: statusInvalidate },
|
|
updateEditorBorderColor,
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError,
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows (scope → list focus).
|
|
hub.handleInput("\x7f"); // Backspace on DEFAULT to unassign.
|
|
await roleCleared.promise;
|
|
// The async setModel continuation needs a microtask to settle.
|
|
await Promise.resolve();
|
|
|
|
expect(settings.getProjectModelRole("default")).toBeUndefined();
|
|
expect(settings.getGlobalModelRole("default")).toBe(globalSelector);
|
|
expect(setModel).toHaveBeenCalledWith(globalModel, "default", expect.objectContaining({ persist: false }));
|
|
expect(statusInvalidate).toHaveBeenCalled();
|
|
expect(updateEditorBorderColor).toHaveBeenCalled();
|
|
expect(showError).not.toHaveBeenCalled();
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("switches to the project default when clearing a runtime-backed global default", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
const runtimeModel = getBundledModel("openai", "gpt-5.1");
|
|
if (!projectModel || !globalModel || !runtimeModel) {
|
|
throw new Error("Expected bundled OpenAI models for selector test");
|
|
}
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const globalSelector = `${globalModel.provider}/${globalModel.id}`;
|
|
const runtimeSelector = `${runtimeModel.provider}/${runtimeModel.id}`;
|
|
const settings = Settings.isolated({ modelRoleStorage: "project" });
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
settings.setModelRole("default", globalSelector);
|
|
settings.overrideModelRoles({ default: runtimeSelector });
|
|
|
|
const switchCompleted = Promise.withResolvers<void>();
|
|
const setModel = vi.fn(async () => {
|
|
switchCompleted.resolve();
|
|
return { switched: true };
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: runtimeModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel, runtimeModel],
|
|
getAvailable: () => [projectModel, globalModel, runtimeModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }, { model: runtimeModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus: vi.fn(),
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[A"); // Runtime model → global fallback.
|
|
hub.handleInput("\n"); // Open scoped role chips.
|
|
hub.handleInput("\x1b[C"); // Project default → global default.
|
|
hub.handleInput("\n"); // Clear the global default.
|
|
await switchCompleted.promise;
|
|
|
|
expect(settings.getGlobalModelRole("default")).toBeUndefined();
|
|
expect(settings.getProjectModelRole("default")).toBe(projectSelector);
|
|
expect(settings.getModelRole("default")).toBe(projectSelector);
|
|
expect(setModel).toHaveBeenCalledWith(projectModel, "default", expect.objectContaining({ persist: false }));
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("serializes a later default edit behind a pending cleared-project fallback", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const settings = Settings.isolated({ modelRoleStorage: "project" });
|
|
settings.setProjectModelRole("default", `${projectModel.provider}/${projectModel.id}`);
|
|
settings.setModelRole("default", `${globalModel.provider}/${globalModel.id}`);
|
|
|
|
const pendingFallback = Promise.withResolvers<{ switched: boolean }>();
|
|
const supersedingEdit = Promise.withResolvers<{ switched: boolean }>();
|
|
const supersedingEditStarted = Promise.withResolvers<void>();
|
|
let setModelCallCount = 0;
|
|
const setModel = vi.fn(() => {
|
|
setModelCallCount++;
|
|
if (setModelCallCount === 1) {
|
|
return pendingFallback.promise;
|
|
}
|
|
supersedingEditStarted.resolve();
|
|
return supersedingEdit.promise;
|
|
});
|
|
const roleCleared = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.includes("role cleared")) roleCleared.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel],
|
|
getAvailable: () => [projectModel, globalModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows.
|
|
hub.handleInput("\x7f"); // Clear project DEFAULT and begin its global fallback switch.
|
|
await roleCleared.promise;
|
|
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
|
|
hub.handleInput("\n"); // Start a later DEFAULT assignment.
|
|
hub.handleInput("\t"); // Sidebar → model list.
|
|
hub.handleInput("\x1b[A"); // Global fallback → project model.
|
|
hub.handleInput("\n"); // Pick the project model.
|
|
hub.handleInput("\n"); // Start the project-scoped default edit.
|
|
await Promise.resolve();
|
|
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
pendingFallback.resolve({ switched: false });
|
|
await supersedingEditStarted.promise;
|
|
expect(setModel).toHaveBeenCalledTimes(2);
|
|
supersedingEdit.resolve({ switched: false });
|
|
await Promise.resolve();
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("does not switch the live session when unassigning a project default with no global fallback", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
if (!projectModel) throw new Error("Expected bundled OpenAI model for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const settings = Settings.isolated({ modelRoleStorage: "project" });
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const showError = vi.fn();
|
|
const roleCleared = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.includes("role cleared")) roleCleared.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel],
|
|
getAvailable: () => [projectModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError,
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows (scope → list focus).
|
|
hub.handleInput("\x7f"); // Backspace on DEFAULT to unassign.
|
|
await roleCleared.promise;
|
|
await Promise.resolve();
|
|
|
|
expect(settings.getProjectModelRole("default")).toBeUndefined();
|
|
expect(settings.getGlobalModelRole("default")).toBeUndefined();
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
expect(showError).not.toHaveBeenCalled();
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("does not switch the live model when a --config overlay remains effective over the global default after the project default is unassigned", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
const overlayModel = getBundledModel("openai", "gpt-5.1");
|
|
if (!projectModel || !globalModel || !overlayModel)
|
|
throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const globalSelector = `${globalModel.provider}/${globalModel.id}`;
|
|
const overlaySelector = `${overlayModel.provider}/${overlayModel.id}`;
|
|
|
|
const testDir = path.join(os.tmpdir(), `selector-overlay-clear-${Snowflake.next()}`);
|
|
const projectDir = path.join(testDir, "project");
|
|
const overlayPath = path.join(testDir, "overlay.yml");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
fs.writeFileSync(overlayPath, `modelRoles:\n default: ${overlaySelector}\n`);
|
|
|
|
try {
|
|
const settings = await Settings.loadIsolated({
|
|
cwd: projectDir,
|
|
agentDir: testDir,
|
|
inMemory: true,
|
|
configFiles: [overlayPath],
|
|
overrides: { modelRoleStorage: "project" },
|
|
});
|
|
settings.setModelRole("default", globalSelector);
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
|
|
// Sanity: the config overlay is authoritative over both the global and
|
|
// project layers in the merged view.
|
|
expect(settings.getGlobalModelRole("default")).toBe(globalSelector);
|
|
expect(settings.getProjectModelRole("default")).toBe(projectSelector);
|
|
expect(settings.getModelRole("default")).toBe(overlaySelector);
|
|
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const statusInvalidate = vi.fn();
|
|
const updateEditorBorderColor = vi.fn();
|
|
const showError = vi.fn();
|
|
const roleCleared = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.includes("role cleared")) roleCleared.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel, overlayModel],
|
|
getAvailable: () => [projectModel, globalModel, overlayModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }, { model: overlayModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: statusInvalidate },
|
|
updateEditorBorderColor,
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError,
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows (scope → list focus).
|
|
hub.handleInput("\x7f"); // Backspace on DEFAULT to unassign.
|
|
await roleCleared.promise;
|
|
await Promise.resolve();
|
|
|
|
expect(settings.getProjectModelRole("default")).toBeUndefined();
|
|
expect(settings.getGlobalModelRole("default")).toBe(globalSelector);
|
|
// The config overlay remains authoritative after the clear.
|
|
expect(settings.getModelRole("default")).toBe(overlaySelector);
|
|
// The overlay is effective (distinct from the hidden global default),
|
|
// so the live model must NOT switch to either fallback — the overlay
|
|
// stays authoritative and no session-side persistence is warranted.
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
expect(showError).not.toHaveBeenCalled();
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
} finally {
|
|
if (fs.existsSync(testDir)) removeSyncWithRetries(testDir);
|
|
}
|
|
});
|
|
|
|
it("does not switch the live model when a --config overlay byte-identical to the global default remains effective after the project default is unassigned", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const sharedModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !sharedModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const sharedSelector = `${sharedModel.provider}/${sharedModel.id}`;
|
|
|
|
const testDir = path.join(os.tmpdir(), `selector-overlay-identical-${Snowflake.next()}`);
|
|
const projectDir = path.join(testDir, "project");
|
|
const overlayPath = path.join(testDir, "overlay.yml");
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
fs.writeFileSync(overlayPath, `modelRoles:\n default: ${sharedSelector}\n`);
|
|
|
|
try {
|
|
const settings = await Settings.loadIsolated({
|
|
cwd: projectDir,
|
|
agentDir: testDir,
|
|
inMemory: true,
|
|
configFiles: [overlayPath],
|
|
overrides: { modelRoleStorage: "project" },
|
|
});
|
|
settings.setModelRole("default", sharedSelector);
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
|
|
// Sanity: the config overlay and global layer carry the same raw value,
|
|
// but the overlay is the effective source in the merged view.
|
|
expect(settings.getGlobalModelRole("default")).toBe(sharedSelector);
|
|
expect(settings.getProjectModelRole("default")).toBe(projectSelector);
|
|
expect(settings.getModelRole("default")).toBe(sharedSelector);
|
|
expect(settings.getModelRoleProvenance("default")).toBe("overlay");
|
|
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const showError = vi.fn();
|
|
const roleCleared = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.includes("role cleared")) roleCleared.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, sharedModel],
|
|
getAvailable: () => [projectModel, sharedModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: sharedModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError,
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A"); // All models → Roles.
|
|
hub.handleInput("\n"); // Enter the role rows (scope → list focus).
|
|
hub.handleInput("\x7f"); // Backspace on DEFAULT to unassign.
|
|
await roleCleared.promise;
|
|
await Promise.resolve();
|
|
|
|
expect(settings.getProjectModelRole("default")).toBeUndefined();
|
|
expect(settings.getGlobalModelRole("default")).toBe(sharedSelector);
|
|
// The overlay is still effective with the same raw value as global.
|
|
expect(settings.getModelRole("default")).toBe(sharedSelector);
|
|
expect(settings.getModelRoleProvenance("default")).toBe("overlay");
|
|
// Provenance is "overlay" (not "global"), so the live model must NOT
|
|
// switch even though the raw values are byte-identical.
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
expect(showError).not.toHaveBeenCalled();
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
} finally {
|
|
if (fs.existsSync(testDir)) removeSyncWithRetries(testDir);
|
|
}
|
|
});
|
|
|
|
it("re-enables auto thinking from defaultThinkingLevel when the global default has no explicit thinking", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const globalSelector = `${globalModel.provider}/${globalModel.id}`;
|
|
const settings = Settings.isolated({ modelRoleStorage: "project", defaultThinkingLevel: AUTO_THINKING });
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
settings.setModelRole("default", globalSelector);
|
|
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const setThinkingLevel = vi.fn((level: unknown, persist?: boolean) => {
|
|
if (level === AUTO_THINKING && persist) {
|
|
settings.set("defaultThinkingLevel", AUTO_THINKING);
|
|
}
|
|
});
|
|
const roleCleared = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.includes("role cleared")) roleCleared.resolve();
|
|
});
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel],
|
|
getAvailable: () => [projectModel, globalModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels: [{ model: projectModel }, { model: globalModel }],
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel,
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A");
|
|
hub.handleInput("\n");
|
|
hub.handleInput("\x7f");
|
|
await roleCleared.promise;
|
|
await Promise.resolve();
|
|
|
|
expect(setModel).toHaveBeenCalledWith(
|
|
globalModel,
|
|
"default",
|
|
expect.objectContaining({ persist: false, thinkingLevel: ThinkingLevel.Inherit }),
|
|
);
|
|
expect(setThinkingLevel).toHaveBeenCalledWith(AUTO_THINKING, true);
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("resolves the global fallback from scoped models when nonempty", async () => {
|
|
const testTheme = await getThemeByName("dark");
|
|
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
|
setThemeInstance(testTheme);
|
|
|
|
const projectModel = getBundledModel("openai", "gpt-5.5");
|
|
const globalModel = getBundledModel("openai", "gpt-5.6");
|
|
if (!projectModel || !globalModel) throw new Error("Expected bundled OpenAI models for selector test");
|
|
|
|
const projectSelector = `${projectModel.provider}/${projectModel.id}`;
|
|
const globalSelector = `${globalModel.provider}/${globalModel.id}`;
|
|
const settings = Settings.isolated({ modelRoleStorage: "project" });
|
|
settings.setProjectModelRole("default", projectSelector);
|
|
settings.setModelRole("default", globalSelector);
|
|
|
|
const setModel = vi.fn(async () => ({ switched: true }));
|
|
const roleCleared = Promise.withResolvers<void>();
|
|
const showStatus = vi.fn((message: string) => {
|
|
if (message.includes("role cleared")) roleCleared.resolve();
|
|
});
|
|
// scopedModels contains ONLY projectModel; the global model is NOT in scopedModels.
|
|
const scopedModels = [{ model: projectModel }];
|
|
let captured: unknown;
|
|
const controller = new SelectorController({
|
|
ui: {
|
|
requestRender: vi.fn(),
|
|
setFocus: vi.fn(),
|
|
showOverlay: vi.fn((component: unknown) => {
|
|
captured = component;
|
|
return { hide: vi.fn() };
|
|
}),
|
|
terminal: { rows: 40 },
|
|
},
|
|
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
|
editor: {},
|
|
settings,
|
|
session: {
|
|
model: projectModel,
|
|
modelRegistry: {
|
|
getAll: () => [projectModel, globalModel],
|
|
getAvailable: () => [projectModel, globalModel],
|
|
getError: () => undefined,
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getDiscoverableProviders: () => [],
|
|
getProviderDiscoveryState: () => undefined,
|
|
authStorage: { hasAuth: () => false },
|
|
},
|
|
scopedModels,
|
|
getContextUsage: () => undefined,
|
|
setModel,
|
|
setThinkingLevel: vi.fn(),
|
|
},
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorBorderColor: vi.fn(),
|
|
keybindings: { getKeys: () => [] },
|
|
showStatus,
|
|
showError: vi.fn(),
|
|
} as unknown as InteractiveModeContext);
|
|
|
|
controller.showModelSelector();
|
|
const hub = captured as { handleInput(data: string): void; dispose(): void } | undefined;
|
|
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
|
try {
|
|
hub.handleInput("\x1b[A");
|
|
hub.handleInput("\n");
|
|
hub.handleInput("\x7f");
|
|
await roleCleared.promise;
|
|
await Promise.resolve();
|
|
|
|
// Global model is not in scopedModels, so resolveModelRoleValue cannot
|
|
// match it → setModel must NOT be called.
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
});
|