diff --git a/packages/coding-agent/src/prompts/tools/todo.md b/packages/coding-agent/src/prompts/tools/todo.md index f5dc3acd3..bf5e2f341 100644 --- a/packages/coding-agent/src/prompts/tools/todo.md +++ b/packages/coding-agent/src/prompts/tools/todo.md @@ -9,6 +9,7 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, an |`op`|Required fields|Effect| |---|---|---| |`init`|`list: [{phase, items: string[]}]`|Initialize the full list (replaces any existing list)| +|`init`|`items: string[]`|Flattened single-phase init| |`start`|`task`|Mark in progress| |`done`|`task` or `phase`|Mark completed| |`drop`|`task` or `phase`|Mark abandoned| diff --git a/packages/coding-agent/src/tools/todo.ts b/packages/coding-agent/src/tools/todo.ts index e4d224a0f..5ef611f91 100644 --- a/packages/coding-agent/src/tools/todo.ts +++ b/packages/coding-agent/src/tools/todo.ts @@ -271,8 +271,20 @@ function getTaskTargets(phases: TodoPhase[], entry: TodoOpEntryValue, errors: st return phases.flatMap(phase => phase.tasks); } +/** Phase name for `init` given a flat `items` list with no explicit `phase`. */ +const DEFAULT_INIT_PHASE = "Tasks"; + function initPhases(entry: TodoOpEntryValue, errors: string[]): TodoPhase[] { - if (!entry.list) { + // Models routinely flatten the single-phase init into `{op:"init", items:[...]}` + // (optionally with a bare `phase`) instead of the canonical + // `list: [{phase, items}]`. Accept that shape by synthesizing a one-phase list + // so a common, recoverable mistake isn't a hard error. + const list = + entry.list ?? + (entry.items && entry.items.length > 0 + ? [{ phase: entry.phase ?? DEFAULT_INIT_PHASE, items: entry.items }] + : undefined); + if (!list) { errors.push("Missing list for init operation"); return []; } @@ -280,7 +292,7 @@ function initPhases(entry: TodoOpEntryValue, errors: string[]): TodoPhase[] { // (every targeting op resolves the first match), so reject them up front. const seenPhases = new Set(); const seenTasks = new Set(); - for (const listEntry of entry.list) { + for (const listEntry of list) { if (seenPhases.has(listEntry.phase)) { errors.push(`Duplicate phase "${listEntry.phase}" in init list`); } @@ -292,7 +304,7 @@ function initPhases(entry: TodoOpEntryValue, errors: string[]): TodoPhase[] { seenTasks.add(content); } } - return entry.list.map(listEntry => ({ + return list.map(listEntry => ({ name: listEntry.phase, tasks: listEntry.items.map(content => ({ content, status: "pending" })), })); diff --git a/packages/coding-agent/test/tools/todo.test.ts b/packages/coding-agent/test/tools/todo.test.ts index 44c9bd952..96191f61f 100644 --- a/packages/coding-agent/test/tools/todo.test.ts +++ b/packages/coding-agent/test/tools/todo.test.ts @@ -269,6 +269,44 @@ describe("TodoTool ops operations", () => { }); }); +describe("TodoTool lenient init shapes", () => { + it("accepts a flattened init with bare items and no phase", async () => { + const tool = new TodoTool(createSession()); + const result = await tool.execute("call-1", { + ops: [{ op: "init", items: ["First", "Second"] }], + }); + + expect(result.isError).toBeUndefined(); + expect(result.details?.phases.map(phase => phase.name)).toEqual(["Tasks"]); + const tasks = result.details?.phases[0]?.tasks ?? []; + expect(tasks.map(task => ({ content: task.content, status: task.status }))).toEqual([ + { content: "First", status: "in_progress" }, + { content: "Second", status: "pending" }, + ]); + }); + + it("honors a bare phase on a flattened init", async () => { + const tool = new TodoTool(createSession()); + const result = await tool.execute("call-1", { + ops: [{ op: "init", phase: "Cleanup", items: ["Remove dead code"] }], + }); + + expect(result.isError).toBeUndefined(); + expect(result.details?.phases.map(phase => phase.name)).toEqual(["Cleanup"]); + expect(result.details?.phases[0]?.tasks.map(task => task.content)).toEqual(["Remove dead code"]); + }); + + it("still errors when init has neither list nor items", async () => { + const tool = new TodoTool(createSession()); + const result = await tool.execute("call-1", { ops: [{ op: "init" }] }); + + expect(result.isError).toBe(true); + const summary = result.content.find(part => part.type === "text"); + if (summary?.type !== "text") throw new Error("Expected text summary"); + expect(summary.text).toContain("Missing list for init operation"); + }); +}); + describe("selectStickyTodoWindow", () => { const makeTasks = (statuses: TodoStatus[]): TodoItem[] => statuses.map((status, i) => ({ content: `task-${i + 1}`, status }));