@fyeeme/pi-todo 1.0.0 → 1.0.2
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/CHANGELOG.md +27 -0
- package/README.md +11 -12
- package/index.ts +12 -156
- package/package.json +8 -7
- package/src/commands.ts +34 -84
- package/src/prompts/todo.md +2 -20
- package/src/render.ts +26 -31
- package/src/restore.ts +0 -3
- package/src/state.ts +76 -190
- package/src/tool.ts +47 -18
- package/test/omp-alignment.test.ts +20 -79
- package/test/render.test.ts +4 -2
- package/test/state.test.ts +47 -48
- package/test/tool.test.ts +58 -91
package/test/tool.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import defaultExport from "../index.ts";
|
|
6
6
|
import { createTodoTool, TodoParamsSchema } from "../src/tool.ts";
|
|
7
|
-
import { restorePhasesFromEntries, TODO_PHASES_ENTRY_TYPE
|
|
7
|
+
import { restorePhasesFromEntries, TODO_PHASES_ENTRY_TYPE } from "../src/restore.ts";
|
|
8
8
|
import { Value } from "typebox/value";
|
|
9
9
|
import type { TodoParams } from "../src/tool.ts";
|
|
10
10
|
import type { TodoPhase, TodoToolDetails } from "../src/state.ts";
|
|
@@ -178,7 +178,7 @@ describe("todo tool execute", () => {
|
|
|
178
178
|
});
|
|
179
179
|
expect(result.details?.op).toBe("init");
|
|
180
180
|
expect(result.details?.storage).toBe("session");
|
|
181
|
-
expect(result.details?.phases[0].tasks[0].status).toBe("
|
|
181
|
+
expect(result.details?.phases[0].tasks[0].status).toBe("pending");
|
|
182
182
|
expect(result.content[0]).toMatchObject({ type: "text" });
|
|
183
183
|
expect(host.entries).toHaveLength(1);
|
|
184
184
|
expect(host.entries[0].customType).toBe(TODO_PHASES_ENTRY_TYPE);
|
|
@@ -227,6 +227,46 @@ describe("todo tool execute", () => {
|
|
|
227
227
|
const result = await executeTool(tool, { op: "done", task: "a1" });
|
|
228
228
|
expect(result.details?.completedTasks).toEqual([{ phase: "A", content: "a1" }]);
|
|
229
229
|
});
|
|
230
|
+
|
|
231
|
+
it("prepends a Changed: block with status and blocker note to mutation results", async () => {
|
|
232
|
+
const host = fakeHost();
|
|
233
|
+
defaultExport(host.pi);
|
|
234
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
235
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1", "a2"] }] });
|
|
236
|
+
|
|
237
|
+
const blocked = await executeTool(tool, { op: "block", task: "a2", reason: "ci down" });
|
|
238
|
+
expect(blocked.content[0]).toMatchObject({ type: "text" });
|
|
239
|
+
expect((blocked.content[0] as { text: string }).text).toContain(
|
|
240
|
+
"Changed:\n - a2 [pending → blocked] (blocked: ci down) (A)",
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const done = await executeTool(tool, { op: "done", task: "a1" });
|
|
244
|
+
expect((done.content[0] as { text: string }).text).toContain("Changed:\n - a1 [pending → completed] (A)");
|
|
245
|
+
|
|
246
|
+
// view stays a pure echo: no Changed block.
|
|
247
|
+
const view = await executeTool(tool, { op: "view" });
|
|
248
|
+
expect((view.content[0] as { text: string }).text).not.toContain("Changed:");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("error results never fold: the unchanged list survives on big lists (retry contract)", async () => {
|
|
252
|
+
const host = fakeHost();
|
|
253
|
+
defaultExport(host.pi);
|
|
254
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
255
|
+
// 25 tasks (> SUMMARY_FULL_LIST_LIMIT): every task name is "task-<n>".
|
|
256
|
+
const items = Array.from({ length: 25 }, (_, i) => `task-${i + 1}`);
|
|
257
|
+
await executeTool(tool, { op: "init", list: [{ phase: "Big", items }] });
|
|
258
|
+
|
|
259
|
+
const thrown = await executeTool(tool, { op: "done", task: "task-19 typo" }).then(
|
|
260
|
+
() => null,
|
|
261
|
+
(err: unknown) => (err instanceof Error ? err : new Error(String(err))),
|
|
262
|
+
);
|
|
263
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
264
|
+
// Retry contract: the unchanged list is fully present, no fold hints.
|
|
265
|
+
expect(thrown?.message).toContain("task-25");
|
|
266
|
+
expect(thrown?.message).toContain("task-11");
|
|
267
|
+
expect(thrown?.message).not.toContain("Full checklist omitted");
|
|
268
|
+
expect(thrown?.message).not.toContain("more open — call");
|
|
269
|
+
});
|
|
230
270
|
});
|
|
231
271
|
|
|
232
272
|
// ---------------------------------------------------------------------------
|
|
@@ -251,7 +291,7 @@ describe("todo restore", () => {
|
|
|
251
291
|
});
|
|
252
292
|
|
|
253
293
|
// ---------------------------------------------------------------------------
|
|
254
|
-
// Session wiring: session_start restore
|
|
294
|
+
// Session wiring: session_start restore
|
|
255
295
|
// ---------------------------------------------------------------------------
|
|
256
296
|
|
|
257
297
|
describe("todo extension wiring", () => {
|
|
@@ -289,72 +329,7 @@ describe("todo extension wiring", () => {
|
|
|
289
329
|
expect(result.details?.phases).toEqual([{ name: "Current", tasks: [{ content: "current-task", status: "in_progress" }] }]);
|
|
290
330
|
});
|
|
291
331
|
|
|
292
|
-
it("
|
|
293
|
-
const host = fakeHost();
|
|
294
|
-
defaultExport(host.pi);
|
|
295
|
-
const tool = host.tools[0] as Record<string, unknown>;
|
|
296
|
-
const endHandlers = host.handlers.get("agent_end") ?? [];
|
|
297
|
-
const assistantMsg = { role: "assistant", content: [{ type: "text", text: "Working on it." }] };
|
|
298
|
-
const fireAgentEnd = async () => {
|
|
299
|
-
for (const handler of endHandlers) {
|
|
300
|
-
await handler({ type: "agent_end", messages: [assistantMsg] }, {});
|
|
301
|
-
}
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
// No todos: silent.
|
|
305
|
-
await fireAgentEnd();
|
|
306
|
-
expect(host.sentMessages.filter(m => m.customType === "todo-reminder")).toHaveLength(0);
|
|
307
|
-
|
|
308
|
-
// Blocked-only work is parked, not nagged (omp counts pending+in_progress).
|
|
309
|
-
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1"] }] });
|
|
310
|
-
await executeTool(tool, { op: "block", task: "a1", reason: "ci down" });
|
|
311
|
-
await fireAgentEnd();
|
|
312
|
-
expect(host.sentMessages.filter(m => m.customType === "todo-reminder")).toHaveLength(0);
|
|
313
|
-
|
|
314
|
-
// A pending task survives: omp reminder text + continuation turn.
|
|
315
|
-
await executeTool(tool, { op: "append", phase: "A", items: ["a2"] });
|
|
316
|
-
await fireAgentEnd();
|
|
317
|
-
const first = host.sentMessages.find(m => m.customType === "todo-reminder");
|
|
318
|
-
expect(first?.display).toBe(false);
|
|
319
|
-
expect(first?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
|
|
320
|
-
expect(first?.content).toContain("<system-reminder>");
|
|
321
|
-
expect(first?.content).toContain("You stopped with 1 incomplete todo item(s):");
|
|
322
|
-
expect(first?.content).toContain("- A\n - a2");
|
|
323
|
-
expect(first?.content).toContain("(Reminder 1/3)");
|
|
324
|
-
// Transcript-anchored component text (omp TodoReminderComponent).
|
|
325
|
-
const reminderEntry = host.entries.find(e => e.customType === TODO_REMINDER_ENTRY_TYPE);
|
|
326
|
-
expect(reminderEntry?.data).toMatchObject({ count: 1, attempt: 1, maxAttempts: 3 });
|
|
327
|
-
|
|
328
|
-
// Attempts count up to the omp default max of 3, then stop.
|
|
329
|
-
host.sentMessages.length = 0;
|
|
330
|
-
await fireAgentEnd();
|
|
331
|
-
await fireAgentEnd();
|
|
332
|
-
await fireAgentEnd();
|
|
333
|
-
const reminders = host.sentMessages.filter(m => m.customType === "todo-reminder");
|
|
334
|
-
expect(reminders).toHaveLength(2);
|
|
335
|
-
expect(reminders[0]?.content).toContain("(Reminder 2/3)");
|
|
336
|
-
expect(reminders[1]?.content).toContain("(Reminder 3/3)");
|
|
337
|
-
|
|
338
|
-
// omp resetCycle: a fresh user prompt restarts the cycle.
|
|
339
|
-
const startHandlers = host.handlers.get("message_start") ?? [];
|
|
340
|
-
for (const handler of startHandlers) {
|
|
341
|
-
await handler({ type: "message_start", message: { role: "user" } }, {});
|
|
342
|
-
}
|
|
343
|
-
host.sentMessages.length = 0;
|
|
344
|
-
await fireAgentEnd();
|
|
345
|
-
expect(host.sentMessages.filter(m => m.customType === "todo-reminder")).toHaveLength(1);
|
|
346
|
-
expect(host.sentMessages[0]?.content).toContain("(Reminder 1/3)");
|
|
347
|
-
|
|
348
|
-
// Close everything: silent again.
|
|
349
|
-
host.sentMessages.length = 0;
|
|
350
|
-
await executeTool(tool, { op: "done", task: "a2" });
|
|
351
|
-
await executeTool(tool, { op: "unblock", task: "a1" });
|
|
352
|
-
await executeTool(tool, { op: "done", task: "a1" });
|
|
353
|
-
await fireAgentEnd();
|
|
354
|
-
expect(host.sentMessages.filter(m => m.customType === "todo-reminder")).toHaveLength(0);
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
it("skips the reminder when the assistant ended by asking the user (omp isAwaitingUserAnswer)", async () => {
|
|
332
|
+
it("agent_end leaves the agent alone: no nag, no auto-continuation", async () => {
|
|
358
333
|
const host = fakeHost();
|
|
359
334
|
defaultExport(host.pi);
|
|
360
335
|
await executeTool(host.tools[0] as Record<string, unknown>, {
|
|
@@ -364,20 +339,18 @@ describe("todo extension wiring", () => {
|
|
|
364
339
|
const endHandlers = host.handlers.get("agent_end") ?? [];
|
|
365
340
|
for (const handler of endHandlers) {
|
|
366
341
|
await handler(
|
|
367
|
-
{
|
|
368
|
-
type: "agent_end",
|
|
369
|
-
messages: [{ role: "assistant", content: [{ type: "text", text: "Which database should I use?" }] }],
|
|
370
|
-
},
|
|
342
|
+
{ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Working on it." }] }] },
|
|
371
343
|
{},
|
|
372
344
|
);
|
|
373
345
|
}
|
|
374
|
-
|
|
346
|
+
// Open todos do not trigger any hidden message or continuation turn.
|
|
347
|
+
expect(host.sentMessages).toHaveLength(0);
|
|
375
348
|
});
|
|
376
349
|
|
|
377
|
-
it("registers
|
|
350
|
+
it("registers no reminder renderer and a /todo command", () => {
|
|
378
351
|
const host = fakeHost();
|
|
379
352
|
defaultExport(host.pi);
|
|
380
|
-
expect(host.entryRenderers.
|
|
353
|
+
expect(host.entryRenderers.size).toBe(0);
|
|
381
354
|
expect(host.commands.todo).toBeDefined();
|
|
382
355
|
});
|
|
383
356
|
});
|
|
@@ -427,8 +400,8 @@ describe("todo command", () => {
|
|
|
427
400
|
const ui = uiStub();
|
|
428
401
|
await run("", makeCtx(ui));
|
|
429
402
|
expect(ui.notifications[0]?.text).toContain("# Auth");
|
|
430
|
-
//
|
|
431
|
-
expect(ui.notifications[0]?.text).toContain("- [
|
|
403
|
+
// No auto-promotion: init leaves every task pending.
|
|
404
|
+
expect(ui.notifications[0]?.text).toContain("- [ ] a1");
|
|
432
405
|
expect(ui.notifications[0]?.text).toContain("- [ ] a2");
|
|
433
406
|
});
|
|
434
407
|
|
|
@@ -529,16 +502,12 @@ describe("todo command", () => {
|
|
|
529
502
|
await run("rm two", makeCtx(ui));
|
|
530
503
|
expect(ui.notifications[2]?.text).toBe("Removed phase: Two");
|
|
531
504
|
|
|
532
|
-
// /todo rm (no arg) clears everything
|
|
505
|
+
// /todo rm (no arg) clears everything.
|
|
533
506
|
host.sentMessages.length = 0;
|
|
534
507
|
await run("rm", makeCtx(ui));
|
|
535
508
|
expect(ui.notifications[3]?.text).toBe("Cleared all todos.");
|
|
536
|
-
|
|
537
|
-
expect(
|
|
538
|
-
expect(reminder?.content).toContain(
|
|
539
|
-
"The user intentionally cleared the todo list. Do NOT recreate or re-populate it",
|
|
540
|
-
);
|
|
541
|
-
expect(reminder?.display).toBe(false);
|
|
509
|
+
// No manual-edit system-reminder is injected (cognitive-neutral refactor).
|
|
510
|
+
expect(host.sentMessages).toHaveLength(0);
|
|
542
511
|
});
|
|
543
512
|
|
|
544
513
|
it("unknown task/phase targets report the omp error", async () => {
|
|
@@ -556,7 +525,7 @@ describe("todo command", () => {
|
|
|
556
525
|
const ui = uiStub();
|
|
557
526
|
await run("copy", makeCtx(ui));
|
|
558
527
|
expect(ui.notifications[0]?.text).toContain("Copy not available");
|
|
559
|
-
expect(ui.notifications[0]?.text).toContain("- [
|
|
528
|
+
expect(ui.notifications[0]?.text).toContain("- [ ] a1");
|
|
560
529
|
});
|
|
561
530
|
|
|
562
531
|
it("help prints the omp usage block; unknown verbs error with it", async () => {
|
|
@@ -620,10 +589,8 @@ describe("todo command", () => {
|
|
|
620
589
|
(ctx.ui as Record<string, unknown>).editor = async () => edited;
|
|
621
590
|
await run("edit", ctx);
|
|
622
591
|
expect(ui.notifications[0]?.text).toBe("Todos updated from editor: 1 phase(s), 2 task(s).");
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
expect(reminder?.content).toContain("The user manually modified the todo list (/todo edit).");
|
|
626
|
-
expect(reminder?.content).toContain("<system-reminder>");
|
|
592
|
+
// No manual-edit system-reminder is injected (cognitive-neutral refactor).
|
|
593
|
+
expect(host.sentMessages).toHaveLength(0);
|
|
627
594
|
});
|
|
628
595
|
|
|
629
596
|
it("edit cancel leaves todos unchanged with the omp warning", async () => {
|
|
@@ -647,7 +614,7 @@ describe("todo command", () => {
|
|
|
647
614
|
stderr.push(String(message));
|
|
648
615
|
});
|
|
649
616
|
await run("", { hasUI: false, ui: { notify: () => {} } });
|
|
650
|
-
expect(stderr.at(-1)).toContain("- [
|
|
617
|
+
expect(stderr.at(-1)).toContain("- [ ] a1");
|
|
651
618
|
spy.mockRestore();
|
|
652
619
|
});
|
|
653
620
|
});
|