@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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility shim for LLM tool-call arguments.
|
|
3
|
+
*
|
|
4
|
+
* Wires into the tool via `prepareArguments`, which pi runs BEFORE schema
|
|
5
|
+
* validation (and before `execute`). The model occasionally sends
|
|
6
|
+
* malformed-but-recoverable `manage_todo_list` arguments; observed in
|
|
7
|
+
* practice (frozen open-weight models, ADR 0009):
|
|
8
|
+
*
|
|
9
|
+
* - `todoList` is a stringified JSON array instead of an array (sometimes
|
|
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")
|
|
20
|
+
* - items are plain strings
|
|
21
|
+
* - stray top-level / per-item keys outside the schema
|
|
22
|
+
*
|
|
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.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ManageTodoListInput } from "./tool.js";
|
|
30
|
+
import type { TodoItem, TodoStatus } from "./types.js";
|
|
31
|
+
|
|
32
|
+
type Record_ = Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
const VALID_STATUSES: ReadonlySet<string> = new Set<string>([
|
|
35
|
+
"pending",
|
|
36
|
+
"in_progress",
|
|
37
|
+
"completed",
|
|
38
|
+
]);
|
|
39
|
+
|
|
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
|
+
*/
|
|
45
|
+
const STATUS_ALIASES: ReadonlyMap<string, TodoStatus> = new Map<string, TodoStatus>([
|
|
46
|
+
["done", "completed"],
|
|
47
|
+
["complete", "completed"],
|
|
48
|
+
["finished", "completed"],
|
|
49
|
+
["closed", "completed"],
|
|
50
|
+
["success", "completed"],
|
|
51
|
+
["passed", "completed"],
|
|
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"],
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
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.
|
|
72
|
+
*/
|
|
73
|
+
const CONTENT_FALLBACK_KEYS = ["title", "step", "task", "text", "name", "label", "activeForm"] as const;
|
|
74
|
+
const DESCRIPTION_FALLBACK_KEYS = ["details", "notes", "summary", "context"] as const;
|
|
75
|
+
|
|
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;
|
|
87
|
+
|
|
88
|
+
function isRecord(value: unknown): value is Record_ {
|
|
89
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function firstStringOf(record: Record_, keys: readonly string[]): string {
|
|
93
|
+
for (const key of keys) {
|
|
94
|
+
const value = record[key];
|
|
95
|
+
if (typeof value === "string" && value.trim() !== "") return value;
|
|
96
|
+
}
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Collapse whitespace and cut at a word boundary so a derived `content`
|
|
102
|
+
* stays a short, human-readable label in the widget.
|
|
103
|
+
*/
|
|
104
|
+
export function deriveTitleFromDescription(description: string): string {
|
|
105
|
+
const flat = description.replace(/\s+/g, " ").trim();
|
|
106
|
+
if (flat.length <= DERIVED_CONTENT_MAX) return flat;
|
|
107
|
+
const cut = flat.slice(0, DERIVED_CONTENT_MAX);
|
|
108
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
109
|
+
const head = lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
|
|
110
|
+
return `${head.trimEnd()}…`;
|
|
111
|
+
}
|
|
112
|
+
|
|
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 {
|
|
120
|
+
if (typeof raw !== "string") {
|
|
121
|
+
// Missing, null, number, … — no signal about intent.
|
|
122
|
+
return "pending";
|
|
123
|
+
}
|
|
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";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
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.
|
|
136
|
+
*/
|
|
137
|
+
export function normalizeTodoItem(raw: unknown, index: number): unknown {
|
|
138
|
+
// Laziest form: ["write tests", "deploy"]
|
|
139
|
+
if (typeof raw === "string") {
|
|
140
|
+
const text = raw.trim();
|
|
141
|
+
if (text === "") return raw;
|
|
142
|
+
return { content: deriveTitleFromDescription(text), status: "pending" as TodoStatus };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!isRecord(raw)) return raw; // null / number / nested array → schema error
|
|
146
|
+
|
|
147
|
+
const description =
|
|
148
|
+
(typeof raw.description === "string" ? raw.description.trim() : "") ||
|
|
149
|
+
firstStringOf(raw, DESCRIPTION_FALLBACK_KEYS);
|
|
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
|
+
}
|
|
166
|
+
|
|
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.
|
|
170
|
+
return {
|
|
171
|
+
content,
|
|
172
|
+
...(description !== "" ? { description } : {}),
|
|
173
|
+
status: normalizeStatus(raw.status),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
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
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Repair the occasional mangled JSON: `"id": 1"` (an extra quote after the
|
|
209
|
+
* number). The replacement is safe to apply blindly — that shape is invalid
|
|
210
|
+
* JSON to begin with.
|
|
211
|
+
*/
|
|
212
|
+
// The long backslash sequence is spelled out as char codes for robustness.
|
|
213
|
+
const ID_FIX_PATTERN = String.fromCharCode(40, 34, 105, 100, 34, 92, 115, 42, 58, 92, 115, 42, 92, 100, 43, 41, 92, 34);
|
|
214
|
+
const ID_QUOTE_FIX = new RegExp(ID_FIX_PATTERN, "g");
|
|
215
|
+
function repairStringifiedJson(s: string): string {
|
|
216
|
+
return s.replace(ID_QUOTE_FIX, "$1");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseStringifiedList(raw: string): unknown {
|
|
220
|
+
try {
|
|
221
|
+
return JSON.parse(repairStringifiedJson(raw));
|
|
222
|
+
} catch {
|
|
223
|
+
return raw; // Unparseable — keep the string; schema error says "must be array"
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function looksLikeTodoItem(value: unknown): boolean {
|
|
228
|
+
return (
|
|
229
|
+
isRecord(value) &&
|
|
230
|
+
("content" in value ||
|
|
231
|
+
"title" in value ||
|
|
232
|
+
"step" in value ||
|
|
233
|
+
"description" in value ||
|
|
234
|
+
"status" in value)
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Normalize the `todoList` argument. `kept` is false only when the field was
|
|
240
|
+
* absent and must not appear in the output object at all.
|
|
241
|
+
*/
|
|
242
|
+
function normalizeTodoList(raw: unknown): { value: unknown; kept: boolean } {
|
|
243
|
+
let value: unknown = raw;
|
|
244
|
+
if (typeof raw === "string") value = parseStringifiedList(raw);
|
|
245
|
+
|
|
246
|
+
if (value === null) {
|
|
247
|
+
// Required-schema fields reject null; dropping the key yields the clearer
|
|
248
|
+
// "todoList is required for write operation" from execute().
|
|
249
|
+
return { value: undefined, kept: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (value === undefined) return { value, kept: false };
|
|
253
|
+
|
|
254
|
+
if (Array.isArray(value)) {
|
|
255
|
+
return { value: value.map((item, i) => normalizeTodoItem(item, i)), kept: true };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// A single bare item instead of an array.
|
|
259
|
+
if (looksLikeTodoItem(value)) {
|
|
260
|
+
return { value: [normalizeTodoItem(value, 0)], kept: true };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { value, kept: true }; // Unmendable — original validation error stands
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeOperation(raw: unknown, listPresent: boolean): "write" | "read" {
|
|
267
|
+
if (raw === "write" || raw === "read") return raw;
|
|
268
|
+
if (typeof raw === "string") {
|
|
269
|
+
const lowered = raw.trim().toLowerCase();
|
|
270
|
+
if (lowered === "write" || lowered === "read") return lowered;
|
|
271
|
+
}
|
|
272
|
+
// Model omitted/garbled the operation: a list implies a write.
|
|
273
|
+
return listPresent ? "write" : "read";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Repair raw `manage_todo_list` call arguments before schema validation.
|
|
278
|
+
* Never throws, never mutates its input, and passes through anything it
|
|
279
|
+
* cannot recover.
|
|
280
|
+
*/
|
|
281
|
+
export function prepareTodoArguments(args: unknown): ManageTodoListInput {
|
|
282
|
+
if (!isRecord(args)) return args as ManageTodoListInput;
|
|
283
|
+
|
|
284
|
+
const normalized =
|
|
285
|
+
args.todoList === undefined ? undefined : normalizeTodoList(args.todoList);
|
|
286
|
+
const present =
|
|
287
|
+
normalized !== undefined && normalized.kept && normalized.value !== undefined;
|
|
288
|
+
|
|
289
|
+
const out: Record_ = {
|
|
290
|
+
operation: normalizeOperation(args.operation, present),
|
|
291
|
+
};
|
|
292
|
+
if (present) {
|
|
293
|
+
out.todoList = (normalized as { value: unknown }).value;
|
|
294
|
+
}
|
|
295
|
+
return out as ManageTodoListInput;
|
|
296
|
+
}
|
|
@@ -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
|
}
|