feat(coding-agent/tools): allowed flattened init for Gemini TODO calls

- Updated `init` handling to build a single-phase list from `items` when `list` is absent, using `phase` if provided or defaulting to `Tasks`.
- Kept the existing init validation behavior by emitting `Missing list for init operation` only when neither `list` nor `items` was supplied.
- Added tests for flattened init forms, including implicit phase defaulting, explicit phase use, and the missing-input error case.
This commit is contained in:
can1357
2026-06-15 08:19:08 +02:00
parent 5afe0e5832
commit d6471c2244
3 changed files with 54 additions and 3 deletions
@@ -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|
+15 -3
View File
@@ -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<string>();
const seenTasks = new Set<string>();
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<TodoItem>(content => ({ content, status: "pending" })),
}));
@@ -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 }));