@pi-archimedes/todo 2.2.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 +9 -2
- package/src/index.test.ts +160 -0
- package/src/index.ts +12 -2
- package/src/prepare-args.test.ts +274 -0
- package/src/prepare-args.ts +296 -0
- package/src/state-manager.test.ts +113 -42
- package/src/state-manager.ts +19 -18
- package/src/tool.test.ts +148 -0
- package/src/tool.ts +55 -32
- 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",
|
|
@@ -19,6 +23,9 @@
|
|
|
19
23
|
"@earendil-works/pi-tui": ">=0.1.0"
|
|
20
24
|
},
|
|
21
25
|
"devDependencies": {
|
|
26
|
+
"@earendil-works/pi-ai": "^0.84.2",
|
|
27
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
28
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
22
29
|
"typescript": "^6.0.0"
|
|
23
30
|
},
|
|
24
31
|
"pi": {
|
|
@@ -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
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
deriveTitleFromDescription,
|
|
4
|
+
normalizeTodoItem,
|
|
5
|
+
normalizeTodoItems,
|
|
6
|
+
prepareTodoArguments,
|
|
7
|
+
} from "./prepare-args.js";
|
|
8
|
+
|
|
9
|
+
// Arguments captured (after JSON decoding) from real failing tool calls in
|
|
10
|
+
// archived sessions — the cases this module exists to repair.
|
|
11
|
+
const REAL_STRINGIFIED_LIST =
|
|
12
|
+
'[{"id": 1", "title": "Catalog all undocumented env vars", "description": ' +
|
|
13
|
+
'"Compare env vars from all K8s manifests against what is in AI-SETUP.md", ' +
|
|
14
|
+
'"status": "completed"}, {"id": 2", "title": "Add K8s env vars section", ' +
|
|
15
|
+
'"description": "Document all container-level env vars", ' +
|
|
16
|
+
'"status": "in-progress"}]';
|
|
17
|
+
|
|
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" };
|
|
21
|
+
|
|
22
|
+
describe("prepareTodoArguments", () => {
|
|
23
|
+
it("passes through already-valid canonical arguments unchanged", () => {
|
|
24
|
+
const input = {
|
|
25
|
+
operation: "write",
|
|
26
|
+
todoList: [
|
|
27
|
+
{ content: "Fix auth", status: "pending" as const },
|
|
28
|
+
{ content: "Ship it", status: "completed" as const },
|
|
29
|
+
],
|
|
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
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("passes non-object arguments through untouched", () => {
|
|
40
|
+
for (const garbage of [null, undefined, 42, "write", [1, 2]]) {
|
|
41
|
+
expect(prepareTodoArguments(garbage)).toBe(garbage);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
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
|
+
|
|
76
|
+
it("parses a stringified todoList (real-world case, incl. mangled quotes)", () => {
|
|
77
|
+
const out = prepareTodoArguments({ operation: "write", todoList: REAL_STRINGIFIED_LIST });
|
|
78
|
+
expect(out.operation).toBe("write");
|
|
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
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns a stringified list it cannot parse, so the schema error still fires", () => {
|
|
94
|
+
const out = prepareTodoArguments({ operation: "write", todoList: "[{ def 《" });
|
|
95
|
+
expect(out.todoList).toBe("[{ def 《");
|
|
96
|
+
});
|
|
97
|
+
|
|
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" }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
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" }]);
|
|
106
|
+
});
|
|
107
|
+
|
|
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" }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("normalizes status synonyms, spacing and dashes", () => {
|
|
114
|
+
const out = prepareTodoArguments({
|
|
115
|
+
operation: "write",
|
|
116
|
+
todoList: [
|
|
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" },
|
|
126
|
+
],
|
|
127
|
+
});
|
|
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",
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("end-to-end: prepareTodoArguments emits operation and canonical list", () => {
|
|
143
|
+
const out = prepareTodoArguments({
|
|
144
|
+
operation: "write",
|
|
145
|
+
todoList: [{ content: "a", status: "pending" }, LEGACY_ITEM],
|
|
146
|
+
});
|
|
147
|
+
expect(out).toEqual({
|
|
148
|
+
operation: "write",
|
|
149
|
+
todoList: [
|
|
150
|
+
{ content: "a", status: "pending" },
|
|
151
|
+
{ content: "Fix auth", description: "Y", status: "pending" },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("infers operation from a present list, tolerates casing, and defaults to read", () => {
|
|
157
|
+
const withList = prepareTodoArguments({ operation: "WRITE-please", todoList: ["x"] });
|
|
158
|
+
expect(withList.operation).toBe("write");
|
|
159
|
+
const inferred = prepareTodoArguments({ todoList: ["x"] });
|
|
160
|
+
expect(inferred.operation).toBe("write");
|
|
161
|
+
const read = prepareTodoArguments({});
|
|
162
|
+
expect(read).toEqual({ operation: "read" });
|
|
163
|
+
});
|
|
164
|
+
|
|
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)", () => {
|
|
172
|
+
const out = prepareTodoArguments({
|
|
173
|
+
operation: "write",
|
|
174
|
+
extraTop: "drop me",
|
|
175
|
+
todoList: { content: "a", fluff: true, status: "completed" },
|
|
176
|
+
});
|
|
177
|
+
expect(out).toEqual({ operation: "write", todoList: [{ content: "a", status: "completed" }] });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("keeps null items null so the schema error stays meaningful", () => {
|
|
181
|
+
const out = prepareTodoArguments({ operation: "write", todoList: [null, "x"] });
|
|
182
|
+
expect(out.todoList).toEqual([null, { content: "x", status: "pending" }]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("is idempotent on repaired output", () => {
|
|
186
|
+
const once = prepareTodoArguments({ operation: "write", todoList: REAL_STRINGIFIED_LIST });
|
|
187
|
+
expect(prepareTodoArguments(once)).toEqual(once);
|
|
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();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("deriveTitleFromDescription", () => {
|
|
263
|
+
it("returns short descriptions verbatim", () => {
|
|
264
|
+
expect(deriveTitleFromDescription(" Fix the build ")).toBe("Fix the build");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("cuts long descriptions at a word boundary with an ellipsis", () => {
|
|
268
|
+
const long = "Understand what c12d4a0 fixed and how the current base interacts with it including patch 8";
|
|
269
|
+
const title = deriveTitleFromDescription(long);
|
|
270
|
+
expect(title.endsWith("…")).toBe(true);
|
|
271
|
+
expect(title.length).toBeLessThanOrEqual(61);
|
|
272
|
+
expect(title.endsWith(" ")).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
});
|