feat: add optional todos parameter to new_task tool

- Added todos parameter to NewTaskToolUse interface
- Updated new_task tool description with todos parameter documentation
- Modified newTaskTool.ts to extract and pass todos to initClineWithTask
- Updated ClineProvider.initClineWithTask to accept todos in options
- Enhanced Task constructor to parse initial todos from markdown format
- Added comprehensive tests for todo parsing functionality
- Fixed bug where empty string todos were not handled correctly
This commit is contained in:
Roo Code 2025-07-27 15:45:57 +00:00
parent 7a6e852248
commit 52a8aa6e10
7 changed files with 357 additions and 4 deletions

View file

@ -7,11 +7,18 @@ Description: This will let you create a new task instance in the chosen mode usi
Parameters:
- mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect").
- message: (required) The initial user message or instructions for this new task.
- todos: (optional) A markdown checklist of todo items to initialize the new task with. Use the same format as update_todo_list.
Usage:
<new_task>
<mode>your-mode-slug-here</mode>
<message>Your initial instructions here</message>
<todos>
[ ] First todo item
[ ] Second todo item
[x] Completed todo item
[-] In progress todo item
</todos>
</new_task>
Example:
@ -19,5 +26,18 @@ Example:
<mode>code</mode>
<message>Implement a new feature for the application.</message>
</new_task>
Example with todos:
<new_task>
<mode>code</mode>
<message>Implement user authentication</message>
<todos>
[ ] Set up authentication middleware
[ ] Create login endpoint
[ ] Create logout endpoint
[ ] Add session management
[ ] Write tests for authentication
</todos>
</new_task>
`
}

View file

@ -126,6 +126,7 @@ export type TaskOptions = {
parentTask?: Task
taskNumber?: number
onCreated?: (cline: Task) => void
todos?: string
}
export class Task extends EventEmitter<ClineEvents> {
@ -228,6 +229,7 @@ export class Task extends EventEmitter<ClineEvents> {
parentTask,
taskNumber = -1,
onCreated,
todos,
}: TaskOptions) {
super()
@ -294,6 +296,11 @@ export class Task extends EventEmitter<ClineEvents> {
this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit)
// Parse and initialize todos if provided
if (todos !== undefined) {
this.parseTodosFromMarkdown(todos)
}
onCreated?.(this)
if (startTask) {
@ -1956,4 +1963,42 @@ export class Task extends EventEmitter<ClineEvents> {
public get cwd() {
return this.workspacePath
}
// Todo parsing
private parseTodosFromMarkdown(todosMarkdown: string): void {
if (typeof todosMarkdown !== "string") {
return
}
this.todoList = []
if (!todosMarkdown) {
return
}
const lines = todosMarkdown
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
for (const line of lines) {
const match = line.match(/^\[\s*([ xX\-~])\s*\]\s+(.+)$/)
if (!match) continue
let status: "pending" | "in_progress" | "completed" = "pending"
if (match[1] === "x" || match[1] === "X") status = "completed"
else if (match[1] === "-" || match[1] === "~") status = "in_progress"
const id = crypto
.createHash("md5")
.update(match[2] + status)
.digest("hex")
this.todoList.push({
id,
content: match[2],
status,
})
}
}
}

View file

@ -389,6 +389,157 @@ describe("Cline", () => {
new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })
}).toThrow("Either historyItem or task/images must be provided")
})
it("should parse todos from markdown checklist when provided", () => {
const todosMarkdown = `[x] Design the architecture
[-] Write the code
[ ] Add tests
[ ] Update documentation`
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
todos: todosMarkdown,
startTask: false,
})
expect(task.todoList).toBeDefined()
expect(task.todoList).toHaveLength(4)
expect(task.todoList![0].content).toBe("Design the architecture")
expect(task.todoList![0].status).toBe("completed")
expect(task.todoList![1].content).toBe("Write the code")
expect(task.todoList![1].status).toBe("in_progress")
expect(task.todoList![2].content).toBe("Add tests")
expect(task.todoList![2].status).toBe("pending")
expect(task.todoList![3].content).toBe("Update documentation")
expect(task.todoList![3].status).toBe("pending")
})
it("should handle various markdown checklist formats", () => {
const todosMarkdown = `[X] Uppercase completed
[~] Alternative in-progress
[ ] Extra spaces
[x]No space after bracket`
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
todos: todosMarkdown,
startTask: false,
})
expect(task.todoList).toBeDefined()
expect(task.todoList).toHaveLength(3) // "No space after bracket" won't match the regex
expect(task.todoList![0].content).toBe("Uppercase completed")
expect(task.todoList![0].status).toBe("completed")
expect(task.todoList![1].content).toBe("Alternative in-progress")
expect(task.todoList![1].status).toBe("in_progress")
expect(task.todoList![2].content).toBe("Extra spaces")
expect(task.todoList![2].status).toBe("pending")
})
it("should handle empty todos parameter", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
todos: "",
startTask: false,
})
expect(task.todoList).toBeDefined()
expect(task.todoList).toHaveLength(0)
})
it("should handle undefined todos parameter", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
expect(task.todoList).toBeUndefined()
})
it("should handle todos with special characters", () => {
const todosMarkdown = `[x] Handle @mentions and $variables
[ ] Support | pipes and \\ backslashes
[-] Test "quotes" and 'apostrophes'`
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
todos: todosMarkdown,
startTask: false,
})
expect(task.todoList).toBeDefined()
expect(task.todoList).toHaveLength(3)
expect(task.todoList![0].content).toBe("Handle @mentions and $variables")
expect(task.todoList![1].content).toBe("Support | pipes and \\ backslashes")
expect(task.todoList![2].content).toBe("Test \"quotes\" and 'apostrophes'")
})
it("should generate unique IDs for todos", () => {
const todosMarkdown = `[ ] First task
[ ] Second task
[ ] Third task`
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
todos: todosMarkdown,
startTask: false,
})
expect(task.todoList).toBeDefined()
expect(task.todoList).toHaveLength(3)
// Check that all IDs are unique
const ids = task.todoList!.map((todo) => todo.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
// Check that IDs are valid MD5 hashes (32 hex characters)
ids.forEach((id) => {
expect(id).toMatch(/^[a-f0-9]{32}$/)
})
})
it("should ignore invalid checklist lines", () => {
const todosMarkdown = `[x] Valid task
This is not a checklist item
- [ ] This format is not supported
[x] Another valid task
Random text`
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
todos: todosMarkdown,
startTask: false,
})
expect(task.todoList).toBeDefined()
expect(task.todoList).toHaveLength(2)
expect(task.todoList![0].content).toBe("Valid task")
expect(task.todoList![1].content).toBe("Another valid task")
})
})
describe("getEnvironmentDetails", () => {

View file

@ -93,6 +93,7 @@ describe("newTaskTool", () => {
"Review this: \\@file1.txt and also \\\\\\@file2.txt", // Unit Test Expectation: \\@ -> \@, \\\\@ -> \\\\@
undefined,
mockCline,
{ todos: undefined },
)
// Verify side effects
@ -126,6 +127,7 @@ describe("newTaskTool", () => {
"This is already unescaped: \\@file1.txt", // Expected: \@ remains \@
undefined,
mockCline,
{ todos: undefined },
)
})
@ -153,6 +155,7 @@ describe("newTaskTool", () => {
"A normal mention @file1.txt", // Expected: @ remains @
undefined,
mockCline,
{ todos: undefined },
)
})
@ -180,8 +183,136 @@ describe("newTaskTool", () => {
"Mix: @file0.txt, \\@file1.txt, \\@file2.txt, \\\\\\@file3.txt", // Unit Test Expectation: @->@, \@->\@, \\@->\@, \\\\@->\\\\@
undefined,
mockCline,
{ todos: undefined },
)
})
it("should pass todos parameter to initClineWithTask when provided", async () => {
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Implement a new feature",
todos: "[x] Design the architecture\n[-] Write the code\n[ ] Add tests\n[ ] Update documentation",
},
partial: false,
}
await newTaskTool(
mockCline as any,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockInitClineWithTask).toHaveBeenCalledWith("Implement a new feature", undefined, mockCline, {
todos: "[x] Design the architecture\n[-] Write the code\n[ ] Add tests\n[ ] Update documentation",
})
})
it("should handle todos parameter with various markdown checklist formats", async () => {
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Complex task",
todos: "[X] Completed uppercase\n[~] Alternative in-progress\n[ ] Pending with extra spaces\n[x]No space after bracket",
},
partial: false,
}
await newTaskTool(
mockCline as any,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockInitClineWithTask).toHaveBeenCalledWith("Complex task", undefined, mockCline, {
todos: "[X] Completed uppercase\n[~] Alternative in-progress\n[ ] Pending with extra spaces\n[x]No space after bracket",
})
})
it("should work without todos parameter (backward compatibility)", async () => {
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Task without todos",
},
partial: false,
}
await newTaskTool(
mockCline as any,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Should be called without the todos in options
expect(mockInitClineWithTask).toHaveBeenCalledWith("Task without todos", undefined, mockCline, {})
})
it("should handle empty todos parameter", async () => {
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Task with empty todos",
todos: "",
},
partial: false,
}
await newTaskTool(
mockCline as any,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Empty string should still be passed
expect(mockInitClineWithTask).toHaveBeenCalledWith("Task with empty todos", undefined, mockCline, { todos: "" })
})
it("should handle todos with special characters and escaping", async () => {
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Task with special todos",
todos: "[x] Handle \\@mentions in todos\n[ ] Support | pipes and \\\\ backslashes\n[-] Test \"quotes\" and 'apostrophes'",
},
partial: false,
}
await newTaskTool(
mockCline as any,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockInitClineWithTask).toHaveBeenCalledWith("Task with special todos", undefined, mockCline, {
todos: "[x] Handle \\@mentions in todos\n[ ] Support | pipes and \\\\ backslashes\n[-] Test \"quotes\" and 'apostrophes'",
})
})
// Add more tests for error handling (missing params, invalid mode, approval denied) if needed
})

View file

@ -16,6 +16,7 @@ export async function newTaskTool(
) {
const mode: string | undefined = block.params.mode
const message: string | undefined = block.params.message
const todos: string | undefined = block.params.todos
try {
if (block.partial) {
@ -23,6 +24,7 @@ export async function newTaskTool(
tool: "newTask",
mode: removeClosingTag("mode", mode),
content: removeClosingTag("message", message),
todos: removeClosingTag("todos", todos),
})
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
@ -59,6 +61,7 @@ export async function newTaskTool(
tool: "newTask",
mode: targetMode.name,
content: message,
todos: todos,
})
const didApprove = await askApproval("tool", toolMessage)
@ -86,7 +89,7 @@ export async function newTaskTool(
// Delay to allow mode change to take effect before next tool is executed.
await delay(500)
const newCline = await provider.initClineWithTask(unescapedMessage, undefined, cline)
const newCline = await provider.initClineWithTask(unescapedMessage, undefined, cline, { todos })
if (!newCline) {
pushToolResult(t("tools:newTask.errors.policy_restriction"))
return

View file

@ -534,7 +534,7 @@ export class ClineProvider
TaskOptions,
"enableDiff" | "enableCheckpoints" | "fuzzyMatchThreshold" | "consecutiveMistakeLimit" | "experiments"
>
> = {},
> & { todos?: string } = {},
) {
const {
apiConfiguration,
@ -549,6 +549,8 @@ export class ClineProvider
throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist"))
}
const { todos, ...taskOptions } = options
const cline = new Task({
provider: this,
apiConfiguration,
@ -563,7 +565,8 @@ export class ClineProvider
parentTask,
taskNumber: this.clineStack.length + 1,
onCreated: (cline) => this.emit("clineCreated", cline),
...options,
todos,
...taskOptions,
})
await this.addClineToStack(cline)

View file

@ -155,7 +155,7 @@ export interface SwitchModeToolUse extends ToolUse {
export interface NewTaskToolUse extends ToolUse {
name: "new_task"
params: Partial<Pick<Record<ToolParamName, string>, "mode" | "message">>
params: Partial<Pick<Record<ToolParamName, string>, "mode" | "message" | "todos">>
}
export interface SearchAndReplaceToolUse extends ToolUse {