mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
2ca9eac9e0
commit
bcce6f4522
5 changed files with 296 additions and 11 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
174
src/shared/__tests__/submodes.spec.ts
Normal file
174
src/shared/__tests__/submodes.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue