@pi-archimedes/todo 2.3.0 → 2.5.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 -5
- 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/src/prepare-args.ts
CHANGED
|
@@ -3,35 +3,45 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Wires into the tool via `prepareArguments`, which pi runs BEFORE schema
|
|
5
5
|
* validation (and before `execute`). The model occasionally sends
|
|
6
|
-
* malformed-but-recoverable `manage_todo_list` arguments; observed in
|
|
6
|
+
* malformed-but-recoverable `manage_todo_list` arguments; observed in
|
|
7
|
+
* practice (frozen open-weight models, ADR 0009):
|
|
7
8
|
*
|
|
8
9
|
* - `todoList` is a stringified JSON array instead of an array (sometimes
|
|
9
|
-
* with mangled escaping, e.g. `"id": 1"` — an extra quote after the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* with mangled escaping, e.g. `"id": 1"` — an extra quote after the
|
|
11
|
+
* number)
|
|
12
|
+
* - items put the task text under `title`/`step` (shapes borrowed from
|
|
13
|
+
* other harnesses) instead of the canonical `content`
|
|
14
|
+
* - `id` is invented, omitted, or nulled — the canonical schema has NO
|
|
15
|
+
* `id`; display numbering is array position, so it is stripped, not
|
|
16
|
+
* repaired
|
|
17
|
+
* - statuses come out dashed (a hyphenated variant of a canonical
|
|
18
|
+
* value that then collapses to a no-separator form), or as plain
|
|
19
|
+
* english synonyms ("doing", "done")
|
|
13
20
|
* - items are plain strings
|
|
14
|
-
* - `id` missing or a numeric string
|
|
15
21
|
* - stray top-level / per-item keys outside the schema
|
|
16
22
|
*
|
|
17
|
-
* Only *recoverable* deviations are repaired. Anything unrecoverable is
|
|
18
|
-
* through untouched so the standard validation error still surfaces
|
|
19
|
-
* real complaint. The public schema stays strict; this module is
|
|
20
|
-
* repair layer.
|
|
23
|
+
* Only *recoverable* deviations are repaired. Anything unrecoverable is
|
|
24
|
+
* passed through untouched so the standard validation error still surfaces
|
|
25
|
+
* with the real complaint. The public schema stays strict; this module is
|
|
26
|
+
* purely a repair layer.
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
import type { ManageTodoListInput } from "./tool.js";
|
|
24
|
-
import type { TodoStatus } from "./types.js";
|
|
30
|
+
import type { TodoItem, TodoStatus } from "./types.js";
|
|
25
31
|
|
|
26
32
|
type Record_ = Record<string, unknown>;
|
|
27
33
|
|
|
28
34
|
const VALID_STATUSES: ReadonlySet<string> = new Set<string>([
|
|
29
|
-
"
|
|
30
|
-
"
|
|
35
|
+
"pending",
|
|
36
|
+
"in_progress",
|
|
31
37
|
"completed",
|
|
32
38
|
]);
|
|
33
39
|
|
|
34
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* LLM synonyms for the three enum values, keyed by the collapsed form
|
|
42
|
+
* (lowercase, all whitespace/underscore/dash separators removed) so dashed,
|
|
43
|
+
* spaced, and underscored variants all land on the same key.
|
|
44
|
+
*/
|
|
35
45
|
const STATUS_ALIASES: ReadonlyMap<string, TodoStatus> = new Map<string, TodoStatus>([
|
|
36
46
|
["done", "completed"],
|
|
37
47
|
["complete", "completed"],
|
|
@@ -39,28 +49,41 @@ const STATUS_ALIASES: ReadonlyMap<string, TodoStatus> = new Map<string, TodoStat
|
|
|
39
49
|
["closed", "completed"],
|
|
40
50
|
["success", "completed"],
|
|
41
51
|
["passed", "completed"],
|
|
42
|
-
["doing", "
|
|
43
|
-
["started", "
|
|
44
|
-
["working", "
|
|
45
|
-
["active", "
|
|
46
|
-
["wip", "
|
|
47
|
-
["ongoing", "
|
|
48
|
-
["
|
|
49
|
-
["
|
|
50
|
-
["
|
|
51
|
-
["
|
|
52
|
+
["doing", "in_progress"],
|
|
53
|
+
["started", "in_progress"],
|
|
54
|
+
["working", "in_progress"],
|
|
55
|
+
["active", "in_progress"],
|
|
56
|
+
["wip", "in_progress"],
|
|
57
|
+
["ongoing", "in_progress"],
|
|
58
|
+
["inprogress", "in_progress"],
|
|
59
|
+
["pending", "pending"],
|
|
60
|
+
["todo", "pending"],
|
|
61
|
+
["planned", "pending"],
|
|
62
|
+
["untouched", "pending"],
|
|
63
|
+
["notstarted", "pending"],
|
|
64
|
+
["unstarted", "pending"],
|
|
65
|
+
["open", "pending"],
|
|
52
66
|
]);
|
|
53
67
|
|
|
54
68
|
/**
|
|
55
|
-
* Keys the model sometimes uses instead of `
|
|
56
|
-
*
|
|
57
|
-
*
|
|
69
|
+
* Keys the model sometimes uses instead of `content`. Checked only when
|
|
70
|
+
* `content` itself is missing/empty, so a model that DID send `content` is
|
|
71
|
+
* never overwritten.
|
|
58
72
|
*/
|
|
59
|
-
const
|
|
73
|
+
const CONTENT_FALLBACK_KEYS = ["title", "step", "task", "text", "name", "label", "activeForm"] as const;
|
|
60
74
|
const DESCRIPTION_FALLBACK_KEYS = ["details", "notes", "summary", "context"] as const;
|
|
61
75
|
|
|
62
|
-
/**
|
|
63
|
-
const
|
|
76
|
+
/** Reserved keys the last-resort text scan must never pick up. */
|
|
77
|
+
const LAST_RESORT_SKIP_KEYS = new Set<string>([
|
|
78
|
+
"content",
|
|
79
|
+
...(CONTENT_FALLBACK_KEYS as readonly string[]),
|
|
80
|
+
"description",
|
|
81
|
+
...(DESCRIPTION_FALLBACK_KEYS as readonly string[]),
|
|
82
|
+
"status",
|
|
83
|
+
"id",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const DERIVED_CONTENT_MAX = 60;
|
|
64
87
|
|
|
65
88
|
function isRecord(value: unknown): value is Record_ {
|
|
66
89
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -75,58 +98,48 @@ function firstStringOf(record: Record_, keys: readonly string[]): string {
|
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
/**
|
|
78
|
-
* Collapse whitespace and cut at a word boundary so a derived
|
|
79
|
-
* short, human-readable label in the widget.
|
|
101
|
+
* Collapse whitespace and cut at a word boundary so a derived `content`
|
|
102
|
+
* stays a short, human-readable label in the widget.
|
|
80
103
|
*/
|
|
81
104
|
export function deriveTitleFromDescription(description: string): string {
|
|
82
105
|
const flat = description.replace(/\s+/g, " ").trim();
|
|
83
|
-
if (flat.length <=
|
|
84
|
-
const cut = flat.slice(0,
|
|
106
|
+
if (flat.length <= DERIVED_CONTENT_MAX) return flat;
|
|
107
|
+
const cut = flat.slice(0, DERIVED_CONTENT_MAX);
|
|
85
108
|
const lastSpace = cut.lastIndexOf(" ");
|
|
86
109
|
const head = lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
|
|
87
110
|
return `${head.trimEnd()}…`;
|
|
88
111
|
}
|
|
89
112
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
n = Number.NaN;
|
|
98
|
-
}
|
|
99
|
-
return Number.isFinite(n) ? Math.trunc(n) : index + 1;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function normalizeStatus(raw: unknown): TodoStatus {
|
|
113
|
+
/**
|
|
114
|
+
* Normalize a raw status to the canonical enum. Accepts dashed/spaced/
|
|
115
|
+
* underscored variants and plain-english synonyms; anything unrecognized
|
|
116
|
+
* (incl. non-strings) falls back to `"pending"`. Kept permissive — this is
|
|
117
|
+
* the REPAIR layer. `state-manager.validate()` stays canonical-strict.
|
|
118
|
+
*/
|
|
119
|
+
export function normalizeStatus(raw: unknown): TodoStatus {
|
|
103
120
|
if (typeof raw !== "string") {
|
|
104
121
|
// Missing, null, number, … — no signal about intent.
|
|
105
|
-
return "
|
|
122
|
+
return "pending";
|
|
106
123
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return STATUS_ALIASES.get(
|
|
124
|
+
const s = raw.trim().toLowerCase();
|
|
125
|
+
if (VALID_STATUSES.has(s)) return s as TodoStatus;
|
|
126
|
+
const collapsed = s.replace(/[\s_-]+/g, "");
|
|
127
|
+
return STATUS_ALIASES.get(collapsed) ?? "pending";
|
|
111
128
|
}
|
|
112
129
|
|
|
113
130
|
/**
|
|
114
|
-
* Normalize one todo item. Returns a fresh object containing ONLY the
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
131
|
+
* Normalize one todo item. Returns a fresh object containing ONLY the
|
|
132
|
+
* canonical keys `{content, description?, status}` — `id` is always dropped,
|
|
133
|
+
* extra keys are dropped (the schema rejects them with "must not have
|
|
134
|
+
* additional properties"). Unrecoverable items are returned as-is so the
|
|
135
|
+
* schema error keeps its meaning.
|
|
118
136
|
*/
|
|
119
137
|
export function normalizeTodoItem(raw: unknown, index: number): unknown {
|
|
120
138
|
// Laziest form: ["write tests", "deploy"]
|
|
121
139
|
if (typeof raw === "string") {
|
|
122
140
|
const text = raw.trim();
|
|
123
141
|
if (text === "") return raw;
|
|
124
|
-
return {
|
|
125
|
-
id: index + 1,
|
|
126
|
-
title: deriveTitleFromDescription(text),
|
|
127
|
-
description: text,
|
|
128
|
-
status: "not-started" as TodoStatus,
|
|
129
|
-
};
|
|
142
|
+
return { content: deriveTitleFromDescription(text), status: "pending" as TodoStatus };
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
if (!isRecord(raw)) return raw; // null / number / nested array → schema error
|
|
@@ -134,20 +147,63 @@ export function normalizeTodoItem(raw: unknown, index: number): unknown {
|
|
|
134
147
|
const description =
|
|
135
148
|
(typeof raw.description === "string" ? raw.description.trim() : "") ||
|
|
136
149
|
firstStringOf(raw, DESCRIPTION_FALLBACK_KEYS);
|
|
137
|
-
let
|
|
138
|
-
|
|
139
|
-
|
|
150
|
+
let content =
|
|
151
|
+
(typeof raw.content === "string" ? raw.content.trim() : "") ||
|
|
152
|
+
firstStringOf(raw, CONTENT_FALLBACK_KEYS);
|
|
153
|
+
if (content === "" && description !== "") {
|
|
154
|
+
content = deriveTitleFromDescription(description);
|
|
155
|
+
}
|
|
156
|
+
// Last resort: any remaining non-empty string field that isn't a known
|
|
157
|
+
// alias/status/id. This alone is the catch-all.
|
|
158
|
+
if (content === "") {
|
|
159
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
160
|
+
if (!LAST_RESORT_SKIP_KEYS.has(key) && typeof value === "string" && value.trim() !== "") {
|
|
161
|
+
content = value;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
140
166
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
167
|
+
// If both are empty this returns { content: "", status } — deliberately
|
|
168
|
+
// keeps an empty (valid-string) field so state-manager.validate() reports
|
|
169
|
+
// the missing `content` instead of the schema masking it.
|
|
143
170
|
return {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
description: description || title,
|
|
171
|
+
content,
|
|
172
|
+
...(description !== "" ? { description } : {}),
|
|
147
173
|
status: normalizeStatus(raw.status),
|
|
148
174
|
};
|
|
149
175
|
}
|
|
150
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Normalize a raw todo list (e.g. from bus payloads) into canonical
|
|
179
|
+
* TodoItems. Returns [] for an empty input array, undefined for anything
|
|
180
|
+
* unrecoverable. Used by the bus consumer (index.ts) and the subagent
|
|
181
|
+
* stream — where prepareArguments is NOT in the call path.
|
|
182
|
+
*/
|
|
183
|
+
export function normalizeTodoItems(raw: unknown): TodoItem[] | undefined {
|
|
184
|
+
let list: unknown[] | undefined;
|
|
185
|
+
if (Array.isArray(raw)) {
|
|
186
|
+
list = raw;
|
|
187
|
+
} else if (raw != null && looksLikeTodoItem(raw)) {
|
|
188
|
+
list = [raw];
|
|
189
|
+
} else {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const items = list.map((item, i) => normalizeTodoItem(item, i));
|
|
194
|
+
const surviving = items.filter(
|
|
195
|
+
(item): item is TodoItem =>
|
|
196
|
+
isRecord(item) &&
|
|
197
|
+
typeof item.content === "string" &&
|
|
198
|
+
item.content.trim() !== "" &&
|
|
199
|
+
typeof item.status === "string" &&
|
|
200
|
+
VALID_STATUSES.has(item.status),
|
|
201
|
+
);
|
|
202
|
+
if (items.length === 0) return [];
|
|
203
|
+
if (surviving.length === 0) return undefined;
|
|
204
|
+
return surviving;
|
|
205
|
+
}
|
|
206
|
+
|
|
151
207
|
/**
|
|
152
208
|
* Repair the occasional mangled JSON: `"id": 1"` (an extra quote after the
|
|
153
209
|
* number). The replacement is safe to apply blindly — that shape is invalid
|
|
@@ -171,7 +227,11 @@ function parseStringifiedList(raw: string): unknown {
|
|
|
171
227
|
function looksLikeTodoItem(value: unknown): boolean {
|
|
172
228
|
return (
|
|
173
229
|
isRecord(value) &&
|
|
174
|
-
("
|
|
230
|
+
("content" in value ||
|
|
231
|
+
"title" in value ||
|
|
232
|
+
"step" in value ||
|
|
233
|
+
"description" in value ||
|
|
234
|
+
"status" in value)
|
|
175
235
|
);
|
|
176
236
|
}
|
|
177
237
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import * as fc from "fast-check";
|
|
3
3
|
import { TodoStateManager } from "./state-manager.js";
|
|
4
|
-
import type { TodoItem } from "./types.js";
|
|
4
|
+
import type { TodoItem, TodoStatus } from "./types.js";
|
|
5
5
|
|
|
6
6
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
function makeTodo(
|
|
9
|
-
return {
|
|
8
|
+
function makeTodo(content: string, status: TodoStatus, description?: string): TodoItem {
|
|
9
|
+
return description === undefined ? { content, status } : { content, status, description };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
// ── TodoStateManager ────────────────────────────────────────────────────────
|
|
@@ -29,9 +29,9 @@ describe("TodoStateManager", () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
it("returns copy (mutations don't affect internal state)", () => {
|
|
32
|
-
manager.write([makeTodo(
|
|
32
|
+
manager.write([makeTodo("Task", "pending")]);
|
|
33
33
|
const copy = manager.read();
|
|
34
|
-
copy.push(makeTodo(
|
|
34
|
+
copy.push(makeTodo("Injected", "pending"));
|
|
35
35
|
expect(manager.read()).toHaveLength(1);
|
|
36
36
|
});
|
|
37
37
|
});
|
|
@@ -39,16 +39,16 @@ describe("TodoStateManager", () => {
|
|
|
39
39
|
describe("write", () => {
|
|
40
40
|
it("stores todos correctly", () => {
|
|
41
41
|
manager.write([
|
|
42
|
-
makeTodo(
|
|
43
|
-
makeTodo(
|
|
42
|
+
makeTodo("Task 1", "pending"),
|
|
43
|
+
makeTodo("Task 2", "in_progress"),
|
|
44
44
|
]);
|
|
45
45
|
expect(manager.read()).toHaveLength(2);
|
|
46
|
-
expect(manager.read()[0]?.
|
|
47
|
-
expect(manager.read()[1]?.status).toBe("
|
|
46
|
+
expect(manager.read()[0]?.content).toBe("Task 1");
|
|
47
|
+
expect(manager.read()[1]?.status).toBe("in_progress");
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
it("with all completed schedules auto-clear", () => {
|
|
51
|
-
manager.write([makeTodo(
|
|
51
|
+
manager.write([makeTodo("Done", "completed")]);
|
|
52
52
|
expect(manager.read()).toHaveLength(1);
|
|
53
53
|
vi.advanceTimersByTime(2000);
|
|
54
54
|
expect(manager.read()).toHaveLength(0);
|
|
@@ -56,8 +56,8 @@ describe("TodoStateManager", () => {
|
|
|
56
56
|
|
|
57
57
|
it("does not schedule auto-clear when not all completed", () => {
|
|
58
58
|
manager.write([
|
|
59
|
-
makeTodo(
|
|
60
|
-
makeTodo(
|
|
59
|
+
makeTodo("Done", "completed"),
|
|
60
|
+
makeTodo("Pending", "pending"),
|
|
61
61
|
]);
|
|
62
62
|
vi.advanceTimersByTime(2000);
|
|
63
63
|
expect(manager.read()).toHaveLength(2);
|
|
@@ -66,25 +66,25 @@ describe("TodoStateManager", () => {
|
|
|
66
66
|
|
|
67
67
|
describe("clear", () => {
|
|
68
68
|
it("empties todos", () => {
|
|
69
|
-
manager.write([makeTodo(
|
|
69
|
+
manager.write([makeTodo("Task", "pending")]);
|
|
70
70
|
manager.clear();
|
|
71
71
|
expect(manager.read()).toEqual([]);
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
describe("getStats", () => {
|
|
76
|
-
it("computes correct counts", () => {
|
|
76
|
+
it("computes correct counts incl. pending", () => {
|
|
77
77
|
manager.write([
|
|
78
|
-
makeTodo(
|
|
79
|
-
makeTodo(
|
|
80
|
-
makeTodo(
|
|
81
|
-
makeTodo(
|
|
78
|
+
makeTodo("A", "pending"),
|
|
79
|
+
makeTodo("B", "in_progress"),
|
|
80
|
+
makeTodo("C", "completed"),
|
|
81
|
+
makeTodo("D", "completed"),
|
|
82
82
|
]);
|
|
83
83
|
expect(manager.getStats()).toEqual({
|
|
84
84
|
total: 4,
|
|
85
85
|
completed: 2,
|
|
86
86
|
inProgress: 1,
|
|
87
|
-
|
|
87
|
+
pending: 1,
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
90
|
|
|
@@ -93,38 +93,56 @@ describe("TodoStateManager", () => {
|
|
|
93
93
|
total: 0,
|
|
94
94
|
completed: 0,
|
|
95
95
|
inProgress: 0,
|
|
96
|
-
|
|
96
|
+
pending: 0,
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
describe("validate", () => {
|
|
102
|
-
it("
|
|
103
|
-
const result = manager.validate([
|
|
102
|
+
it("accepts canonical items with optional description", () => {
|
|
103
|
+
const result = manager.validate([
|
|
104
|
+
{ content: "Task", status: "pending" },
|
|
105
|
+
{ content: "Task 2", status: "in_progress", description: "desc" },
|
|
106
|
+
{ content: "Task 3", status: "completed" },
|
|
107
|
+
]);
|
|
108
|
+
expect(result.valid).toBe(true);
|
|
109
|
+
expect(result.errors).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("rejects empty content", () => {
|
|
113
|
+
const result = manager.validate([{ content: " ", status: "pending" }]);
|
|
104
114
|
expect(result.valid).toBe(false);
|
|
105
|
-
expect(result.errors.
|
|
115
|
+
expect(result.errors.some((e) => e.includes("content"))).toBe(true);
|
|
106
116
|
});
|
|
107
117
|
|
|
108
|
-
it("
|
|
109
|
-
const result = manager.validate([{
|
|
118
|
+
it("rejects missing content", () => {
|
|
119
|
+
const result = manager.validate([{ status: "pending" } as unknown as TodoItem]);
|
|
110
120
|
expect(result.valid).toBe(false);
|
|
111
|
-
expect(result.errors
|
|
121
|
+
expect(result.errors.some((e) => e.includes("content"))).toBe(true);
|
|
112
122
|
});
|
|
113
123
|
|
|
114
|
-
it("
|
|
115
|
-
const result = manager.validate("
|
|
124
|
+
it("rejects dashed legacy statuses (validate stays canonical-strict)", () => {
|
|
125
|
+
const result = manager.validate([{ content: "Test", status: "in-progress" } as unknown as TodoItem]);
|
|
116
126
|
expect(result.valid).toBe(false);
|
|
117
|
-
expect(result.errors
|
|
127
|
+
expect(result.errors.some((e) => e.includes("pending, in_progress, completed"))).toBe(true);
|
|
118
128
|
});
|
|
119
129
|
|
|
120
|
-
it("
|
|
121
|
-
const result = manager.validate([
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
it("rejects non-string description", () => {
|
|
131
|
+
const result = manager.validate([{ content: "x", status: "pending", description: 42 } as unknown as TodoItem]);
|
|
132
|
+
expect(result.valid).toBe(false);
|
|
133
|
+
expect(result.errors.some((e) => e.includes("description"))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects undefined items", () => {
|
|
137
|
+
const result = manager.validate([undefined, { content: "x", status: "pending" }] as unknown as TodoItem[]);
|
|
138
|
+
expect(result.valid).toBe(false);
|
|
139
|
+
expect(result.errors.some((e) => e.includes("undefined item"))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("catches non-array input", () => {
|
|
143
|
+
const result = manager.validate("not an array" as unknown as TodoItem[]);
|
|
144
|
+
expect(result.valid).toBe(false);
|
|
145
|
+
expect(result.errors[0]).toBe("todoList must be an array");
|
|
128
146
|
});
|
|
129
147
|
|
|
130
148
|
it("property: validate(write(x)).valid === true after write (round-trip)", () => {
|
|
@@ -132,15 +150,14 @@ describe("TodoStateManager", () => {
|
|
|
132
150
|
fc.property(
|
|
133
151
|
fc.array(
|
|
134
152
|
fc.record({
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
status: fc.oneof(fc.constant("not-started"), fc.constant("in-progress"), fc.constant("completed")),
|
|
153
|
+
content: fc.string({ minLength: 1, maxLength: 50 }),
|
|
154
|
+
description: fc.string({ minLength: 0, maxLength: 100 }),
|
|
155
|
+
status: fc.oneof(fc.constant("pending"), fc.constant("in_progress"), fc.constant("completed")),
|
|
139
156
|
}),
|
|
140
157
|
),
|
|
141
158
|
(todos) => {
|
|
142
159
|
const mgr = new TodoStateManager();
|
|
143
|
-
mgr.write(todos as TodoItem[]);
|
|
160
|
+
mgr.write(todos.filter((t) => t.content.trim() !== "") as TodoItem[]);
|
|
144
161
|
const readTodos = mgr.read();
|
|
145
162
|
const result = mgr.validate(readTodos);
|
|
146
163
|
if (!result.valid) {
|
|
@@ -152,4 +169,58 @@ describe("TodoStateManager", () => {
|
|
|
152
169
|
);
|
|
153
170
|
});
|
|
154
171
|
});
|
|
172
|
+
|
|
173
|
+
describe("loadFromSession", () => {
|
|
174
|
+
function fakeCtx(branch: unknown[]): unknown {
|
|
175
|
+
return { sessionManager: { getBranch: () => branch } };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function toolResultEntry(details: unknown): unknown {
|
|
179
|
+
return { type: "message", message: { role: "toolResult", toolName: "manage_todo_list", details } };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
it("normalizes legacy persisted items to canonical shape (id dropped)", () => {
|
|
183
|
+
const ctx = fakeCtx([
|
|
184
|
+
toolResultEntry({
|
|
185
|
+
todos: [{ id: 1, title: "Old", status: "not-started" }],
|
|
186
|
+
}),
|
|
187
|
+
]);
|
|
188
|
+
manager.loadFromSession(ctx as never);
|
|
189
|
+
expect(manager.read()).toEqual([{ content: "Old", status: "pending" }]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("keeps current canonical items verbatim", () => {
|
|
193
|
+
const ctx = fakeCtx([
|
|
194
|
+
toolResultEntry({
|
|
195
|
+
todos: [
|
|
196
|
+
{ content: "A", status: "in_progress" },
|
|
197
|
+
{ content: "B", status: "completed", description: "d" },
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
]);
|
|
201
|
+
manager.loadFromSession(ctx as never);
|
|
202
|
+
expect(manager.read()).toEqual([
|
|
203
|
+
{ content: "A", status: "in_progress" },
|
|
204
|
+
{ content: "B", status: "completed", description: "d" },
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("ignores other tools and non-message entries", () => {
|
|
209
|
+
const ctx = fakeCtx([
|
|
210
|
+
{ type: "message", message: { role: "toolResult", toolName: "run_read", details: { todos: [] } } },
|
|
211
|
+
toolResultEntry({ todos: [{ content: "X", status: "pending" }] }),
|
|
212
|
+
]);
|
|
213
|
+
manager.loadFromSession(ctx as never);
|
|
214
|
+
expect(manager.read()).toEqual([{ content: "X", status: "pending" }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("ignores unrecoverable persisted entries (leaves earlier state)", () => {
|
|
218
|
+
const ctx = fakeCtx([
|
|
219
|
+
toolResultEntry({ todos: [{ content: "Good", status: "pending" }] }),
|
|
220
|
+
toolResultEntry({ todos: "a bare string" }),
|
|
221
|
+
]);
|
|
222
|
+
manager.loadFromSession(ctx as never);
|
|
223
|
+
expect(manager.read()).toEqual([{ content: "Good", status: "pending" }]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
155
226
|
});
|
package/src/state-manager.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { normalizeTodoItems } from "./prepare-args.js";
|
|
2
3
|
import type { TodoItem, TodoStats, ValidationResult } from "./types.js";
|
|
3
4
|
|
|
4
5
|
/** Manages the in-memory todo list state. */
|
|
@@ -27,9 +28,9 @@ export class TodoStateManager {
|
|
|
27
28
|
getStats(): TodoStats {
|
|
28
29
|
const total = this.todos.length;
|
|
29
30
|
const completed = this.todos.filter((t) => t.status === "completed").length;
|
|
30
|
-
const inProgress = this.todos.filter((t) => t.status === "
|
|
31
|
-
const
|
|
32
|
-
return { total, completed, inProgress,
|
|
31
|
+
const inProgress = this.todos.filter((t) => t.status === "in_progress").length;
|
|
32
|
+
const pending = this.todos.filter((t) => t.status === "pending").length;
|
|
33
|
+
return { total, completed, inProgress, pending };
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
validate(todos: TodoItem[]): ValidationResult {
|
|
@@ -37,7 +38,7 @@ export class TodoStateManager {
|
|
|
37
38
|
if (!Array.isArray(todos)) {
|
|
38
39
|
return { valid: false, errors: ["todoList must be an array"] };
|
|
39
40
|
}
|
|
40
|
-
const validStatuses = new Set(["
|
|
41
|
+
const validStatuses = new Set(["pending", "in_progress", "completed"]);
|
|
41
42
|
for (let i = 0; i < todos.length; i++) {
|
|
42
43
|
const item = todos[i];
|
|
43
44
|
const prefix = `Item ${i + 1}`;
|
|
@@ -45,19 +46,14 @@ export class TodoStateManager {
|
|
|
45
46
|
errors.push(`${prefix}: undefined item`);
|
|
46
47
|
continue;
|
|
47
48
|
}
|
|
48
|
-
if (item.
|
|
49
|
-
errors.push(`${prefix}: missing '
|
|
50
|
-
} else if (typeof item.id !== "number") {
|
|
51
|
-
errors.push(`${prefix}: 'id' must be a number`);
|
|
49
|
+
if (typeof item.content !== "string" || item.content.trim() === "") {
|
|
50
|
+
errors.push(`${prefix}: missing or invalid 'content'`);
|
|
52
51
|
}
|
|
53
|
-
if (
|
|
54
|
-
errors.push(`${prefix}:
|
|
52
|
+
if (typeof item.status !== "string" || !validStatuses.has(item.status)) {
|
|
53
|
+
errors.push(`${prefix}: 'status' must be one of: pending, in_progress, completed`);
|
|
55
54
|
}
|
|
56
|
-
if (
|
|
57
|
-
errors.push(`${prefix}:
|
|
58
|
-
}
|
|
59
|
-
if (!item.status || !validStatuses.has(item.status)) {
|
|
60
|
-
errors.push(`${prefix}: 'status' must be one of: not-started, in-progress, completed`);
|
|
55
|
+
if (item.description !== undefined && typeof item.description !== "string") {
|
|
56
|
+
errors.push(`${prefix}: 'description' must be a string`);
|
|
61
57
|
}
|
|
62
58
|
}
|
|
63
59
|
return { valid: errors.length === 0, errors };
|
|
@@ -69,9 +65,14 @@ export class TodoStateManager {
|
|
|
69
65
|
if (entry.type !== "message") continue;
|
|
70
66
|
const msg = entry.message;
|
|
71
67
|
if (msg.role !== "toolResult" || msg.toolName !== "manage_todo_list") continue;
|
|
72
|
-
const details = msg.details as { todos?:
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
const details = msg.details as { todos?: unknown } | undefined;
|
|
69
|
+
// Legacy persisted items (id / title / dashed or synonym
|
|
70
|
+
// statuses) and newer canonical items both go through the same
|
|
71
|
+
// normalizer. Unrecoverable entries
|
|
72
|
+
// leave state untouched for that entry.
|
|
73
|
+
const items = normalizeTodoItems(details?.todos);
|
|
74
|
+
if (items) {
|
|
75
|
+
this.todos = items.map((t) => ({ ...t }));
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
}
|