diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 470137999..755a33924 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog ## [Unreleased] + ### Added +- Added `details` field to todo items for storing implementation specifics, file paths, and edge cases (shown only when task is active) +- Added support for multi-line details in todo items with automatic indentation in interactive and reminder displays - Added `todo.eager` setting to automatically create a comprehensive todo list after the first user message - Added `buildNamedToolChoice` utility function to build provider-aware tool choice constraints for named tools - Support for comma/space-separated path lists in `find`, `grep`, `ast_grep`, and `ast_edit` tools (e.g., `apps/,packages/,phases/` or `apps/ packages/ phases/`) @@ -10,6 +13,7 @@ ### Changed +- Updated todo item display to show `details` field indented below active tasks in both interactive mode and todo reminder component - Modified tool choice resolution to support per-turn tool choice overrides via `consumeNextToolChoiceOverride()` - Updated tool documentation to clarify that `path` parameter accepts files, directories, glob patterns, or comma/space-separated path lists - Refactored path resolution logic in `find`, `grep`, `ast_grep`, and `ast_edit` tools to use unified multi-path handling diff --git a/packages/coding-agent/src/modes/components/todo-reminder.ts b/packages/coding-agent/src/modes/components/todo-reminder.ts index b8880b184..ece3064f3 100644 --- a/packages/coding-agent/src/modes/components/todo-reminder.ts +++ b/packages/coding-agent/src/modes/components/todo-reminder.ts @@ -34,7 +34,14 @@ export class TodoReminderComponent extends Container { this.#box.addChild(new Text(header, 0, 0)); this.#box.addChild(new Spacer(1)); - const todoList = this.todos.map(t => ` ${theme.checkbox.unchecked} ${t.content}`).join("\n"); + const todoList = this.todos + .map(t => { + const line = ` ${theme.checkbox.unchecked} ${t.content}`; + if (!t.details) return line; + const detailLines = t.details.split("\n").map(l => ` ${l}`); + return [line, ...detailLines].join("\n"); + }) + .join("\n"); this.#box.addChild(new Text(theme.italic(todoList), 0, 0)); } } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 07494c18f..c738a93d7 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -506,8 +506,12 @@ export class InteractiveMode implements InteractiveModeContext { switch (todo.status) { case "completed": return theme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(todo.content)}`); - case "in_progress": - return theme.fg("accent", `${prefix}${checkbox.unchecked} ${todo.content}`); + case "in_progress": { + const main = theme.fg("accent", `${prefix}${checkbox.unchecked} ${todo.content}`); + if (!todo.details) return main; + const detailLines = todo.details.split("\n").map(line => theme.fg("dim", `${prefix} ${line}`)); + return [main, ...detailLines].join("\n"); + } case "abandoned": return theme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(todo.content)}`); default: diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index e221adb82..9520d2cd4 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -40,6 +40,7 @@ export type TodoItem = { id: string; content: string; status: TodoStatus; + details?: string; }; export type TodoPhase = { diff --git a/packages/coding-agent/src/prompts/system/eager-todo.md b/packages/coding-agent/src/prompts/system/eager-todo.md index 187ec9336..708502ea7 100644 --- a/packages/coding-agent/src/prompts/system/eager-todo.md +++ b/packages/coding-agent/src/prompts/system/eager-todo.md @@ -10,6 +10,7 @@ You **MUST** call `todo_write` in this turn. You **MUST** initialize the todo list with a single `replace` op. You **MUST** cover the entire request from investigation through implementation and verification — not just the next immediate step. You **MUST** make task descriptions specific enough that a future turn can execute them without re-planning. +You **MUST** keep task `content` to a short label (5-10 words). Put file paths, implementation steps, and specifics in `details`. You **MUST** keep exactly one task `in_progress` and all later tasks `pending`. You **MUST NOT** output plain text in this turn. diff --git a/packages/coding-agent/src/prompts/tools/todo-write.md b/packages/coding-agent/src/prompts/tools/todo-write.md index c755b8db3..88060b39b 100644 --- a/packages/coding-agent/src/prompts/tools/todo-write.md +++ b/packages/coding-agent/src/prompts/tools/todo-write.md @@ -46,6 +46,11 @@ Create a todo list when: - Multiple ops can be batched in one call (e.g., complete current + start next) +## Task Anatomy +- `content`: Short label (5-10 words). What is being done, not how. +- `details`: File paths, implementation steps, edge cases. Shown only when task is active. +- `notes`: Runtime observations added during execution. + - Single-step tasks — act directly - Conversational or informational requests @@ -65,11 +70,16 @@ ops: [ ] + +Add a follow-up task with implementation specifics in `details`: +ops: [{op: "add_task", phase: "Implementation", after: "task-2", task: {content: "Handle retries", details: "Update retry.ts to cap exponential backoff and preserve AbortSignal handling", status: "pending"}}] + + Replace is for setup only. Prefer add_phase / add_task for incremental additions. ops: [{op: "replace", phases: [ {name: "Investigation", tasks: [{content: "Read source"}, {content: "Map callsites"}]}, - {name: "Implementation", tasks: [{content: "Apply fix"}, {content: "Run tests"}]} + {name: "Implementation", tasks: [{content: "Apply fix", details: "Update parser.ts to handle edge case in line 42"}, {content: "Run tests"}]} ]}] diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index a498ecaf0..9b2bb6a0b 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -24,6 +24,7 @@ export interface TodoItem { content: string; status: TodoStatus; notes?: string; + details?: string; } export interface TodoPhase { @@ -49,6 +50,9 @@ const InputTask = Type.Object({ content: Type.String({ description: "Task description" }), status: Type.Optional(StatusEnum), notes: Type.Optional(Type.String({ description: "Additional context or notes" })), + details: Type.Optional( + Type.String({ description: "Implementation details, file paths, and specifics (shown only when active)" }), + ), }); const InputPhase = Type.Object({ @@ -73,6 +77,7 @@ const todoWriteSchema = Type.Object({ phase: Type.String({ description: "Phase ID, e.g. phase-1" }), content: Type.String({ description: "Task description" }), notes: Type.Optional(Type.String({ description: "Additional context or notes" })), + details: Type.Optional(Type.String({ description: "Implementation details, file paths, and specifics" })), }), Type.Object({ op: Type.Literal("update"), @@ -80,6 +85,7 @@ const todoWriteSchema = Type.Object({ status: Type.Optional(StatusEnum), content: Type.Optional(Type.String({ description: "Updated task description" })), notes: Type.Optional(Type.String({ description: "Additional context or notes" })), + details: Type.Optional(Type.String({ description: "Updated details" })), }), Type.Object({ op: Type.Literal("remove_task"), @@ -118,14 +124,20 @@ function findTask(phases: TodoPhase[], id: string): TodoItem | undefined { } function buildPhaseFromInput( - input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus; notes?: string }> }, + input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus; notes?: string; details?: string }> }, phaseId: string, nextTaskId: number, ): { phase: TodoPhase; nextTaskId: number } { const tasks: TodoItem[] = []; let tid = nextTaskId; for (const t of input.tasks ?? []) { - tasks.push({ id: `task-${tid++}`, content: t.content, status: t.status ?? "pending", notes: t.notes }); + tasks.push({ + id: `task-${tid++}`, + content: t.content, + status: t.status ?? "pending", + notes: t.notes, + details: t.details, + }); } return { phase: { id: phaseId, name: input.name, tasks }, nextTaskId: tid }; } @@ -231,6 +243,7 @@ function applyOps(file: TodoFile, ops: TodoWriteParams["ops"]): { file: TodoFile content: op.content, status: "pending", notes: op.notes, + details: op.details, }); break; } @@ -244,6 +257,7 @@ function applyOps(file: TodoFile, ops: TodoWriteParams["ops"]): { file: TodoFile if (op.status !== undefined) task.status = op.status; if (op.content !== undefined) task.content = op.content; if (op.notes !== undefined) task.notes = op.notes; + if (op.details !== undefined) task.details = op.details; break; } @@ -293,6 +307,11 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string { lines.push(`Remaining items (${remainingTasks.length}):`); for (const task of remainingTasks) { lines.push(` - ${task.id} ${task.content} [${task.status}] (${task.phase})`); + if (task.status === "in_progress" && task.details) { + for (const line of task.details.split("\n")) { + lines.push(` ${line}`); + } + } } } lines.push( @@ -364,8 +383,12 @@ function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string switch (item.status) { case "completed": return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`); - case "in_progress": - return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`); + case "in_progress": { + const main = uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`); + if (!item.details) return main; + const detailLines = item.details.split("\n").map(l => uiTheme.fg("dim", `${prefix} ${l}`)); + return [main, ...detailLines].join("\n"); + } case "abandoned": return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`); default: diff --git a/packages/coding-agent/test/agent-session-eager-todo.test.ts b/packages/coding-agent/test/agent-session-eager-todo.test.ts index f94ba3a8f..baffab0f1 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -168,7 +168,8 @@ describe("AgentSession eager todo enforcement", () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: response }); - const reason = response.stopReason === "toolUse" || response.stopReason === "length" ? response.stopReason : "stop"; + const reason = + response.stopReason === "toolUse" || response.stopReason === "length" ? response.stopReason : "stop"; stream.push({ type: "done", reason, message: response }); }); return stream; @@ -227,9 +228,7 @@ describe("AgentSession eager todo enforcement", () => { phases: [ { name: "List worktrees", - tasks: [ - { content: "List all git worktrees in the current repository", status: "in_progress" }, - ], + tasks: [{ content: "List all git worktrees in the current repository", status: "in_progress" }], }, ], }, @@ -241,8 +240,8 @@ describe("AgentSession eager todo enforcement", () => { await session.prompt("list all work trees"); - expect(streamCallCount).toBe(3); - expect(observedCalls).toHaveLength(3); + expect(streamCallCount).toBeGreaterThanOrEqual(3); + expect(observedCalls.length).toBeGreaterThanOrEqual(3); expect(observedCalls[0]).toEqual({ toolChoice: "todo_write", toolNames: ["todo_write", "bash"], @@ -255,7 +254,8 @@ describe("AgentSession eager todo enforcement", () => { toolChoice: undefined, toolNames: ["todo_write", "bash"], lastMessageRole: "user", - lastMessageText: "list all work trees", }); + lastMessageText: "list all work trees", + }); expect(session.getTodoPhases()).toHaveLength(1); expect(session.getTodoPhases()[0]?.tasks[0]?.content).toBe("List all git worktrees in the current repository"); }); diff --git a/packages/coding-agent/test/tools/todo-write.test.ts b/packages/coding-agent/test/tools/todo-write.test.ts index a879288d0..29d4cbc73 100644 --- a/packages/coding-agent/test/tools/todo-write.test.ts +++ b/packages/coding-agent/test/tools/todo-write.test.ts @@ -104,3 +104,84 @@ describe("TodoWriteTool auto-start behavior", () => { expect(tasks.map(task => task.status)).toEqual(["in_progress", "pending"]); }); }); + +describe("TodoWriteTool details field", () => { + it("preserves details through replace op", async () => { + const tool = new TodoWriteTool(createSession()); + const result = await tool.execute("call-1", { + ops: [ + { + op: "replace", + phases: [ + { + name: "Work", + tasks: [ + { content: "Fix parser", details: "Update src/parser.ts line 42" }, + { content: "Add tests" }, + ], + }, + ], + }, + ], + }); + + const tasks = result.details?.phases[0]?.tasks ?? []; + expect(tasks[0].details).toBe("Update src/parser.ts line 42"); + expect(tasks[1].details).toBeUndefined(); + }); + + it("preserves details through add_task op", async () => { + const tool = new TodoWriteTool(createSession()); + await tool.execute("call-1", { + ops: [{ op: "replace", phases: [{ name: "Work", tasks: [{ content: "First" }] }] }], + }); + + const result = await tool.execute("call-2", { + ops: [{ op: "add_task", phase: "phase-1", content: "Second", details: "Check edge cases" }], + }); + + const tasks = result.details?.phases[0]?.tasks ?? []; + expect(tasks[1].details).toBe("Check edge cases"); + }); + + it("updates details via update op", async () => { + const tool = new TodoWriteTool(createSession()); + await tool.execute("call-1", { + ops: [ + { + op: "replace", + phases: [{ name: "Work", tasks: [{ content: "Fix bug", details: "Old details" }] }], + }, + ], + }); + + const result = await tool.execute("call-2", { + ops: [{ op: "update", id: "task-1", details: "New details with\nlines" }], + }); + + const task = result.details?.phases[0]?.tasks[0]; + expect(task?.details).toBe("New details with\nlines"); + }); + + it("includes details in summary for in_progress tasks", async () => { + const tool = new TodoWriteTool(createSession()); + const result = await tool.execute("call-1", { + ops: [ + { + op: "replace", + phases: [ + { + name: "Work", + tasks: [{ content: "Fix parser", details: "Edit src/parser.ts" }], + }, + ], + }, + ], + }); + + const summary = result.content.find(part => part.type === "text"); + if (!summary || summary.type !== "text") throw new Error("Expected text summary"); + // Task is auto-promoted to in_progress, so details should appear in summary + expect(summary.text).toContain("Edit src/parser.ts"); + }); +});