Skip to content

Commit c815ea7

Browse files
authored
Merge pull request RooCodeInc#992 from RooVetGit/new_mode_role_validation
Validate custom modes schema before creation from the UI
2 parents 998beee + 5c9fea9 commit c815ea7

File tree

8 files changed

+125
-90
lines changed

8 files changed

+125
-90
lines changed

src/__mocks__/get-folder-size.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@ module.exports = async function getFolderSize() {
44
errors: [],
55
}
66
}
7+
8+
module.exports.loose = async function getFolderSizeLoose() {
9+
return {
10+
size: 1000,
11+
errors: [],
12+
}
13+
}

src/core/config/CustomModesManager.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,6 @@ export class CustomModesManager {
6262
const settings = JSON.parse(content)
6363
const result = CustomModesSettingsSchema.safeParse(settings)
6464
if (!result.success) {
65-
const errorMsg = `Schema validation failed for ${filePath}`
66-
console.error(`[CustomModesManager] ${errorMsg}:`, result.error)
6765
return []
6866
}
6967

src/core/config/CustomModesSchema.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,19 @@ const GroupOptionsSchema = z.object({
2929
const GroupEntrySchema = z.union([ToolGroupSchema, z.tuple([ToolGroupSchema, GroupOptionsSchema])])
3030

3131
// Schema for array of groups
32-
const GroupsArraySchema = z
33-
.array(GroupEntrySchema)
34-
.min(1, "At least one tool group is required")
35-
.refine(
36-
(groups) => {
37-
const seen = new Set()
38-
return groups.every((group) => {
39-
// For tuples, check the group name (first element)
40-
const groupName = Array.isArray(group) ? group[0] : group
41-
if (seen.has(groupName)) return false
42-
seen.add(groupName)
43-
return true
44-
})
45-
},
46-
{ message: "Duplicate groups are not allowed" },
47-
)
32+
const GroupsArraySchema = z.array(GroupEntrySchema).refine(
33+
(groups) => {
34+
const seen = new Set()
35+
return groups.every((group) => {
36+
// For tuples, check the group name (first element)
37+
const groupName = Array.isArray(group) ? group[0] : group
38+
if (seen.has(groupName)) return false
39+
seen.add(groupName)
40+
return true
41+
})
42+
},
43+
{ message: "Duplicate groups are not allowed" },
44+
)
4845

4946
// Schema for mode configuration
5047
export const CustomModeSchema = z.object({

src/core/config/__tests__/CustomModesSchema.test.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,6 @@ describe("CustomModeSchema", () => {
9595
expect(() => validateCustomMode(invalidGroupMode)).toThrow(ZodError)
9696
})
9797

98-
test("rejects empty groups array", () => {
99-
const invalidMode = {
100-
slug: "123e4567-e89b-12d3-a456-426614174000",
101-
name: "Test Mode",
102-
roleDefinition: "Test role definition",
103-
groups: [] as const,
104-
} satisfies ModeConfig
105-
106-
expect(() => validateCustomMode(invalidMode)).toThrow("At least one tool group is required")
107-
})
108-
10998
test("handles null and undefined gracefully", () => {
11099
expect(() => validateCustomMode(null)).toThrow(ZodError)
111100
expect(() => validateCustomMode(undefined)).toThrow(ZodError)
@@ -179,16 +168,5 @@ describe("CustomModeSchema", () => {
179168

180169
expect(() => CustomModeSchema.parse(modeWithDuplicates)).toThrow(/Duplicate groups/)
181170
})
182-
183-
it("requires at least one group", () => {
184-
const modeWithNoGroups = {
185-
slug: "test",
186-
name: "Test",
187-
roleDefinition: "Test",
188-
groups: [],
189-
}
190-
191-
expect(() => CustomModeSchema.parse(modeWithNoGroups)).toThrow(/At least one tool group is required/)
192-
})
193171
})
194172
})

src/core/config/__tests__/GroupConfigSchema.test.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,6 @@ describe("GroupConfigSchema", () => {
4545
expect(() => CustomModeSchema.parse(mode)).toThrow()
4646
})
4747

48-
test("rejects empty groups array", () => {
49-
const mode = {
50-
...validBaseMode,
51-
groups: [] as const,
52-
} satisfies ModeConfig
53-
54-
expect(() => CustomModeSchema.parse(mode)).toThrow("At least one tool group is required")
55-
})
56-
5748
test("rejects invalid group names", () => {
5849
const mode = {
5950
...validBaseMode,

src/core/webview/__tests__/ClineProvider.test.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ describe("ClineProvider", () => {
246246
// Mock CustomModesManager
247247
const mockCustomModesManager = {
248248
updateCustomMode: jest.fn().mockResolvedValue(undefined),
249-
getCustomModes: jest.fn().mockResolvedValue({}),
249+
getCustomModes: jest.fn().mockResolvedValue({ customModes: [] }),
250250
dispose: jest.fn(),
251251
}
252252

@@ -1049,7 +1049,7 @@ describe("ClineProvider", () => {
10491049
"900x600", // browserViewportSize
10501050
"code", // mode
10511051
{}, // customModePrompts
1052-
{}, // customModes
1052+
{ customModes: [] }, // customModes
10531053
undefined, // effectiveInstructions
10541054
undefined, // preferredLanguage
10551055
true, // diffEnabled
@@ -1102,7 +1102,7 @@ describe("ClineProvider", () => {
11021102
"900x600", // browserViewportSize
11031103
"code", // mode
11041104
{}, // customModePrompts
1105-
{}, // customModes
1105+
{ customModes: [] }, // customModes
11061106
undefined, // effectiveInstructions
11071107
undefined, // preferredLanguage
11081108
false, // diffEnabled
@@ -1220,12 +1220,14 @@ describe("ClineProvider", () => {
12201220
provider.customModesManager = {
12211221
updateCustomMode: jest.fn().mockResolvedValue(undefined),
12221222
getCustomModes: jest.fn().mockResolvedValue({
1223-
"test-mode": {
1224-
slug: "test-mode",
1225-
name: "Test Mode",
1226-
roleDefinition: "Updated role definition",
1227-
groups: ["read"] as const,
1228-
},
1223+
customModes: [
1224+
{
1225+
slug: "test-mode",
1226+
name: "Test Mode",
1227+
roleDefinition: "Updated role definition",
1228+
groups: ["read"] as const,
1229+
},
1230+
],
12291231
}),
12301232
dispose: jest.fn(),
12311233
} as any
@@ -1251,27 +1253,29 @@ describe("ClineProvider", () => {
12511253
)
12521254

12531255
// Verify state was updated
1254-
expect(mockContext.globalState.update).toHaveBeenCalledWith(
1255-
"customModes",
1256-
expect.objectContaining({
1257-
"test-mode": expect.objectContaining({
1256+
expect(mockContext.globalState.update).toHaveBeenCalledWith("customModes", {
1257+
customModes: [
1258+
expect.objectContaining({
12581259
slug: "test-mode",
12591260
roleDefinition: "Updated role definition",
12601261
}),
1261-
}),
1262-
)
1262+
],
1263+
})
12631264

12641265
// Verify state was posted to webview
1266+
// Verify state was posted to webview with correct format
12651267
expect(mockPostMessage).toHaveBeenCalledWith(
12661268
expect.objectContaining({
12671269
type: "state",
12681270
state: expect.objectContaining({
1269-
customModes: expect.objectContaining({
1270-
"test-mode": expect.objectContaining({
1271-
slug: "test-mode",
1272-
roleDefinition: "Updated role definition",
1273-
}),
1274-
}),
1271+
customModes: {
1272+
customModes: [
1273+
expect.objectContaining({
1274+
slug: "test-mode",
1275+
roleDefinition: "Updated role definition",
1276+
}),
1277+
],
1278+
},
12751279
}),
12761280
}),
12771281
)

webview-ui/src/components/prompts/PromptsView.tsx

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ModeConfig,
2020
GroupEntry,
2121
} from "../../../../src/shared/modes"
22+
import { CustomModeSchema } from "../../../../src/core/config/CustomModesSchema"
2223
import {
2324
supportPrompt,
2425
SupportPromptType,
@@ -157,15 +158,34 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
157158
const [newModeGroups, setNewModeGroups] = useState<GroupEntry[]>(availableGroups)
158159
const [newModeSource, setNewModeSource] = useState<ModeSource>("global")
159160

161+
// Field-specific error states
162+
const [nameError, setNameError] = useState<string>("")
163+
const [slugError, setSlugError] = useState<string>("")
164+
const [roleDefinitionError, setRoleDefinitionError] = useState<string>("")
165+
const [groupsError, setGroupsError] = useState<string>("")
166+
167+
// Helper to reset form state
168+
const resetFormState = useCallback(() => {
169+
// Reset form fields
170+
setNewModeName("")
171+
setNewModeSlug("")
172+
setNewModeGroups(availableGroups)
173+
setNewModeRoleDefinition("")
174+
setNewModeCustomInstructions("")
175+
setNewModeSource("global")
176+
// Reset error states
177+
setNameError("")
178+
setSlugError("")
179+
setRoleDefinitionError("")
180+
setGroupsError("")
181+
}, [])
182+
160183
// Reset form fields when dialog opens
161184
useEffect(() => {
162185
if (isCreateModeDialogOpen) {
163-
setNewModeGroups(availableGroups)
164-
setNewModeRoleDefinition("")
165-
setNewModeCustomInstructions("")
166-
setNewModeSource("global")
186+
resetFormState()
167187
}
168-
}, [isCreateModeDialogOpen])
188+
}, [isCreateModeDialogOpen, resetFormState])
169189

170190
// Helper function to generate a unique slug from a name
171191
const generateSlug = useCallback((name: string, attempt = 0): string => {
@@ -186,26 +206,52 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
186206
)
187207

188208
const handleCreateMode = useCallback(() => {
189-
if (!newModeName.trim() || !newModeSlug.trim()) return
209+
// Clear previous errors
210+
setNameError("")
211+
setSlugError("")
212+
setRoleDefinitionError("")
213+
setGroupsError("")
190214

191215
const source = newModeSource
192216
const newMode: ModeConfig = {
193217
slug: newModeSlug,
194218
name: newModeName,
195-
roleDefinition: newModeRoleDefinition.trim() || "",
219+
roleDefinition: newModeRoleDefinition.trim(),
196220
customInstructions: newModeCustomInstructions.trim() || undefined,
197221
groups: newModeGroups,
198222
source,
199223
}
224+
225+
// Validate the mode against the schema
226+
const result = CustomModeSchema.safeParse(newMode)
227+
if (!result.success) {
228+
// Map Zod errors to specific fields
229+
result.error.errors.forEach((error) => {
230+
const field = error.path[0] as string
231+
const message = error.message
232+
233+
switch (field) {
234+
case "name":
235+
setNameError(message)
236+
break
237+
case "slug":
238+
setSlugError(message)
239+
break
240+
case "roleDefinition":
241+
setRoleDefinitionError(message)
242+
break
243+
case "groups":
244+
setGroupsError(message)
245+
break
246+
}
247+
})
248+
return
249+
}
250+
200251
updateCustomMode(newModeSlug, newMode)
201252
switchMode(newModeSlug)
202253
setIsCreateModeDialogOpen(false)
203-
setNewModeName("")
204-
setNewModeSlug("")
205-
setNewModeRoleDefinition("")
206-
setNewModeCustomInstructions("")
207-
setNewModeGroups(availableGroups)
208-
setNewModeSource("global")
254+
resetFormState()
209255
// eslint-disable-next-line react-hooks/exhaustive-deps
210256
}, [
211257
newModeName,
@@ -431,7 +477,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
431477

432478
<div className="mt-5">
433479
<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mb-3">
434-
<h3 className="text-vscode-foreground m-0">Mode-Specific Prompts</h3>
480+
<h3 className="text-vscode-foreground m-0">Modes</h3>
435481
<div className="flex gap-2">
436482
<VSCodeButton appearance="icon" onClick={openCreateModeDialog} title="Create new mode">
437483
<span className="codicon codicon-add"></span>
@@ -727,7 +773,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
727773
alignItems: "center",
728774
marginBottom: "4px",
729775
}}>
730-
<div style={{ fontWeight: "bold" }}>Mode-specific Custom Instructions</div>
776+
<div style={{ fontWeight: "bold" }}>Mode-specific Custom Instructions (optional)</div>
731777
{!findModeBySlug(mode, customModes) && (
732778
<VSCodeButton
733779
appearance="icon"
@@ -1069,6 +1115,9 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
10691115
}}
10701116
style={{ width: "100%" }}
10711117
/>
1118+
{nameError && (
1119+
<div className="text-xs text-vscode-errorForeground mt-1">{nameError}</div>
1120+
)}
10721121
</div>
10731122
<div style={{ marginBottom: "16px" }}>
10741123
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Slug</div>
@@ -1091,6 +1140,9 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
10911140
The slug is used in URLs and file names. It should be lowercase and contain only
10921141
letters, numbers, and hyphens.
10931142
</div>
1143+
{slugError && (
1144+
<div className="text-xs text-vscode-errorForeground mt-1">{slugError}</div>
1145+
)}
10941146
</div>
10951147
<div style={{ marginBottom: "16px" }}>
10961148
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Save Location</div>
@@ -1147,6 +1199,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
11471199
resize="vertical"
11481200
style={{ width: "100%" }}
11491201
/>
1202+
{roleDefinitionError && (
1203+
<div className="text-xs text-vscode-errorForeground mt-1">
1204+
{roleDefinitionError}
1205+
</div>
1206+
)}
11501207
</div>
11511208
<div style={{ marginBottom: "16px" }}>
11521209
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Available Tools</div>
@@ -1184,9 +1241,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
11841241
</VSCodeCheckbox>
11851242
))}
11861243
</div>
1244+
{groupsError && (
1245+
<div className="text-xs text-vscode-errorForeground mt-1">{groupsError}</div>
1246+
)}
11871247
</div>
11881248
<div style={{ marginBottom: "16px" }}>
1189-
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Custom Instructions</div>
1249+
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>
1250+
Custom Instructions (optional)
1251+
</div>
11901252
<div
11911253
style={{
11921254
fontSize: "13px",
@@ -1219,10 +1281,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
12191281
backgroundColor: "var(--vscode-editor-background)",
12201282
}}>
12211283
<VSCodeButton onClick={() => setIsCreateModeDialogOpen(false)}>Cancel</VSCodeButton>
1222-
<VSCodeButton
1223-
appearance="primary"
1224-
onClick={handleCreateMode}
1225-
disabled={!newModeName.trim() || !newModeSlug.trim()}>
1284+
<VSCodeButton appearance="primary" onClick={handleCreateMode}>
12261285
Create Mode
12271286
</VSCodeButton>
12281287
</div>

webview-ui/src/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
--color-vscode-notifications-background: var(--vscode-notifications-background);
8585
--color-vscode-notifications-border: var(--vscode-notifications-border);
8686
--color-vscode-descriptionForeground: var(--vscode-descriptionForeground);
87+
--color-vscode-errorForeground: var(--vscode-errorForeground);
8788
}
8889

8990
@layer base {

0 commit comments

Comments
 (0)