| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677 |
- // npx vitest core/tools/__tests__/newTaskTool.spec.ts
- import type { AskApproval, HandleError, NativeToolArgs, ToolUse } from "../../../shared/tools"
- // Mock vscode module
- vi.mock("vscode", () => ({
- workspace: {
- getConfiguration: vi.fn(() => ({
- get: vi.fn(),
- })),
- },
- }))
- // Mock Package module
- vi.mock("../../../shared/package", () => ({
- Package: {
- name: "roo-cline",
- publisher: "RooVeterinaryInc",
- version: "1.0.0",
- outputChannel: "Roo-Code",
- },
- }))
- // Mock other modules first - these are hoisted to the top
- vi.mock("../../../shared/modes", () => ({
- getModeBySlug: vi.fn(),
- defaultModeSlug: "ask",
- }))
- vi.mock("../../prompts/responses", () => ({
- formatResponse: {
- toolError: vi.fn((msg: string) => `Tool Error: ${msg}`),
- },
- }))
- vi.mock("../updateTodoListTool", () => ({
- parseMarkdownChecklist: vi.fn((md: string) => {
- // Simple mock implementation
- const lines = md.split("\n").filter((line) => line.trim())
- return lines.map((line, index) => {
- let status = "pending"
- let content = line
- if (line.includes("[x]") || line.includes("[X]")) {
- status = "completed"
- content = line.replace(/^\[x\]\s*/i, "")
- } else if (line.includes("[-]") || line.includes("[~]")) {
- status = "in_progress"
- content = line.replace(/^\[-\]\s*/, "").replace(/^\[~\]\s*/, "")
- } else {
- content = line.replace(/^\[\s*\]\s*/, "")
- }
- return {
- id: `todo-${index}`,
- content,
- status,
- }
- })
- }),
- }))
- // Define a minimal type for the resolved value
- type MockClineInstance = { taskId: string }
- // Mock dependencies after modules are mocked
- const mockAskApproval = vi.fn<AskApproval>()
- const mockHandleError = vi.fn<HandleError>()
- const mockPushToolResult = vi.fn()
- const mockEmit = vi.fn()
- const mockRecordToolError = vi.fn()
- const mockSayAndCreateMissingParamError = vi.fn()
- const mockStartSubtask = vi
- .fn<(message: string, todoItems: any[], mode: string) => Promise<MockClineInstance>>()
- .mockResolvedValue({ taskId: "mock-subtask-id" })
- // Adapter to satisfy legacy expectations while exercising new delegation path
- const mockDelegateParentAndOpenChild = vi.fn(
- async (args: { parentTaskId: string; message: string; initialTodos: any[]; mode: string }) => {
- // Call legacy spy so existing expectations still pass
- await mockStartSubtask(args.message, args.initialTodos, args.mode)
- return { taskId: "child-1" }
- },
- )
- const mockCheckpointSave = vi.fn()
- // Mock the Cline instance and its methods/properties
- const mockCline = {
- ask: vi.fn(),
- sayAndCreateMissingParamError: mockSayAndCreateMissingParamError,
- emit: mockEmit,
- recordToolError: mockRecordToolError,
- consecutiveMistakeCount: 0,
- isPaused: false,
- pausedModeSlug: "ask",
- taskId: "mock-parent-task-id",
- enableCheckpoints: false,
- checkpointSave: mockCheckpointSave,
- startSubtask: mockStartSubtask,
- providerRef: {
- deref: vi.fn(() => ({
- getState: vi.fn(() => ({ customModes: [], mode: "ask" })),
- handleModeSwitch: vi.fn(),
- delegateParentAndOpenChild: mockDelegateParentAndOpenChild,
- })),
- },
- }
- // Import the class to test AFTER mocks are set up
- import { newTaskTool } from "../NewTaskTool"
- import { getModeBySlug } from "../../../shared/modes"
- import * as vscode from "vscode"
- const withNativeArgs = (block: ToolUse<"new_task">): ToolUse<"new_task"> => ({
- ...block,
- // Native tool calling: `nativeArgs` is the source of truth for tool execution.
- // These tests intentionally exercise missing-param behavior, so we allow undefined
- // values and let the tool's runtime validation handle it.
- nativeArgs: {
- mode: block.params.mode,
- message: block.params.message,
- todos: block.params.todos,
- } as unknown as NativeToolArgs["new_task"],
- })
- describe("newTaskTool", () => {
- beforeEach(() => {
- // Reset mocks before each test
- vi.clearAllMocks()
- mockAskApproval.mockResolvedValue(true) // Default to approved
- vi.mocked(getModeBySlug).mockReturnValue({
- slug: "code",
- name: "Code Mode",
- roleDefinition: "Test role definition",
- groups: ["command", "read", "edit"],
- }) // Default valid mode
- mockCline.consecutiveMistakeCount = 0
- mockCline.isPaused = false
- // Default: VSCode setting is disabled
- const mockGet = vi.fn().mockReturnValue(false)
- vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
- get: mockGet,
- } as any)
- })
- it("should correctly un-escape \\\\@ to \\@ in the message passed to the new task", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use", // Add required 'type' property
- name: "new_task", // Correct property name
- params: {
- mode: "code",
- message: "Review this: \\\\@file1.txt and also \\\\\\\\@file2.txt", // Input with \\@ and \\\\@
- todos: "[ ] First task\n[ ] Second task",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Verify askApproval was called
- expect(mockAskApproval).toHaveBeenCalled()
- // Verify the message passed to startSubtask reflects the code's behavior in unit tests
- expect(mockStartSubtask).toHaveBeenCalledWith(
- "Review this: \\@file1.txt and also \\\\\\@file2.txt", // Unit Test Expectation: \\@ -> \@, \\\\@ -> \\\\@
- expect.arrayContaining([
- expect.objectContaining({ content: "First task" }),
- expect.objectContaining({ content: "Second task" }),
- ]),
- "code",
- )
- // Verify side effects
- expect(mockPushToolResult).not.toHaveBeenCalled()
- })
- it("should not un-escape single escaped \@", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use", // Add required 'type' property
- name: "new_task", // Correct property name
- params: {
- mode: "code",
- message: "This is already unescaped: \\@file1.txt",
- todos: "[ ] Test todo",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- expect(mockStartSubtask).toHaveBeenCalledWith(
- "This is already unescaped: \\@file1.txt", // Expected: \@ remains \@
- expect.any(Array),
- "code",
- )
- })
- it("should not un-escape non-escaped @", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use", // Add required 'type' property
- name: "new_task", // Correct property name
- params: {
- mode: "code",
- message: "A normal mention @file1.txt",
- todos: "[ ] Test todo",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- expect(mockStartSubtask).toHaveBeenCalledWith(
- "A normal mention @file1.txt", // Expected: @ remains @
- expect.any(Array),
- "code",
- )
- })
- it("should handle mixed escaping scenarios", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use", // Add required 'type' property
- name: "new_task", // Correct property name
- params: {
- mode: "code",
- message: "Mix: @file0.txt, \\@file1.txt, \\\\@file2.txt, \\\\\\\\@file3.txt",
- todos: "[ ] Test todo",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- expect(mockStartSubtask).toHaveBeenCalledWith(
- "Mix: @file0.txt, \\@file1.txt, \\@file2.txt, \\\\\\@file3.txt", // Unit Test Expectation: @->@, \@->\@, \\@->\@, \\\\@->\\\\@
- expect.any(Array),
- "code",
- )
- })
- it("should handle missing todos parameter gracefully (backward compatibility)", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message",
- // todos missing - should work for backward compatibility
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Should NOT error when todos is missing
- expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos")
- expect(mockCline.consecutiveMistakeCount).toBe(0)
- expect(mockCline.recordToolError).not.toHaveBeenCalledWith("new_task")
- // Should create task with empty todos array
- expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code")
- // Should complete successfully
- expect(mockPushToolResult).not.toHaveBeenCalled()
- })
- it("should work with todos parameter when provided", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message with todos",
- todos: "[ ] First task\n[ ] Second task",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Should parse and include todos when provided
- expect(mockStartSubtask).toHaveBeenCalledWith(
- "Test message with todos",
- expect.arrayContaining([
- expect.objectContaining({ content: "First task" }),
- expect.objectContaining({ content: "Second task" }),
- ]),
- "code",
- )
- expect(mockPushToolResult).not.toHaveBeenCalled()
- })
- it("should error when mode parameter is missing", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- // mode missing
- message: "Test message",
- todos: "[ ] Test todo",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "mode")
- expect(mockCline.consecutiveMistakeCount).toBe(1)
- expect(mockCline.recordToolError).toHaveBeenCalledWith("new_task")
- })
- it("should error when message parameter is missing", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- // message missing
- todos: "[ ] Test todo",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "message")
- expect(mockCline.consecutiveMistakeCount).toBe(1)
- expect(mockCline.recordToolError).toHaveBeenCalledWith("new_task")
- })
- it("should parse todos with different statuses correctly", async () => {
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message",
- todos: "[ ] Pending task\n[x] Completed task\n[-] In progress task",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- expect(mockStartSubtask).toHaveBeenCalledWith(
- "Test message",
- expect.arrayContaining([
- expect.objectContaining({ content: "Pending task", status: "pending" }),
- expect.objectContaining({ content: "Completed task", status: "completed" }),
- expect.objectContaining({ content: "In progress task", status: "in_progress" }),
- ]),
- "code",
- )
- })
- describe("VSCode setting: newTaskRequireTodos", () => {
- it("should NOT require todos when VSCode setting is disabled (default)", async () => {
- // Ensure VSCode setting is disabled
- const mockGet = vi.fn().mockReturnValue(false)
- vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
- get: mockGet,
- } as any)
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message",
- // todos missing - should work when setting is disabled
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Should NOT error when todos is missing and setting is disabled
- expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos")
- expect(mockCline.consecutiveMistakeCount).toBe(0)
- expect(mockCline.recordToolError).not.toHaveBeenCalledWith("new_task")
- // Should create task with empty todos array
- expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code")
- // Should complete successfully
- expect(mockPushToolResult).not.toHaveBeenCalled()
- })
- it("should REQUIRE todos when VSCode setting is enabled", async () => {
- // Enable VSCode setting
- const mockGet = vi.fn().mockReturnValue(true)
- vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
- get: mockGet,
- } as any)
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message",
- // todos missing - should error when setting is enabled
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Should error when todos is missing and setting is enabled
- expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "todos")
- expect(mockCline.consecutiveMistakeCount).toBe(1)
- expect(mockCline.recordToolError).toHaveBeenCalledWith("new_task")
- // Should NOT create task
- expect(mockStartSubtask).not.toHaveBeenCalled()
- expect(mockPushToolResult).not.toHaveBeenCalledWith(
- expect.stringContaining("Successfully created new task"),
- )
- })
- it("should work with todos when VSCode setting is enabled", async () => {
- // Enable VSCode setting
- const mockGet = vi.fn().mockReturnValue(true)
- vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
- get: mockGet,
- } as any)
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message",
- todos: "[ ] First task\n[ ] Second task",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Should NOT error when todos is provided and setting is enabled
- expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos")
- expect(mockCline.consecutiveMistakeCount).toBe(0)
- // Should create task with parsed todos
- expect(mockStartSubtask).toHaveBeenCalledWith(
- "Test message",
- expect.arrayContaining([
- expect.objectContaining({ content: "First task" }),
- expect.objectContaining({ content: "Second task" }),
- ]),
- "code",
- )
- // Should complete successfully
- expect(mockPushToolResult).not.toHaveBeenCalled()
- })
- it("should work with empty todos string when VSCode setting is enabled", async () => {
- // Enable VSCode setting
- const mockGet = vi.fn().mockReturnValue(true)
- vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
- get: mockGet,
- } as any)
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message",
- todos: "", // Empty string should be accepted
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Should NOT error when todos is empty string and setting is enabled
- expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos")
- expect(mockCline.consecutiveMistakeCount).toBe(0)
- // Should create task with empty todos array
- expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code")
- // Should complete successfully
- expect(mockPushToolResult).not.toHaveBeenCalled()
- })
- it("should check VSCode setting with Package.name configuration key", async () => {
- const mockGet = vi.fn().mockReturnValue(false)
- const mockGetConfiguration = vi.fn().mockReturnValue({
- get: mockGet,
- } as any)
- vi.mocked(vscode.workspace.getConfiguration).mockImplementation(mockGetConfiguration)
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Verify that VSCode configuration was accessed with Package.name
- expect(mockGetConfiguration).toHaveBeenCalledWith("roo-cline")
- expect(mockGet).toHaveBeenCalledWith("newTaskRequireTodos", false)
- })
- it("should use current Package.name value (roo-code-nightly) when accessing VSCode configuration", async () => {
- // Arrange: capture calls to VSCode configuration and ensure we can assert the namespace
- const mockGet = vi.fn().mockReturnValue(false)
- const mockGetConfiguration = vi.fn().mockReturnValue({
- get: mockGet,
- } as any)
- vi.mocked(vscode.workspace.getConfiguration).mockImplementation(mockGetConfiguration)
- // Mutate the mocked Package.name dynamically to simulate a different build variant
- const pkg = await import("../../../shared/package")
- ;(pkg.Package as any).name = "roo-code-nightly"
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Test message",
- },
- partial: false,
- }
- await newTaskTool.handle(mockCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Assert: configuration was read using the dynamic nightly namespace
- expect(mockGetConfiguration).toHaveBeenCalledWith("roo-code-nightly")
- expect(mockGet).toHaveBeenCalledWith("newTaskRequireTodos", false)
- })
- })
- // Add more tests for error handling (invalid mode, approval denied) if needed
- })
- describe("newTaskTool delegation flow", () => {
- it("delegates to provider and does not call legacy startSubtask", async () => {
- // Arrange: stub provider delegation
- const providerSpy = {
- getState: vi.fn().mockResolvedValue({
- mode: "ask",
- experiments: {},
- }),
- delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }),
- handleModeSwitch: vi.fn(),
- } as any
- // Use a fresh local cline instance to avoid cross-test interference
- const localStartSubtask = vi.fn()
- const localEmit = vi.fn()
- const localCline = {
- ask: vi.fn(),
- sayAndCreateMissingParamError: mockSayAndCreateMissingParamError,
- emit: localEmit,
- recordToolError: mockRecordToolError,
- consecutiveMistakeCount: 0,
- isPaused: false,
- pausedModeSlug: "ask",
- taskId: "mock-parent-task-id",
- enableCheckpoints: false,
- checkpointSave: mockCheckpointSave,
- startSubtask: localStartSubtask,
- providerRef: {
- deref: vi.fn(() => providerSpy),
- },
- }
- const block: ToolUse<"new_task"> = {
- type: "tool_use",
- name: "new_task",
- params: {
- mode: "code",
- message: "Do something",
- // no todos -> should default to []
- },
- partial: false,
- }
- // Act
- await newTaskTool.handle(localCline as any, withNativeArgs(block), {
- askApproval: mockAskApproval,
- handleError: mockHandleError,
- pushToolResult: mockPushToolResult,
- })
- // Assert: provider method called with correct params
- expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({
- parentTaskId: "mock-parent-task-id",
- message: "Do something",
- initialTodos: [],
- mode: "code",
- })
- // Assert: legacy path not used
- expect(localStartSubtask).not.toHaveBeenCalled()
- // Assert: no pause/unpause events emitted in delegation path
- const pauseEvents = (localEmit as any).mock.calls.filter(
- (c: any[]) => c[0] === "taskPaused" || c[0] === "taskUnpaused",
- )
- expect(pauseEvents.length).toBe(0)
- // Assert: tool result reflects delegation
- expect(mockPushToolResult).not.toHaveBeenCalled()
- })
- })
|