Skip to content

Commit 560bbd3

Browse files
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
1 parent f25b102 commit 560bbd3

1 file changed

Lines changed: 381 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)