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:
@@ -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|
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user