@pi-archimedes/todo 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ Track complex multi-step tasks structured in a todo list with automatic clearing
17
17
 
18
18
  ### Multiple todos with progress tracking
19
19
 
20
- Widget showing three todos with completion status — completed items dimmed with strikethrough, in-progress highlighted:
20
+ Widget showing three todos with completion status — completed items dimmed with strikethrough, the item currently being worked on highlighted:
21
21
 
22
22
  ![todos multiple todos](../../docs/images/todos-multiple-todos.png)
23
23
 
@@ -59,9 +59,9 @@ The `manage_todo_list` tool accepts two operations:
59
59
  {
60
60
  "operation": "write",
61
61
  "todoList": [
62
- { "id": 1, "title": "Parse config files", "description": "Read and validate all config files", "status": "in-progress" },
63
- { "id": 2, "title": "Build state manager", "description": "Implement TodoStateManager class", "status": "not-started" },
64
- { "id": 3, "title": "Wire up widget", "description": "Connect widget to bus events", "status": "not-started" }
62
+ { "content": "Parse config files", "description": "Read and validate all config files", "status": "in_progress" },
63
+ { "content": "Build state manager", "description": "Implement TodoStateManager class", "status": "pending" },
64
+ { "content": "Wire up widget", "description": "Connect widget to bus events", "status": "pending" }
65
65
  ]
66
66
  }
67
67
  ```
@@ -75,8 +75,8 @@ The `manage_todo_list` tool accepts two operations:
75
75
 
76
76
  | Status | Icon | Description |
77
77
  |--------|------|-------------|
78
- | `not-started` | ○ | Not yet begun |
79
- | `in-progress` | ◉ | Currently being worked on |
78
+ | `pending` | ○ | Not yet begun |
79
+ | `in_progress` | ◉ | Currently being worked on |
80
80
  | `completed` | ✓ | Fully finished |
81
81
 
82
82
  ## Auto-clear
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/todo",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -10,8 +10,12 @@
10
10
  "src"
11
11
  ],
12
12
  "main": "./src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./prepare-args": "./src/prepare-args.ts"
16
+ },
13
17
  "dependencies": {
14
- "@pi-archimedes/core": "2.3.0"
18
+ "@pi-archimedes/core": "2.4.0"
15
19
  },
16
20
  "peerDependencies": {
17
21
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Index-level test: the bus consumer guarantee.
3
+ *
4
+ * The parent todo widget receives subagent todo state ONLY via the
5
+ * TODOS_UPDATE bus event. These tests prove that whatever the producer
6
+ * emits (legacy shapes, garbage) the consumer stored in `subagentTodos`
7
+ * is always canonical — the widget can render real text and can never
8
+ * print the string "undefined".
9
+ */
10
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { getBus, Events } from "@pi-archimedes/core/bus";
13
+ import { registerTodo } from "./index.js";
14
+
15
+ describe("todo index — bus consumer normalization", () => {
16
+ const captured: Record<string, unknown> = {};
17
+ const handlers: Record<string, Function> = {};
18
+ const registered: { tool?: any; commands: Record<string, unknown> } = { commands: {} };
19
+
20
+ const pi = {
21
+ on(evt: string, fn: Function) {
22
+ handlers[evt] = fn;
23
+ },
24
+ registerTool(tool: any) {
25
+ registered.tool = tool;
26
+ },
27
+ registerCommand(name: string, def: unknown) {
28
+ registered.commands[name] = def;
29
+ },
30
+ } as unknown as ExtensionAPI;
31
+
32
+ const ctx = {
33
+ ui: {
34
+ setWidget(id: string, component: unknown) {
35
+ captured[id] = component;
36
+ },
37
+ },
38
+ sessionManager: { getBranch: () => [] },
39
+ } as any;
40
+
41
+ // The widget calls theme.fg / theme.strikethrough; same behavior these
42
+ // tests rely on (identity-ish, no ANSI) is enough to inspect the text.
43
+ const theme: any = {
44
+ fg: (_c: unknown, s: unknown) => String(s),
45
+ strikethrough: (s: unknown) => String(s),
46
+ };
47
+
48
+ function renderLines(): string[] {
49
+ // Read fresh on every render step — the factory can be replaced, or
50
+ // self-cleared to undefined, between steps.
51
+ const factory = captured["todo-list"] as
52
+ | ((_tui: unknown, t: unknown) => { render(width: number): string[]; invalidate(): void })
53
+ | undefined;
54
+ expect(typeof factory).toBe("function");
55
+ const comp = factory!(null, theme);
56
+ return comp.render(200);
57
+ }
58
+
59
+ beforeAll(() => {
60
+ registerTodo(pi as any);
61
+ });
62
+
63
+ it("normalizes a legacy subagent payload — renders real text, never 'undefined'", async () => {
64
+ // CRITICAL ordering: session_start must fire BEFORE seeding, because
65
+ // reconstructState → state.loadFromSession(ctx) resets the list from
66
+ // getBranch() (mock returns []), which would wipe the seed.
67
+ const start = handlers.session_start;
68
+ if (typeof start !== "function") throw new Error("session_start handler not registered");
69
+ let anyP: unknown;
70
+ anyP = start(undefined, ctx as any);
71
+ await anyP;
72
+
73
+ // Seed main state through the real tool.
74
+ const result = await registered.tool.execute(
75
+ "c1",
76
+ { operation: "write", todoList: [{ content: "Main task", status: "pending" }] },
77
+ undefined,
78
+ undefined,
79
+ ctx as any,
80
+ );
81
+ expect(result.isError).toBeFalsy();
82
+
83
+ // Legacy (old-schema) subagent payload: id/title/not-started.
84
+ getBus().emit(Events.TODOS_UPDATE, {
85
+ source: "subagent:fake1",
86
+ todos: [{ id: 1, title: "Old shape task", status: "not-started" }],
87
+ });
88
+
89
+ const lines = renderLines();
90
+ const joined = lines.join("\n");
91
+ expect(joined).toContain("Old shape task");
92
+ expect(joined).toContain("Main task");
93
+ expect(joined).not.toContain("undefined");
94
+ });
95
+
96
+ it("ignores unrecoverable payloads without crashing (no phantom column)", () => {
97
+ // State from the previous test persists (same registered instance).
98
+ expect(() => {
99
+ getBus().emit(Events.TODOS_UPDATE, { source: "subagent:fake2", todos: "garbage" });
100
+ getBus().emit(Events.TODOS_UPDATE, {
101
+ source: "subagent:fake2",
102
+ todos: [{ status: "pending" }],
103
+ });
104
+ }).not.toThrow();
105
+
106
+ const lines = renderLines();
107
+ const joined = lines.join("\n");
108
+ expect(joined).toContain("Main task");
109
+ expect(lines.some((l) => l.includes("subagent (fake2)"))).toBe(false);
110
+ });
111
+
112
+ it("TODOS_CLEAR removes the subagent column", () => {
113
+ getBus().emit(Events.TODOS_CLEAR, { source: "subagent:fake1" });
114
+
115
+ const lines = renderLines();
116
+ const joined = lines.join("\n");
117
+ expect(lines.some((l) => l.includes("subagent (fake1)"))).toBe(false);
118
+ expect(joined).toContain("Main task");
119
+ });
120
+
121
+ it("renders the plain agent name for a unique per-child (suffixed) source", async () => {
122
+ // Fresh setup mirroring test 1: session_start first (resets main), then
123
+ // seed main through the real tool, then a suffixed subagent bus emit.
124
+ const start = handlers.session_start;
125
+ if (typeof start !== "function") throw new Error("session_start handler not registered");
126
+ let anyP: unknown;
127
+ anyP = start(undefined, ctx as any);
128
+ await anyP;
129
+
130
+ const result = await registered.tool.execute(
131
+ "c1",
132
+ { operation: "write", todoList: [{ content: "Main task", status: "pending" }] },
133
+ undefined,
134
+ undefined,
135
+ ctx as any,
136
+ );
137
+ expect(result.isError).toBeFalsy();
138
+
139
+ // Per-child source now carries a suffix; the header must still render
140
+ // just the agent name, not the uuid.
141
+ getBus().emit(Events.TODOS_UPDATE, {
142
+ source: "subagent:fake1:aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
143
+ todos: [{ id: 1, title: "Old shape task", status: "not-started" }],
144
+ });
145
+
146
+ const lines = renderLines();
147
+ const joined = lines.join("\n");
148
+ expect(lines.some((l) => l.includes("subagent (fake1)"))).toBe(true);
149
+ expect(joined).toContain("Old shape task");
150
+ expect(joined).toContain("Main task");
151
+ expect(joined).not.toContain("undefined");
152
+ });
153
+
154
+ afterAll(() => {
155
+ // Unsubscribes the bus listeners registered by registerTodo and
156
+ // clears state (fixture uses a pending item, so no auto-clear timer
157
+ // is pending, but cancel defensively).
158
+ handlers.session_shutdown?.(undefined, ctx as any);
159
+ });
160
+ });
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@e
2
2
  import { getBus, Events } from "@pi-archimedes/core/bus";
3
3
  import { TodoStateManager } from "./state-manager.js";
4
4
  import { createManageTodoListTool } from "./tool.js";
5
+ import { normalizeTodoItems } from "./prepare-args.js";
5
6
  import { updateWidget, clearWidget } from "./ui/todo-widget.js";
6
7
  import type { TodoItem } from "./types.js";
7
8
 
@@ -25,8 +26,17 @@ export function registerTodo(pi: ExtensionAPI): void {
25
26
  const unsubTodosUpdate = getBus().on(Events.TODOS_UPDATE, (payload: unknown) => {
26
27
  const data = payload as { source: string; todos: TodoItem[] };
27
28
  if (data.source === "main") return; // main handled locally
28
- subagentTodos.set(data.source, data.todos);
29
- refreshWidget();
29
+ // The consumer is the guarantee: producers (subagent stream, older
30
+ // child sessions) may emit raw or legacy shapes, so every incoming
31
+ // payload is normalized before it is stored. Unrecoverable payloads
32
+ // (normalizeTodoItems → undefined) and empty lists are not mirrored —
33
+ // the widget hides empty subagent columns, and TODOS_CLEAR/child-exit
34
+ // remain the lifecycle ends.
35
+ const normalized = normalizeTodoItems(data.todos);
36
+ if (normalized && normalized.length > 0) {
37
+ subagentTodos.set(data.source, normalized);
38
+ refreshWidget();
39
+ }
30
40
  });
31
41
  unsubscribes.push(unsubTodosUpdate);
32
42
 
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  deriveTitleFromDescription,
4
4
  normalizeTodoItem,
5
+ normalizeTodoItems,
5
6
  prepareTodoArguments,
6
7
  } from "./prepare-args.js";
7
8
 
@@ -14,32 +15,25 @@ const REAL_STRINGIFIED_LIST =
14
15
  '"description": "Document all container-level env vars", ' +
15
16
  '"status": "in-progress"}]';
16
17
 
17
- const REAL_MISSING_TITLE = {
18
- operation: "write",
19
- todoList: [
20
- { description: "Understand what c12d4a0 fixed and how the current base interacts with it", id: 1, status: "in-progress" },
21
- { description: "Trace user_api_key_auth() catch-all behaviour", id: 2, status: "not-started" },
22
- ],
23
- };
24
-
25
- const REAL_MISSING_STATUS = {
26
- operation: "write",
27
- todoList: [
28
- { id: 1, title: "Fix shell command interpolation", description: "fetchNewToken() builds cmd as string" },
29
- { id: 2, title: "Update tests", description: "add regression test" },
30
- ],
31
- };
18
+ // Legacy 4-field shape (id/title/description + dashed statuses) that older
19
+ // sessions persisted — must normalize to the canonical {content, status}.
20
+ const LEGACY_ITEM = { id: 1, title: "Fix auth", description: "Y", status: "not-started" };
32
21
 
33
22
  describe("prepareTodoArguments", () => {
34
- it("passes through already-valid arguments unchanged", () => {
23
+ it("passes through already-valid canonical arguments unchanged", () => {
35
24
  const input = {
36
25
  operation: "write",
37
26
  todoList: [
38
- { id: 1, title: "Task", description: "Desc", status: "in-progress" },
39
- { id: 2, title: "Other", description: "More", status: "completed" },
27
+ { content: "Fix auth", status: "pending" as const },
28
+ { content: "Ship it", status: "completed" as const },
40
29
  ],
41
- } as const;
42
- expect(prepareTodoArguments(input)).toEqual(input);
30
+ };
31
+ const out = prepareTodoArguments(input);
32
+ expect(out).toEqual(input);
33
+ for (const item of out.todoList!) {
34
+ expect(Object.keys(item as Record<string, unknown>)).not.toContain("id");
35
+ expect(Object.keys(item as Record<string, unknown>)).not.toContain("title");
36
+ }
43
37
  });
44
38
 
45
39
  it("passes non-object arguments through untouched", () => {
@@ -48,14 +42,52 @@ describe("prepareTodoArguments", () => {
48
42
  }
49
43
  });
50
44
 
45
+ it("normalizes a legacy item (id/title/not-started) to canonical, dropping id", () => {
46
+ const out = prepareTodoArguments({ operation: "write", todoList: [LEGACY_ITEM] });
47
+ expect(out.todoList).toEqual([{ content: "Fix auth", description: "Y", status: "pending" }]);
48
+ });
49
+
50
+ it("reads Codex-shaped items ({step, status}) into content", () => {
51
+ const out = prepareTodoArguments({ operation: "write", todoList: [{ step: "X", status: "in_progress" }] });
52
+ expect(out.todoList).toEqual([{ content: "X", status: "in_progress" }]);
53
+ });
54
+
55
+ it("strips id (even null) and folds spaced statuses", () => {
56
+ const out = prepareTodoArguments({
57
+ operation: "write",
58
+ todoList: [{ id: null, content: "X", status: "in progress" }],
59
+ });
60
+ expect(out.todoList).toEqual([{ content: "X", status: "in_progress" }]);
61
+ });
62
+
63
+ it("recovers content from activeForm alone", () => {
64
+ const out = prepareTodoArguments({ operation: "write", todoList: [{ activeForm: "Adding tests" }] });
65
+ expect(out.todoList).toEqual([{ content: "Adding tests", status: "pending" }]);
66
+ });
67
+
68
+ it("wraps bare-string items into canonical items", () => {
69
+ const out = prepareTodoArguments({ operation: "write", todoList: ["write tests", "deploy it"] });
70
+ expect(out.todoList).toEqual([
71
+ { content: "write tests", status: "pending" },
72
+ { content: "deploy it", status: "pending" },
73
+ ]);
74
+ });
75
+
51
76
  it("parses a stringified todoList (real-world case, incl. mangled quotes)", () => {
52
77
  const out = prepareTodoArguments({ operation: "write", todoList: REAL_STRINGIFIED_LIST });
53
78
  expect(out.operation).toBe("write");
54
- const list = out.todoList as Array<Record<string, unknown>>;
55
- expect(Array.isArray(list)).toBe(true);
56
- expect(list).toHaveLength(2);
57
- expect(list[0]).toEqual({ id: 1, title: "Catalog all undocumented env vars", description: "Compare env vars from all K8s manifests against what is in AI-SETUP.md", status: "completed" });
58
- expect(list[1]).toEqual({ id: 2, title: "Add K8s env vars section", description: "Document all container-level env vars", status: "in-progress" });
79
+ expect(out.todoList).toEqual([
80
+ {
81
+ content: "Catalog all undocumented env vars",
82
+ description: "Compare env vars from all K8s manifests against what is in AI-SETUP.md",
83
+ status: "completed",
84
+ },
85
+ {
86
+ content: "Add K8s env vars section",
87
+ description: "Document all container-level env vars",
88
+ status: "in_progress",
89
+ },
90
+ ]);
59
91
  });
60
92
 
61
93
  it("returns a stringified list it cannot parse, so the schema error still fires", () => {
@@ -63,113 +95,167 @@ describe("prepareTodoArguments", () => {
63
95
  expect(out.todoList).toBe("[{ def 《");
64
96
  });
65
97
 
66
- it("derives a title when the model only sent description", () => {
67
- const out = prepareTodoArguments(parseJSON(JSON.stringify(REAL_MISSING_TITLE)));
68
- const list = out.todoList as Array<Record<string, unknown>>;
69
- expect(list[0]?.title).not.toBe("");
70
- expect(list[0]?.description).toBe("Understand what c12d4a0 fixed and how the current base interacts with it");
71
- expect(list[1]).toEqual({ id: 2, title: expect.any(String), description: "Trace user_api_key_auth() catch-all behaviour", status: "not-started" });
98
+ it("last-resort scan picks the first unreserved string field", () => {
99
+ const out = prepareTodoArguments({ operation: "write", todoList: [{ instruction: "do X", status: "pending" }] });
100
+ expect(out.todoList).toEqual([{ content: "do X", status: "pending" }]);
72
101
  });
73
102
 
74
- it("defaults missing status to not-started", () => {
75
- const out = prepareTodoArguments(parseJSON(JSON.stringify(REAL_MISSING_STATUS)));
76
- const list = out.todoList as Array<Record<string, unknown>>;
77
- expect(list.every((item) => item?.status === "not-started")).toBe(true);
103
+ it("last-resort scan does NOT pick up status or id values", () => {
104
+ const out = prepareTodoArguments({ operation: "write", todoList: [{ id: 42, status: "bad" }] });
105
+ expect(out.todoList).toEqual([{ content: "", status: "pending" }]);
78
106
  });
79
107
 
80
- it("normalizes status synonyms and spacing", () => {
81
- const out = prepareTodoArguments({
82
- operation: "write",
83
- todoList: [
84
- { id: 1, title: "a", description: "a", status: "In_Progress" },
85
- { id: 2, title: "b", description: "b", status: "Done" },
86
- { id: 3, title: "c", description: "c", status: "doing" },
87
- { id: 4, title: "d", description: "d", status: "Pending" },
88
- { id: 5, title: "e", description: "e", status: "CLOSED" },
89
- { id: 6, title: "f", description: "f", status: "in-progress" },
90
- ],
91
- });
92
- const list = out.todoList as Array<Record<string, unknown>>;
93
- const statuses = list.map((item) => item?.status);
94
- expect(statuses).toEqual(["in-progress", "completed", "in-progress", "not-started", "completed", "in-progress"]);
108
+ it("copies description from fallback keys (notes)", () => {
109
+ const out = prepareTodoArguments({ operation: "write", todoList: [{ title: "X", notes: "n" }] });
110
+ expect(out.todoList).toEqual([{ content: "X", description: "n", status: "pending" }]);
95
111
  });
96
112
 
97
- it("repairs missing ids (string, null, missing) to sequential numbers", () => {
113
+ it("normalizes status synonyms, spacing and dashes", () => {
98
114
  const out = prepareTodoArguments({
99
115
  operation: "write",
100
116
  todoList: [
101
- { title: "a", description: "a", status: "not-started" },
102
- { id: "4", title: "b", description: "b", status: "not-started" },
103
- { id: null, title: "c", description: "c", status: "not-started" },
117
+ { content: "a", status: "In_Progress" },
118
+ { content: "b", status: "Done" },
119
+ { content: "c", status: "doing" },
120
+ { content: "d", status: "Pending" },
121
+ { content: "e", status: "CLOSED" },
122
+ { content: "f", status: "in-progress" },
123
+ { content: "g", status: "wip" },
124
+ { content: "h", status: "open" },
125
+ { content: "i" },
104
126
  ],
105
127
  });
106
- const list = out.todoList as Array<Record<string, unknown>>;
107
- expect(list.map((item) => item?.id)).toEqual([1, 4, 3]);
108
- });
109
-
110
- it("wraps bare-string items into full items", () => {
111
- const out = prepareTodoArguments({ operation: "write", todoList: ["write tests", "deploy it"] });
112
- const list = out.todoList as Array<Record<string, unknown>>;
113
- expect(list).toEqual([
114
- { id: 1, title: "write tests", description: "write tests", status: "not-started" },
115
- { id: 2, title: "deploy it", description: "deploy it", status: "not-started" },
128
+ const statuses = (out.todoList as Array<Record<string, unknown>>).map((item) => item?.status);
129
+ expect(statuses).toEqual([
130
+ "in_progress",
131
+ "completed",
132
+ "in_progress",
133
+ "pending",
134
+ "completed",
135
+ "in_progress",
136
+ "in_progress",
137
+ "pending",
138
+ "pending",
116
139
  ]);
117
140
  });
118
141
 
119
- it("wraps a single bare object in an array and drops extra keys (item + top level)", () => {
142
+ it("end-to-end: prepareTodoArguments emits operation and canonical list", () => {
120
143
  const out = prepareTodoArguments({
121
144
  operation: "write",
122
- extraTop: "drop me",
123
- todoList: { id: 1, title: "a", description: "a", status: "completed", fluff: true },
145
+ todoList: [{ content: "a", status: "pending" }, LEGACY_ITEM],
124
146
  });
125
147
  expect(out).toEqual({
126
148
  operation: "write",
127
- todoList: [{ id: 1, title: "a", description: "a", status: "completed" }],
149
+ todoList: [
150
+ { content: "a", status: "pending" },
151
+ { content: "Fix auth", description: "Y", status: "pending" },
152
+ ],
128
153
  });
129
154
  });
130
155
 
131
- it("drops a null todoList and doesn't fabricate one", () => {
132
- const out = prepareTodoArguments({ operation: "write", todoList: null });
133
- expect(out).toEqual({ operation: "write" });
134
- expect(Object.hasOwn(out, "todoList")).toBe(false);
135
- });
136
-
137
156
  it("infers operation from a present list, tolerates casing, and defaults to read", () => {
138
157
  const withList = prepareTodoArguments({ operation: "WRITE-please", todoList: ["x"] });
139
158
  expect(withList.operation).toBe("write");
159
+ const inferred = prepareTodoArguments({ todoList: ["x"] });
160
+ expect(inferred.operation).toBe("write");
140
161
  const read = prepareTodoArguments({});
141
162
  expect(read).toEqual({ operation: "read" });
142
163
  });
143
164
 
144
- it("looks up title/description under the keys models sometimes pick", () => {
165
+ it("drops a null todoList and doesn't fabricate one", () => {
166
+ const out = prepareTodoArguments({ operation: "write", todoList: null });
167
+ expect(out).toEqual({ operation: "write" });
168
+ expect(Object.hasOwn(out, "todoList")).toBe(false);
169
+ });
170
+
171
+ it("wraps a single bare object in an array and drops extra keys (item + top level)", () => {
145
172
  const out = prepareTodoArguments({
146
173
  operation: "write",
147
- todoList: [{ id: 1, content: "Write the thing", details: "Long context here", status: "not-started" }],
174
+ extraTop: "drop me",
175
+ todoList: { content: "a", fluff: true, status: "completed" },
148
176
  });
149
- const item = (out.todoList as Array<Record<string, unknown>>)[0];
150
- expect(item).toEqual({ id: 1, title: "Write the thing", description: "Long context here", status: "not-started" });
177
+ expect(out).toEqual({ operation: "write", todoList: [{ content: "a", status: "completed" }] });
151
178
  });
152
179
 
153
180
  it("keeps null items null so the schema error stays meaningful", () => {
154
181
  const out = prepareTodoArguments({ operation: "write", todoList: [null, "x"] });
155
- expect(out.todoList).toEqual([null, { id: 2, title: "x", description: "x", status: "not-started" }]);
182
+ expect(out.todoList).toEqual([null, { content: "x", status: "pending" }]);
156
183
  });
157
184
 
158
185
  it("is idempotent on repaired output", () => {
159
- const parsed = parseJSON(
160
- JSON.stringify({ ...REAL_MISSING_TITLE, extra: "gap", todoListX: [null] }),
161
- ) as Record<string, unknown>;
162
- const once = prepareTodoArguments(parsed);
163
- const twice = prepareTodoArguments(once);
164
- expect(twice).toEqual(once);
165
- });
166
-
167
- it("is idempotent on the real-world string + missing-title failures", () => {
168
186
  const once = prepareTodoArguments({ operation: "write", todoList: REAL_STRINGIFIED_LIST });
169
187
  expect(prepareTodoArguments(once)).toEqual(once);
170
- expect(prepareTodoArguments(prepareTodoArguments(REAL_MISSING_TITLE))).toEqual(
171
- prepareTodoArguments(REAL_MISSING_TITLE),
172
- );
188
+ const legacy = prepareTodoArguments({ operation: "write", todoList: [LEGACY_ITEM] });
189
+ expect(prepareTodoArguments(legacy)).toEqual(legacy);
190
+ });
191
+ });
192
+
193
+ describe("normalizeTodoItem", () => {
194
+ it("returns a fresh canonical object (no id/title/description keys)", () => {
195
+ const out = normalizeTodoItem({ content: "Fix auth", status: "pending" }, 0);
196
+ expect(out).toEqual({ content: "Fix auth", status: "pending" });
197
+ });
198
+
199
+ it("keeps a textless item as an empty (valid-string) content item", () => {
200
+ expect(normalizeTodoItem({}, 0)).toEqual({ content: "", status: "pending" });
201
+ });
202
+
203
+ it("returns non-object items unchanged", () => {
204
+ expect(normalizeTodoItem(3, 0)).toBe(3);
205
+ expect(normalizeTodoItem("", 0)).toBe("");
206
+ });
207
+
208
+ it("derives content from description when only description was given", () => {
209
+ const desc = "Understand what c12d4a0 fixed and how the current base interacts with it";
210
+ const out = normalizeTodoItem({ description: desc, status: "in-progress" }, 0);
211
+ expect(out).toEqual({
212
+ content: deriveTitleFromDescription(desc),
213
+ description: desc,
214
+ status: "in_progress",
215
+ });
216
+ });
217
+ });
218
+
219
+ describe("normalizeTodoItems", () => {
220
+ it("returns canonical items as-is", () => {
221
+ expect(
222
+ normalizeTodoItems([
223
+ { content: "a", status: "pending" },
224
+ { content: "b", status: "in_progress" },
225
+ ])
226
+ ).toEqual([
227
+ { content: "a", status: "pending" },
228
+ { content: "b", status: "in_progress" },
229
+ ]);
230
+ });
231
+
232
+ it("normalizes legacy items", () => {
233
+ expect(normalizeTodoItems([LEGACY_ITEM])).toEqual([{ content: "Fix auth", description: "Y", status: "pending" }]);
234
+ });
235
+
236
+ it("returns [] for an empty input array", () => {
237
+ expect(normalizeTodoItems([])).toEqual([]);
238
+ });
239
+
240
+ it("returns undefined for null/undefined input", () => {
241
+ expect(normalizeTodoItems(null)).toBeUndefined();
242
+ expect(normalizeTodoItems(undefined)).toBeUndefined();
243
+ });
244
+
245
+ it("returns undefined when no item survives", () => {
246
+ expect(normalizeTodoItems([{ content: "", status: "pending" }])).toBeUndefined();
247
+ expect(normalizeTodoItems(["", null])).toBeUndefined();
248
+ });
249
+
250
+ it("wraps a single looks-like-todo object", () => {
251
+ expect(normalizeTodoItems({ title: "X", status: "not-started" })).toEqual([
252
+ { content: "X", status: "pending" },
253
+ ]);
254
+ });
255
+
256
+ it("returns undefined for unrelated values", () => {
257
+ expect(normalizeTodoItems(42)).toBeUndefined();
258
+ expect(normalizeTodoItems("a string")).toBeUndefined();
173
259
  });
174
260
  });
175
261
 
@@ -186,21 +272,3 @@ describe("deriveTitleFromDescription", () => {
186
272
  expect(title.endsWith(" ")).toBe(false);
187
273
  });
188
274
  });
189
-
190
- describe("normalizeTodoItem", () => {
191
- it("keeps a textless item as an empty item", () => {
192
- expect(normalizeTodoItem({}, 0)).toEqual({ id: 1, title: "", description: "", status: "not-started" });
193
- });
194
-
195
- it("returns non-object items unchanged", () => {
196
- expect(normalizeTodoItem(3, 0)).toBe(3);
197
- expect(normalizeTodoItem("", 0)).toBe("");
198
- });
199
- });
200
-
201
- // ── helpers ─────────────────────────────────────────────────────────────────
202
-
203
- // Deep-clone fixture objects, mirroring how arguments arrive (post-JSON).
204
- function parseJSON(s: string): unknown {
205
- return JSON.parse(s) as unknown;
206
- }