feat: implement submode LLM customization with compound slugs

- Add hidden and parent fields to ModeConfig type
- Implement compound slug naming convention (parent/child)
- Update ProviderSettingsManager to handle compound slugs with fallback
- Add helper functions for submode management
- Filter hidden modes from mode selector while showing in config panel
- Add comprehensive tests for submode functionality

This allows complex modes to delegate to focused submodes with individual
LLM model & API provider customization, addressing issue #9446
This commit is contained in:
Roo Code 2025-11-22 08:19:49 +00:00
parent 2ca9eac9e0
commit bcce6f4522
5 changed files with 296 additions and 11 deletions

View file

@ -62,7 +62,7 @@ const groupEntryArraySchema = z.array(groupEntrySchema).refine(
)
export const modeConfigSchema = z.object({
slug: z.string().regex(/^[a-zA-Z0-9-]+$/, "Slug must contain only letters numbers and dashes"),
slug: z.string().regex(/^[a-zA-Z0-9-/]+$/, "Slug must contain only letters, numbers, dashes, and forward slashes"),
name: z.string().min(1, "Name is required"),
roleDefinition: z.string().min(1, "Role definition is required"),
whenToUse: z.string().optional(),
@ -70,6 +70,8 @@ export const modeConfigSchema = z.object({
customInstructions: z.string().optional(),
groups: groupEntryArraySchema,
source: z.enum(["global", "project"]).optional(),
hidden: z.boolean().optional(),
parent: z.string().optional(),
})
export type ModeConfig = z.infer<typeof modeConfigSchema>

View file

@ -14,7 +14,7 @@ import {
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { Mode, modes } from "../../shared/modes"
import { Mode, modes, parseCompoundSlug, getCompoundSlug } from "../../shared/modes"
import { buildApiHandler } from "../../api"
// Type-safe model migrations mapping
@ -488,6 +488,7 @@ export class ProviderSettingsManager {
/**
* Set the API config for a specific mode.
* Supports compound slugs for submodes (e.g., "parent/child")
*/
public async setModeConfig(mode: Mode, configId: string) {
try {
@ -498,6 +499,7 @@ export class ProviderSettingsManager {
providerProfiles.modeApiConfigs = {}
}
// Assign the chosen config ID to this mode
// Mode can be a compound slug like "parent/child" for submodes
providerProfiles.modeApiConfigs[mode] = configId
await this.store(providerProfiles)
})
@ -508,12 +510,27 @@ export class ProviderSettingsManager {
/**
* Get the API config ID for a specific mode.
* Supports compound slugs for submodes (e.g., "parent/child")
* Falls back to parent mode config if submode has no specific config
*/
public async getModeConfigId(mode: Mode) {
try {
return await this.lock(async () => {
const { modeApiConfigs } = await this.load()
return modeApiConfigs?.[mode]
if (!modeApiConfigs) return undefined
// First try to get config for the exact mode (could be compound slug)
const directConfig = modeApiConfigs[mode]
if (directConfig) return directConfig
// If mode contains a slash, it's a compound slug for a submode
// Try to fall back to parent mode config
const { parent } = parseCompoundSlug(mode)
if (parent) {
return modeApiConfigs[parent]
}
return undefined
})
} catch (error) {
throw new Error(`Failed to get mode config: ${error}`)

View file

@ -52,7 +52,15 @@ import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage"
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
import {
Mode,
defaultModeSlug,
getModeBySlug,
getCompoundSlug,
parseCompoundSlug,
getModeByCompoundSlug,
getAllModesForConfiguration,
} from "../../shared/modes"
import { experimentDefault } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
import { WebviewMessage } from "../../shared/WebviewMessage"
@ -1268,8 +1276,13 @@ export class ClineProvider
this.emit(RooCodeEventName.ModeChanged, newMode)
// For submodes, use compound slug for configuration
const customModes = await this.customModesManager.getCustomModes()
const modeConfig = getModeBySlug(newMode, customModes)
const configKey = modeConfig?.parent ? getCompoundSlug(modeConfig) : newMode
// Load the saved API config for the new mode if it exists.
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
const savedConfigId = await this.providerSettingsManager.getModeConfigId(configKey)
const listApiConfig = await this.providerSettingsManager.listConfig()
// Update listApiConfigMeta first to ensure UI has latest data.
@ -1290,7 +1303,7 @@ export class ClineProvider
const config = listApiConfig.find((c) => c.name === currentApiConfigName)
if (config?.id) {
await this.providerSettingsManager.setModeConfig(newMode, config.id)
await this.providerSettingsManager.setModeConfig(configKey, config.id)
}
}
}
@ -1433,8 +1446,13 @@ export class ClineProvider
const { mode } = await this.getState()
// For submodes, use compound slug for configuration
const customModes = await this.customModesManager.getCustomModes()
const modeConfig = getModeBySlug(mode, customModes)
const configKey = modeConfig?.parent ? getCompoundSlug(modeConfig) : mode
if (id) {
await this.providerSettingsManager.setModeConfig(mode, id)
await this.providerSettingsManager.setModeConfig(configKey, id)
}
// Change the provider for the current task.
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
@ -2807,7 +2825,9 @@ export class ClineProvider
public async getModes(): Promise<{ slug: string; name: string }[]> {
try {
const customModes = await this.customModesManager.getCustomModes()
return [...DEFAULT_MODES, ...customModes].map(({ slug, name }) => ({ slug, name }))
// Get all modes for configuration (includes submodes with compound slugs)
const allModes = getAllModesForConfiguration(customModes)
return allModes.map(({ slug, name }) => ({ slug, name }))
} catch (error) {
return DEFAULT_MODES.map(({ slug, name }) => ({ slug, name }))
}

View file

@ -0,0 +1,174 @@
import { describe, it, expect } from "vitest"
import type { ModeConfig } from "@roo-code/types"
import {
getSubmodes,
isSubmode,
getCompoundSlug,
parseCompoundSlug,
getModeByCompoundSlug,
getAllModes,
getVisibleModes,
getAllModesForConfiguration,
} from "../modes"
describe("Submode functionality", () => {
const parentMode: ModeConfig = {
slug: "architect",
name: "Architect",
roleDefinition: "Parent mode",
groups: ["read"],
}
const submode1: ModeConfig = {
slug: "planner",
name: "Planner",
roleDefinition: "Planning submode",
groups: ["read"],
parent: "architect",
hidden: true,
}
const submode2: ModeConfig = {
slug: "designer",
name: "Designer",
roleDefinition: "Design submode",
groups: ["read", "edit"],
parent: "architect",
hidden: true,
}
const visibleSubmode: ModeConfig = {
slug: "reviewer",
name: "Reviewer",
roleDefinition: "Review submode",
groups: ["read"],
parent: "architect",
hidden: false,
}
const customModes = [parentMode, submode1, submode2, visibleSubmode]
describe("getSubmodes", () => {
it("should return all submodes for a parent", () => {
const submodes = getSubmodes("architect", customModes)
expect(submodes).toHaveLength(3)
expect(submodes).toContainEqual(submode1)
expect(submodes).toContainEqual(submode2)
expect(submodes).toContainEqual(visibleSubmode)
})
it("should return empty array for mode with no submodes", () => {
const submodes = getSubmodes("code", customModes)
expect(submodes).toHaveLength(0)
})
})
describe("isSubmode", () => {
it("should return true for submodes", () => {
expect(isSubmode(submode1)).toBe(true)
expect(isSubmode(submode2)).toBe(true)
})
it("should return false for parent modes", () => {
expect(isSubmode(parentMode)).toBe(false)
})
})
describe("getCompoundSlug", () => {
it("should return compound slug for submodes", () => {
expect(getCompoundSlug(submode1)).toBe("architect/planner")
expect(getCompoundSlug(submode2)).toBe("architect/designer")
})
it("should return simple slug for parent modes", () => {
expect(getCompoundSlug(parentMode)).toBe("architect")
})
})
describe("parseCompoundSlug", () => {
it("should parse compound slugs correctly", () => {
expect(parseCompoundSlug("architect/planner")).toEqual({
parent: "architect",
child: "planner",
})
})
it("should parse simple slugs correctly", () => {
expect(parseCompoundSlug("architect")).toEqual({
child: "architect",
})
})
})
describe("getModeByCompoundSlug", () => {
it("should find submode by compound slug", () => {
const mode = getModeByCompoundSlug("architect/planner", customModes)
expect(mode).toEqual(submode1)
})
it("should find parent mode by simple slug", () => {
const mode = getModeByCompoundSlug("architect", customModes)
expect(mode).toEqual(parentMode)
})
it("should return undefined for non-existent compound slug", () => {
const mode = getModeByCompoundSlug("architect/nonexistent", customModes)
expect(mode).toBeUndefined()
})
})
describe("getAllModes", () => {
it("should exclude hidden modes by default", () => {
const modes = getAllModes(customModes)
expect(modes).toContain(parentMode)
expect(modes).toContain(visibleSubmode)
expect(modes).not.toContain(submode1)
expect(modes).not.toContain(submode2)
})
it("should include hidden modes when requested", () => {
const modes = getAllModes(customModes, true)
expect(modes).toContain(parentMode)
expect(modes).toContain(visibleSubmode)
expect(modes).toContain(submode1)
expect(modes).toContain(submode2)
})
})
describe("getVisibleModes", () => {
it("should only return visible modes", () => {
const modes = getVisibleModes(customModes)
expect(modes).toContain(parentMode)
expect(modes).toContain(visibleSubmode)
expect(modes).not.toContain(submode1)
expect(modes).not.toContain(submode2)
})
})
describe("getAllModesForConfiguration", () => {
it("should include all modes with compound slugs for submodes", () => {
const modes = getAllModesForConfiguration(customModes)
// Should include all modes (including hidden)
// We have 4 custom modes + 5 built-in modes = 9, but architect appears in both
// so we expect 8 total (architect from custom overrides the built-in)
expect(modes).toHaveLength(8)
// Find the transformed submodes
const transformedPlanner = modes.find((m) => m.slug === "architect/planner")
const transformedDesigner = modes.find((m) => m.slug === "architect/designer")
const transformedReviewer = modes.find((m) => m.slug === "architect/reviewer")
// Check compound slugs are created
expect(transformedPlanner).toBeDefined()
expect(transformedDesigner).toBeDefined()
expect(transformedReviewer).toBeDefined()
// Check names show hierarchy
expect(transformedPlanner?.name).toBe("architect Planner")
expect(transformedDesigner?.name).toBe("architect Designer")
expect(transformedReviewer?.name).toBe("architect Reviewer")
})
})
})

View file

@ -77,6 +77,48 @@ export function getModeBySlug(slug: string, customModes?: ModeConfig[]): ModeCon
return modes.find((mode) => mode.slug === slug)
}
// Get all submodes for a parent mode
export function getSubmodes(parentSlug: string, customModes?: ModeConfig[]): ModeConfig[] {
const allModes = getAllModes(customModes, true) // Include hidden modes
return allModes.filter((mode) => mode.parent === parentSlug)
}
// Check if a mode is a submode
export function isSubmode(mode: ModeConfig): boolean {
return !!mode.parent
}
// Get the compound slug for a submode (parent/child format)
export function getCompoundSlug(mode: ModeConfig): string {
if (mode.parent) {
return `${mode.parent}/${mode.slug}`
}
return mode.slug
}
// Parse a compound slug to get parent and child parts
export function parseCompoundSlug(compoundSlug: string): { parent?: string; child: string } {
const parts = compoundSlug.split("/")
if (parts.length === 2) {
return { parent: parts[0], child: parts[1] }
}
return { child: compoundSlug }
}
// Get a mode by compound slug
export function getModeByCompoundSlug(compoundSlug: string, customModes?: ModeConfig[]): ModeConfig | undefined {
const { parent, child } = parseCompoundSlug(compoundSlug)
if (parent) {
// Look for a submode with matching parent and slug
const allModes = getAllModes(customModes, true) // Include hidden modes
return allModes.find((mode) => mode.parent === parent && mode.slug === child)
}
// If no parent, just look for the mode by slug
return getModeBySlug(child, customModes)
}
export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeConfig {
const mode = getModeBySlug(slug, customModes)
if (!mode) {
@ -86,7 +128,7 @@ export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeCon
}
// Get all available modes, with custom modes overriding built-in modes
export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] {
export function getAllModes(customModes?: ModeConfig[], includeHidden: boolean = false): ModeConfig[] {
if (!customModes?.length) {
return [...modes]
}
@ -106,9 +148,36 @@ export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] {
}
})
// Filter out hidden modes unless explicitly requested
if (!includeHidden) {
return allModes.filter((mode) => !mode.hidden)
}
return allModes
}
// Get all visible modes (excludes hidden submodes)
export function getVisibleModes(customModes?: ModeConfig[]): ModeConfig[] {
return getAllModes(customModes, false)
}
// Get all modes including submodes for configuration UI
export function getAllModesForConfiguration(customModes?: ModeConfig[]): ModeConfig[] {
const allModes = getAllModes(customModes, true)
// Transform submodes to use compound slugs for display
return allModes.map((mode) => {
if (mode.parent) {
return {
...mode,
slug: getCompoundSlug(mode),
name: `${mode.parent} ${mode.name}`, // Use to show hierarchy
}
}
return mode
})
}
// Check if a mode is custom or an override
export function isCustomMode(slug: string, customModes?: ModeConfig[]): boolean {
return !!customModes?.some((mode) => mode.slug === slug)
@ -284,11 +353,14 @@ export const defaultPrompts: Readonly<CustomModePrompts> = Object.freeze(
)
// Helper function to get all modes with their prompt overrides from extension state
export async function getAllModesWithPrompts(context: vscode.ExtensionContext): Promise<ModeConfig[]> {
export async function getAllModesWithPrompts(
context: vscode.ExtensionContext,
includeHidden: boolean = false,
): Promise<ModeConfig[]> {
const customModes = (await context.globalState.get<ModeConfig[]>("customModes")) || []
const customModePrompts = (await context.globalState.get<CustomModePrompts>("customModePrompts")) || {}
const allModes = getAllModes(customModes)
const allModes = getAllModes(customModes, includeHidden)
return allModes.map((mode) => ({
...mode,
roleDefinition: customModePrompts[mode.slug]?.roleDefinition ?? mode.roleDefinition,