feat(coding-agent): added hidden title role for prioritized title model selection
- Added a new built-in `title` model role with `hidden` metadata and updated role definitions and schema. - Updated title generation to resolve models in `title`, `commit`, then `smol` order and added test coverage for that precedence. - Filtered hidden roles from selector badges and documented the new built-in role in model/settings docs.
This commit is contained in:
+1
-1
@@ -417,7 +417,7 @@ Resolution precedence for exact selectors:
|
||||
|
||||
Supported model roles:
|
||||
|
||||
- `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `task`
|
||||
- `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `title`, `task`
|
||||
|
||||
Role aliases like `pi/smol` expand through `settings.modelRoles`. Each role value can also append a thinking selector such as `:minimal`, `:low`, `:medium`, or `:high`.
|
||||
|
||||
|
||||
+1
-1
@@ -299,7 +299,7 @@ enabledModels:
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `modelRoles` | record | `{}` | Map of role name -> model id. Built-in roles: `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `task`. Per-role env/flags: `--model`/`--smol`/`--slow`/`--plan`. |
|
||||
| `modelRoles` | record | `{}` | Map of role name -> model id. Built-in roles: `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `title`, `task`. Per-role env/flags: `--model`/`--smol`/`--slow`/`--plan`. |
|
||||
| `modelTags` | record | `{}` | Custom role/tag metadata; can introduce additional roles. |
|
||||
| `modelProviderOrder` | array | `[]` | Preferred provider order when a model id is ambiguous. |
|
||||
| `cycleOrder` | array | `["smol","default","slow"]` | Roles cycled by the model switcher. |
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task";
|
||||
export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "title" | "task";
|
||||
|
||||
export interface ModelRoleInfo {
|
||||
tag?: string;
|
||||
name: string;
|
||||
color?: ThemeColor;
|
||||
/** If true, the role is functional but not shown in the model selector UI. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
||||
@@ -21,12 +23,22 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
||||
plan: { tag: "PLAN", name: "Architect", color: "muted" },
|
||||
designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
|
||||
commit: { tag: "COMMIT", name: "Commit", color: "dim" },
|
||||
title: { tag: "TITLE", name: "Title", color: "dim", hidden: true },
|
||||
task: { tag: "TASK", name: "Subtask", color: "muted" },
|
||||
};
|
||||
|
||||
export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"];
|
||||
export const MODEL_ROLE_IDS: ModelRole[] = [
|
||||
"default",
|
||||
"smol",
|
||||
"slow",
|
||||
"vision",
|
||||
"plan",
|
||||
"designer",
|
||||
"commit",
|
||||
"title",
|
||||
"task",
|
||||
];
|
||||
|
||||
/** Alias for ModelRoleInfo - used for both built-in and custom roles */
|
||||
export type RoleInfo = ModelRoleInfo;
|
||||
|
||||
/**
|
||||
@@ -37,7 +49,7 @@ export type RoleInfo = ModelRoleInfo;
|
||||
* entries across settings.
|
||||
*/
|
||||
export function getKnownRoleIds(settings: Settings): string[] {
|
||||
const roles = [...MODEL_ROLE_IDS] as string[];
|
||||
const roles = MODEL_ROLE_IDS.filter(role => !MODEL_ROLES[role as ModelRole]?.hidden) as string[];
|
||||
const seen = new Set<string>(roles);
|
||||
const addRole = (role: string) => {
|
||||
if (seen.has(role)) return;
|
||||
@@ -65,6 +77,7 @@ export function getRoleInfo(role: string, settings: Settings): RoleInfo {
|
||||
tag: builtIn?.tag,
|
||||
name: configured.name || builtIn?.name || role,
|
||||
color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color,
|
||||
hidden: configured.hidden ?? builtIn?.hidden,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,8 @@ type SettingDef =
|
||||
export interface ModelTagDef {
|
||||
name: string;
|
||||
color?: string;
|
||||
/** If true, the role is functional but not shown in the model selector UI. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelTagsSettings {
|
||||
|
||||
@@ -936,7 +936,8 @@ export class ModelSelectorComponent extends Container {
|
||||
// Build role badges. Solid badges are configured; outlined badges are auto-selected defaults.
|
||||
const roleBadgeTokens: string[] = [];
|
||||
for (const role of MODEL_ROLE_IDS) {
|
||||
const { tag, color } = getRoleInfo(role, this.#settings);
|
||||
const { tag, color, hidden } = getRoleInfo(role, this.#settings);
|
||||
if (hidden) continue;
|
||||
const assigned = this.#roles[role];
|
||||
if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
|
||||
const availableModels = registry.getAvailable();
|
||||
if (availableModels.length === 0) return undefined;
|
||||
|
||||
const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels, registry)?.model;
|
||||
const titleModel = resolveRoleSelection(["title", "commit", "smol"], settings, availableModels, registry)?.model;
|
||||
if (titleModel) return titleModel;
|
||||
|
||||
if (currentModel) return currentModel;
|
||||
|
||||
@@ -373,4 +373,87 @@ describe("title generator", () => {
|
||||
expect(request?.systemPrompt?.[0]).toBe(customPrompt);
|
||||
expect(request?.systemPrompt?.[1]).toContain("<title>");
|
||||
});
|
||||
|
||||
it("resolves the model roles in precedence order: title -> commit -> smol", async () => {
|
||||
const titleModel = getModelOrThrow("claude-haiku-4-5");
|
||||
const commitModel = getModelOrThrow("claude-sonnet-4-5");
|
||||
const smolModel = getModelOrThrow("claude-opus-4-8");
|
||||
|
||||
const mockComplete = vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: "<title>Test Title</title>" }],
|
||||
} as never);
|
||||
|
||||
// Case 1: All three roles configured. 'title' should be used.
|
||||
let currentSettings = {
|
||||
get(path: string) {
|
||||
if (path === "providers.tinyModel") return "online";
|
||||
return undefined;
|
||||
},
|
||||
getModelRole(role: string) {
|
||||
if (role === "title") return `${titleModel.provider}/${titleModel.id}`;
|
||||
if (role === "commit") return `${commitModel.provider}/${commitModel.id}`;
|
||||
if (role === "smol") return `${smolModel.provider}/${smolModel.id}`;
|
||||
return undefined;
|
||||
},
|
||||
getStorage() {
|
||||
return undefined;
|
||||
},
|
||||
} as never;
|
||||
|
||||
const registry = {
|
||||
getAvailable: () => [titleModel, commitModel, smolModel],
|
||||
getApiKey: async () => "test-key",
|
||||
getApiKeyForProvider: async () => "test-key",
|
||||
authStorage: { rotateSessionCredential: async () => false },
|
||||
resolver: () => async () => "test-key",
|
||||
} as never;
|
||||
|
||||
await generateSessionTitle("Some message", registry, currentSettings);
|
||||
expect(mockComplete).toHaveBeenCalled();
|
||||
expect(mockComplete.mock.calls[0]?.[0]).toBe(titleModel);
|
||||
|
||||
mockComplete.mockClear();
|
||||
|
||||
// Case 2: 'title' role not configured, 'commit' and 'smol' configured. 'commit' should be used.
|
||||
currentSettings = {
|
||||
get(path: string) {
|
||||
if (path === "providers.tinyModel") return "online";
|
||||
return undefined;
|
||||
},
|
||||
getModelRole(role: string) {
|
||||
if (role === "commit") return `${commitModel.provider}/${commitModel.id}`;
|
||||
if (role === "smol") return `${smolModel.provider}/${smolModel.id}`;
|
||||
return undefined;
|
||||
},
|
||||
getStorage() {
|
||||
return undefined;
|
||||
},
|
||||
} as never;
|
||||
|
||||
await generateSessionTitle("Some message", registry, currentSettings);
|
||||
expect(mockComplete).toHaveBeenCalled();
|
||||
expect(mockComplete.mock.calls[0]?.[0]).toBe(commitModel);
|
||||
|
||||
mockComplete.mockClear();
|
||||
|
||||
// Case 3: Only 'smol' role configured. 'smol' should be used.
|
||||
currentSettings = {
|
||||
get(path: string) {
|
||||
if (path === "providers.tinyModel") return "online";
|
||||
return undefined;
|
||||
},
|
||||
getModelRole(role: string) {
|
||||
if (role === "smol") return `${smolModel.provider}/${smolModel.id}`;
|
||||
return undefined;
|
||||
},
|
||||
getStorage() {
|
||||
return undefined;
|
||||
},
|
||||
} as never;
|
||||
|
||||
await generateSessionTitle("Some message", registry, currentSettings);
|
||||
expect(mockComplete).toHaveBeenCalled();
|
||||
expect(mockComplete.mock.calls[0]?.[0]).toBe(smolModel);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user