Files
oh-my-pi/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts
T
can1357 6d99f2e740 feat(model-selector): disabled models below current context size
- Dimmed models whose context window is smaller than current usage.
- Skipped disabled entries during navigation and selection.
- Showed context-limit suffix and warning for unselectable models.
2026-06-06 15:10:14 +02:00

319 lines
9.6 KiB
TypeScript

import { beforeAll, describe, expect, test, vi } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { getBundledModel, type Model } from "@oh-my-pi/pi-ai";
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { ModelSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/model-selector";
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { TUI } from "@oh-my-pi/pi-tui";
function normalizeRenderedText(text: string): string {
return stripVTControlCharacters(text).replace(/\s+/g, " ").trim();
}
function createSelector(model: Model, settings: Settings): ModelSelectorComponent {
const modelRegistry = {
getAll: () => [model],
getDiscoverableProviders: () => [],
getCanonicalModels: () => [],
resolveCanonicalModel: () => undefined,
} as unknown as ModelRegistry;
const ui = {
requestRender: vi.fn(),
} as unknown as TUI;
return new ModelSelectorComponent(
ui,
model,
settings,
modelRegistry,
[{ model, thinkingLevel: "off" }],
() => {},
() => {},
);
}
function createOllamaCloudModel(id: string): Model {
return {
id,
name: "DeepSeek V4 Pro",
api: "ollama-chat",
provider: "ollama-cloud",
baseUrl: "https://ollama.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 8192,
};
}
function createContextTestModel(id: string, contextWindow: number): Model {
return {
id,
name: id,
api: "ollama-chat",
baseUrl: "https://example.com",
reasoning: false,
provider: "test",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens: 1024,
};
}
function createScopedSelector(
models: Model[],
settings: Settings,
onSelect: (model: Model) => void,
options?: { temporaryOnly?: boolean; currentContextTokens?: number },
): ModelSelectorComponent {
const modelRegistry = {
getAll: () => models,
getDiscoverableProviders: () => [],
getCanonicalModels: () => [],
resolveCanonicalModel: () => undefined,
} as unknown as ModelRegistry;
const ui = {
requestRender: vi.fn(),
} as unknown as TUI;
return new ModelSelectorComponent(
ui,
undefined,
settings,
modelRegistry,
models.map(model => ({ model })),
model => onSelect(model),
() => {},
options,
);
}
let testTheme = await getThemeByName("dark");
function installTestTheme(): void {
if (!testTheme) {
throw new Error("Failed to load dark theme for ModelSelector tests");
}
setThemeInstance(testTheme);
}
describe("ModelSelector role badge thinking display", () => {
beforeAll(async () => {
testTheme = await getThemeByName("dark");
if (!testTheme) {
throw new Error("Failed to load dark theme for ModelSelector tests");
}
});
test("shows custom roles from cycleOrder/modelRoles and honors built-in metadata overrides", async () => {
installTestTheme();
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5");
const settings = Settings.isolated({
cycleOrder: ["smol", "custom-fast", "default"],
modelRoles: {
default: `${model.provider}/${model.id}`,
"custom-fast": `${model.provider}/${model.id}:low`,
smol: `${model.provider}/${model.id}`,
},
modelTags: {
smol: { name: "Quick", color: "error" },
},
});
const selector = createSelector(model, settings);
await Bun.sleep(0);
installTestTheme();
const rendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(rendered).toContain("custom-fast (low)");
expect(rendered).toContain("SMOL (inherit)");
selector.handleInput("\n");
installTestTheme();
const menuRendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(menuRendered).toContain("Set as custom-fast");
expect(menuRendered).toContain("Set as SMOL (Quick)");
});
test("dims and disables models below the current context size", async () => {
installTestTheme();
const settings = Settings.isolated({});
const small = createContextTestModel("a-small", 4096);
const large = createContextTestModel("b-large", 128_000);
const selected: string[] = [];
const selector = createScopedSelector([small, large], settings, model => selected.push(model.id), {
temporaryOnly: true,
currentContextTokens: 6000,
});
await Bun.sleep(0);
installTestTheme();
const rendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(rendered).toContain("a-small");
expect(rendered).toContain("context>4.1k");
selector.handleInput("\n");
expect(selected).toEqual(["b-large"]);
});
test("does not open the model menu when every candidate is disabled", async () => {
installTestTheme();
const settings = Settings.isolated({});
const small = createContextTestModel("only-small", 4096);
const onSelect = vi.fn();
const selector = createScopedSelector([small], settings, onSelect, {
currentContextTokens: 6000,
});
await Bun.sleep(0);
installTestTheme();
const rendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(rendered).toContain("only-small");
expect(rendered).toContain("current context 6k > 4.1k limit");
selector.handleInput("\n");
const afterEnter = normalizeRenderedText(selector.render(220).join("\n"));
expect(afterEnter).not.toContain("Action for");
expect(onSelect).not.toHaveBeenCalled();
});
test("refreshes Ollama Cloud using provider id instead of tab label", async () => {
installTestTheme();
const settings = Settings.isolated({});
const discoveredModel = createOllamaCloudModel("deepseek-v4-pro");
let availableModels: Model[] = [];
const refreshProvider = vi.fn(async (providerId: string) => {
if (providerId === "ollama-cloud") {
availableModels = [discoveredModel];
}
});
const modelRegistry = {
getAll: () => availableModels,
refresh: vi.fn(async () => {}),
refreshProvider,
getError: () => undefined,
getAvailable: () => availableModels,
getDiscoverableProviders: () => ["ollama-cloud"],
getCanonicalModels: () => [],
resolveCanonicalModel: () => undefined,
getProviderDiscoveryState: () => ({
provider: "ollama-cloud",
status: "idle",
optional: false,
stale: false,
models: [],
}),
} as unknown as ModelRegistry;
const ui = {
requestRender: vi.fn(),
} as unknown as TUI;
const selector = new ModelSelectorComponent(
ui,
undefined,
settings,
modelRegistry,
[],
() => {},
() => {},
);
await Bun.sleep(0);
installTestTheme();
const initialRendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(initialRendered).toContain("OLLAMA CLOUD");
selector.handleInput("\t");
selector.handleInput("\t");
await Bun.sleep(125);
installTestTheme();
expect(refreshProvider).toHaveBeenCalledWith("ollama-cloud", "online");
expect(modelRegistry.refresh).toHaveBeenCalledTimes(1);
const rendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(rendered).toContain("deepseek-v4-pro");
expect(rendered).not.toContain("Provider has not been refreshed yet");
});
test("switches provider tabs immediately and refreshes in background with spinner animation", async () => {
installTestTheme();
const settings = Settings.isolated({});
const discoveredModel = createOllamaCloudModel("deepseek-v4-pro");
let availableModels: Model[] = [];
let resolveRefresh: (() => void) | undefined;
const refreshProvider = vi.fn(
(_providerId: string, _strategy?: string) =>
new Promise<void>(resolve => {
resolveRefresh = () => {
availableModels = [discoveredModel];
resolve();
};
}),
);
const modelRegistry = {
getAll: () => availableModels,
refresh: vi.fn(async () => {}),
refreshProvider,
getError: () => undefined,
getAvailable: () => availableModels,
getDiscoverableProviders: () => ["ollama-cloud"],
getCanonicalModels: () => [],
resolveCanonicalModel: () => undefined,
getProviderDiscoveryState: () => ({
provider: "ollama-cloud",
status: "idle",
optional: false,
stale: false,
models: [],
}),
} as unknown as ModelRegistry;
const ui = {
requestRender: vi.fn(),
} as unknown as TUI;
const selector = new ModelSelectorComponent(
ui,
undefined,
settings,
modelRegistry,
[],
() => {},
() => {},
);
await Bun.sleep(0);
installTestTheme();
selector.handleInput("\t");
selector.handleInput("\t");
// Core regression: tab switch must not synchronously enter provider refresh.
expect(refreshProvider).not.toHaveBeenCalled();
const immediateRendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(immediateRendered).toContain("Refreshing OLLAMA CLOUD in background");
await Bun.sleep(5);
expect(refreshProvider).not.toHaveBeenCalled();
await Bun.sleep(120);
expect(refreshProvider).toHaveBeenCalledWith("ollama-cloud", "online");
const spinnerFrame1 = selector.render(220).join("\n");
await Bun.sleep(100);
installTestTheme();
const spinnerFrame2 = selector.render(220).join("\n");
expect(normalizeRenderedText(spinnerFrame2)).toContain("Refreshing OLLAMA CLOUD in background");
expect(spinnerFrame2).not.toEqual(spinnerFrame1);
resolveRefresh?.();
await Bun.sleep(10);
installTestTheme();
expect(modelRegistry.refresh).toHaveBeenCalledTimes(1);
const finalRendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(finalRendered).toContain("deepseek-v4-pro");
expect(finalRendered).not.toContain("Refreshing OLLAMA CLOUD in background");
});
});