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:
can1357
2026-06-15 00:23:29 +02:00
parent c7564f4e57
commit 5a910863c3
7 changed files with 107 additions and 8 deletions
+1 -1
View File
@@ -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
View File
@@ -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);
});
});