@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 +6 -6
- package/package.json +6 -2
- package/src/index.test.ts +160 -0
- package/src/index.ts +12 -2
- package/src/prepare-args.test.ts +177 -109
- package/src/prepare-args.ts +131 -71
- package/src/state-manager.test.ts +113 -42
- package/src/state-manager.ts +19 -18
- package/src/tool.test.ts +60 -3
- package/src/tool.ts +25 -18
- package/src/types.ts +11 -13
- package/src/ui/todo-widget.ts +9 -7
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,
|
|
20
|
+
Widget showing three todos with completion status — completed items dimmed with strikethrough, the item currently being worked on highlighted:
|
|
21
21
|
|
|
22
22
|

|
|
23
23
|
|
|
@@ -59,9 +59,9 @@ The `manage_todo_list` tool accepts two operations:
|
|
|
59
59
|
{
|
|
60
60
|
"operation": "write",
|
|
61
61
|
"todoList": [
|
|
62
|
-
{ "
|
|
63
|
-
{ "
|
|
64
|
-
{ "
|
|
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
|
-
| `
|
|
79
|
-
| `
|
|
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
|
+
"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.
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
package/src/prepare-args.test.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
{
|
|
39
|
-
{
|
|
27
|
+
{ content: "Fix auth", status: "pending" as const },
|
|
28
|
+
{ content: "Ship it", status: "completed" as const },
|
|
40
29
|
],
|
|
41
|
-
}
|
|
42
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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("
|
|
67
|
-
const out = prepareTodoArguments(
|
|
68
|
-
|
|
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("
|
|
75
|
-
const out = prepareTodoArguments(
|
|
76
|
-
|
|
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("
|
|
81
|
-
const out = prepareTodoArguments({
|
|
82
|
-
|
|
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("
|
|
113
|
+
it("normalizes status synonyms, spacing and dashes", () => {
|
|
98
114
|
const out = prepareTodoArguments({
|
|
99
115
|
operation: "write",
|
|
100
116
|
todoList: [
|
|
101
|
-
{
|
|
102
|
-
{
|
|
103
|
-
{
|
|
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
|
|
107
|
-
expect(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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("
|
|
142
|
+
it("end-to-end: prepareTodoArguments emits operation and canonical list", () => {
|
|
120
143
|
const out = prepareTodoArguments({
|
|
121
144
|
operation: "write",
|
|
122
|
-
|
|
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: [
|
|
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("
|
|
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
|
-
|
|
174
|
+
extraTop: "drop me",
|
|
175
|
+
todoList: { content: "a", fluff: true, status: "completed" },
|
|
148
176
|
});
|
|
149
|
-
|
|
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, {
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
}
|