Skip to content

Commit bff1e76

Browse files
test(tools): add unit tests for SwitchModeTool (#211)
* test(tools): add unit tests for SwitchModeTool - Add comprehensive test suite for SwitchModeTool (18 tests) - Cover mode slug validation (valid/invalid/missing) - Test error propagation from mode loading - Validate approval flow with correct params - Test mode switching delegation to Controller - Verify resolve() returns correct messages for both paths - Follow existing test patterns from ask.spec.ts * test: tighten switch mode review feedback cases --------- Co-authored-by: Roomote <roomote@roocode.com>
1 parent eaaa254 commit bff1e76

1 file changed

Lines changed: 357 additions & 0 deletions

File tree

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
3+
import type { Task } from "../../task/Task"
4+
import type { ToolUse } from "../../../shared/tools"
5+
import type { ToolCallbacks } from "../BaseTool"
6+
import { switchModeTool } from "../SwitchModeTool"
7+
import { formatResponse } from "../../prompts/responses"
8+
9+
// Mock delay to avoid actual waits in tests
10+
vi.mock("delay", () => ({
11+
default: vi.fn().mockResolvedValue(undefined),
12+
}))
13+
14+
// Mock the modes module
15+
vi.mock("../../../shared/modes", async (importOriginal) => {
16+
const actual = await importOriginal<typeof import("../../../shared/modes")>()
17+
return {
18+
...actual,
19+
defaultModeSlug: "code",
20+
getModeBySlug: vi.fn((slug: string, customModes?: Array<{ slug: string; name: string }>) => {
21+
const builtInModes: Record<string, { slug: string; name: string }> = {
22+
code: { slug: "code", name: "Code" },
23+
architect: { slug: "architect", name: "Architect" },
24+
ask: { slug: "ask", name: "Ask" },
25+
}
26+
return customModes?.find((mode) => mode.slug === slug) ?? builtInModes[slug]
27+
}),
28+
}
29+
})
30+
31+
describe("SwitchModeTool", () => {
32+
let mockTask: Task
33+
let mockCallbacks: ToolCallbacks
34+
let mockHandleModeSwitch: ReturnType<typeof vi.fn>
35+
let mockGetState: ReturnType<typeof vi.fn>
36+
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
40+
mockHandleModeSwitch = vi.fn().mockResolvedValue(undefined)
41+
mockGetState = vi.fn().mockResolvedValue({ mode: "code", customModes: [] })
42+
43+
mockTask = {
44+
consecutiveMistakeCount: 0,
45+
recordToolError: vi.fn(),
46+
didToolFailInCurrentTurn: false,
47+
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"),
48+
ask: vi.fn().mockResolvedValue({}),
49+
providerRef: {
50+
deref: vi.fn().mockReturnValue({
51+
getState: mockGetState,
52+
handleModeSwitch: mockHandleModeSwitch,
53+
}),
54+
},
55+
} as unknown as Task
56+
57+
mockCallbacks = {
58+
askApproval: vi.fn().mockResolvedValue(true),
59+
handleError: vi.fn(),
60+
pushToolResult: vi.fn(),
61+
}
62+
})
63+
64+
function createBlock(params: { mode_slug?: string; reason?: string }, partial = false): ToolUse<"switch_mode"> {
65+
return {
66+
type: "tool_use" as const,
67+
name: "switch_mode" as const,
68+
params,
69+
partial,
70+
nativeArgs: {
71+
mode_slug: params.mode_slug ?? "",
72+
reason: params.reason ?? "",
73+
},
74+
} as unknown as ToolUse<"switch_mode">
75+
}
76+
77+
// ===== Parameter validation tests =====
78+
79+
it("should handle missing mode_slug parameter", async () => {
80+
const block = createBlock({ mode_slug: "" })
81+
82+
await switchModeTool.handle(mockTask, block, mockCallbacks)
83+
84+
expect(mockTask.consecutiveMistakeCount).toBe(1)
85+
expect(mockTask.recordToolError).toHaveBeenCalledWith("switch_mode")
86+
expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("switch_mode", "mode_slug")
87+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Missing parameter error")
88+
})
89+
90+
it("should handle missing reason parameter without error (reason is optional)", async () => {
91+
const block = createBlock({ mode_slug: "architect", reason: "" })
92+
93+
await switchModeTool.handle(mockTask, block, mockCallbacks)
94+
95+
// Should not treat missing reason as an error — it's optional
96+
expect(mockTask.sayAndCreateMissingParamError).not.toHaveBeenCalled()
97+
})
98+
99+
// ===== Invalid mode tests =====
100+
101+
it("should handle invalid mode slug", async () => {
102+
const block = createBlock({ mode_slug: "nonexistent-mode", reason: "testing" })
103+
104+
await switchModeTool.handle(mockTask, block, mockCallbacks)
105+
106+
expect(mockTask.recordToolError).toHaveBeenCalledWith("switch_mode")
107+
expect(mockTask.didToolFailInCurrentTurn).toBe(true)
108+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
109+
formatResponse.toolError("Invalid mode: nonexistent-mode"),
110+
)
111+
// Should NOT attempt to switch or ask approval
112+
expect(mockCallbacks.askApproval).not.toHaveBeenCalled()
113+
expect(mockHandleModeSwitch).not.toHaveBeenCalled()
114+
})
115+
116+
// ===== Already in mode tests =====
117+
118+
it("should handle switching to the same mode", async () => {
119+
// Current mode is "code" (from mockGetState)
120+
const block = createBlock({ mode_slug: "code", reason: "already here" })
121+
122+
await switchModeTool.handle(mockTask, block, mockCallbacks)
123+
124+
expect(mockTask.recordToolError).toHaveBeenCalledWith("switch_mode")
125+
expect(mockTask.didToolFailInCurrentTurn).toBe(true)
126+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Already in Code mode.")
127+
// Should NOT ask approval or switch
128+
expect(mockCallbacks.askApproval).not.toHaveBeenCalled()
129+
expect(mockHandleModeSwitch).not.toHaveBeenCalled()
130+
})
131+
132+
// ===== Approval denial tests =====
133+
134+
it("should handle user denying the approval", async () => {
135+
;(mockCallbacks.askApproval as ReturnType<typeof vi.fn>).mockResolvedValue(false)
136+
137+
const block = createBlock({ mode_slug: "architect", reason: "need architecture view" })
138+
139+
await switchModeTool.handle(mockTask, block, mockCallbacks)
140+
141+
// Should have asked for approval
142+
expect(mockCallbacks.askApproval).toHaveBeenCalledWith(
143+
"tool",
144+
JSON.stringify({ tool: "switchMode", mode: "architect", reason: "need architecture view" }),
145+
)
146+
// But should NOT switch mode or push result
147+
expect(mockHandleModeSwitch).not.toHaveBeenCalled()
148+
expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled()
149+
})
150+
151+
// ===== Happy path tests =====
152+
153+
it("should successfully switch mode with reason", async () => {
154+
const block = createBlock({ mode_slug: "architect", reason: "need to plan architecture" })
155+
156+
await switchModeTool.handle(mockTask, block, mockCallbacks)
157+
158+
// Should have asked for approval with correct message
159+
expect(mockCallbacks.askApproval).toHaveBeenCalledWith(
160+
"tool",
161+
JSON.stringify({
162+
tool: "switchMode",
163+
mode: "architect",
164+
reason: "need to plan architecture",
165+
}),
166+
)
167+
168+
// Should have called handleModeSwitch with the target slug
169+
expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect")
170+
171+
// Should have pushed success result
172+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
173+
"Successfully switched from Code mode to Architect mode because: need to plan architecture.",
174+
)
175+
})
176+
177+
it("should successfully switch mode without reason", async () => {
178+
const block = createBlock({ mode_slug: "ask", reason: "" })
179+
180+
await switchModeTool.handle(mockTask, block, mockCallbacks)
181+
182+
expect(mockCallbacks.askApproval).toHaveBeenCalledWith(
183+
"tool",
184+
JSON.stringify({ tool: "switchMode", mode: "ask", reason: "" }),
185+
)
186+
187+
expect(mockHandleModeSwitch).toHaveBeenCalledWith("ask")
188+
189+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Successfully switched from Code mode to Ask mode.")
190+
})
191+
192+
it("should reset consecutive mistake count on success", async () => {
193+
mockTask.consecutiveMistakeCount = 3
194+
195+
const block = createBlock({ mode_slug: "architect", reason: "test" })
196+
197+
await switchModeTool.handle(mockTask, block, mockCallbacks)
198+
199+
expect(mockTask.consecutiveMistakeCount).toBe(0)
200+
})
201+
202+
// ===== Edge case: getState throws an error =====
203+
204+
it("should handle getState throwing an error", async () => {
205+
const stateError = new Error("Provider state unavailable")
206+
mockGetState.mockRejectedValue(stateError)
207+
208+
const block = createBlock({ mode_slug: "architect", reason: "test" })
209+
210+
await switchModeTool.handle(mockTask, block, mockCallbacks)
211+
212+
// The error should be caught by the try/catch and reported via handleError
213+
expect(mockCallbacks.handleError).toHaveBeenCalledWith("switching mode", stateError)
214+
// Should NOT have asked for approval or attempted switch
215+
expect(mockCallbacks.askApproval).not.toHaveBeenCalled()
216+
expect(mockHandleModeSwitch).not.toHaveBeenCalled()
217+
})
218+
219+
// ===== Edge case: getState returns null =====
220+
221+
it("should use defaultModeSlug when getState returns null", async () => {
222+
mockGetState.mockResolvedValue(null)
223+
224+
const block = createBlock({ mode_slug: "architect", reason: "test" })
225+
226+
await switchModeTool.handle(mockTask, block, mockCallbacks)
227+
228+
// Should fall back to defaultModeSlug ("code") and succeed
229+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
230+
"Successfully switched from Code mode to Architect mode because: test.",
231+
)
232+
})
233+
234+
// ===== handleModeSwitch failure =====
235+
236+
it("should handle handleModeSwitch throwing an error", async () => {
237+
const switchError = new Error("Failed to switch mode")
238+
mockHandleModeSwitch.mockRejectedValue(switchError)
239+
240+
const block = createBlock({ mode_slug: "architect", reason: "test" })
241+
242+
await switchModeTool.handle(mockTask, block, mockCallbacks)
243+
244+
// Should have asked for approval first
245+
expect(mockCallbacks.askApproval).toHaveBeenCalled()
246+
// Should have called handleModeSwitch (which throws)
247+
expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect")
248+
// Error should be caught and reported
249+
expect(mockCallbacks.handleError).toHaveBeenCalledWith("switching mode", switchError)
250+
})
251+
252+
// ===== Partial message handling =====
253+
254+
it("should handle partial messages during streaming", async () => {
255+
const block = createBlock({ mode_slug: "architect", reason: "streaming test" }, true)
256+
257+
await switchModeTool.handle(mockTask, block, mockCallbacks)
258+
259+
// Should send partial message via task.ask
260+
expect(mockTask.ask).toHaveBeenCalledWith(
261+
"tool",
262+
JSON.stringify({
263+
tool: "switchMode",
264+
mode: "architect",
265+
reason: "streaming test",
266+
}),
267+
true,
268+
)
269+
// Should NOT execute the actual switch
270+
expect(mockCallbacks.askApproval).not.toHaveBeenCalled()
271+
expect(mockHandleModeSwitch).not.toHaveBeenCalled()
272+
})
273+
274+
it("should handle partial messages with empty parameters", async () => {
275+
const block = createBlock(
276+
{ mode_slug: undefined as unknown as string, reason: undefined as unknown as string },
277+
true,
278+
)
279+
280+
await switchModeTool.handle(mockTask, block, mockCallbacks)
281+
282+
expect(mockTask.ask).toHaveBeenCalledWith(
283+
"tool",
284+
JSON.stringify({
285+
tool: "switchMode",
286+
mode: "",
287+
reason: "",
288+
}),
289+
true,
290+
)
291+
})
292+
293+
// ===== Custom mode support =====
294+
295+
it("should switch to a custom mode", async () => {
296+
mockGetState.mockResolvedValue({
297+
mode: "code",
298+
customModes: [{ slug: "custom-mode", name: "Custom Mode" }],
299+
})
300+
301+
const block = createBlock({ mode_slug: "custom-mode", reason: "testing custom modes" })
302+
303+
await switchModeTool.handle(mockTask, block, mockCallbacks)
304+
305+
expect(mockCallbacks.askApproval).toHaveBeenCalled()
306+
expect(mockHandleModeSwitch).toHaveBeenCalledWith("custom-mode")
307+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
308+
"Successfully switched from Code mode to Custom Mode mode because: testing custom modes.",
309+
)
310+
})
311+
312+
// ===== Message format tests =====
313+
314+
it("should format the approval message correctly", async () => {
315+
const block = createBlock({ mode_slug: "ask", reason: "quick question" })
316+
317+
await switchModeTool.handle(mockTask, block, mockCallbacks)
318+
319+
const expectedMessage = JSON.stringify({
320+
tool: "switchMode",
321+
mode: "ask",
322+
reason: "quick question",
323+
})
324+
325+
expect(mockCallbacks.askApproval).toHaveBeenCalledWith("tool", expectedMessage)
326+
})
327+
328+
// ===== getState with custom modes =====
329+
330+
it("should read current mode from providerRef state", async () => {
331+
// Set current mode to "architect"
332+
mockGetState.mockResolvedValue({ mode: "architect", customModes: [] })
333+
334+
const block = createBlock({ mode_slug: "code", reason: "switching back" })
335+
336+
await switchModeTool.handle(mockTask, block, mockCallbacks)
337+
338+
expect(mockHandleModeSwitch).toHaveBeenCalledWith("code")
339+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
340+
"Successfully switched from Architect mode to Code mode because: switching back.",
341+
)
342+
})
343+
344+
it("should use defaultModeSlug when getState returns no mode", async () => {
345+
mockGetState.mockResolvedValue({})
346+
347+
const block = createBlock({ mode_slug: "ask", reason: "test" })
348+
349+
await switchModeTool.handle(mockTask, block, mockCallbacks)
350+
351+
// defaultModeSlug is "code" (from mock)
352+
// Should report switching from Code mode
353+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
354+
"Successfully switched from Code mode to Ask mode because: test.",
355+
)
356+
})
357+
})

0 commit comments

Comments
 (0)