@fyeeme/pi-todo 1.0.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/index.ts +222 -0
- package/package.json +61 -0
- package/src/commands.ts +457 -0
- package/src/prompts/todo.md +44 -0
- package/src/render.ts +295 -0
- package/src/restore.ts +33 -0
- package/src/state.ts +842 -0
- package/src/tool.ts +153 -0
- package/test/omp-alignment.test.ts +369 -0
- package/test/render.test.ts +213 -0
- package/test/state.test.ts +373 -0
- package/test/tool.test.ts +653 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import defaultExport from "../index.ts";
|
|
6
|
+
import { createTodoTool, TodoParamsSchema } from "../src/tool.ts";
|
|
7
|
+
import { restorePhasesFromEntries, TODO_PHASES_ENTRY_TYPE, TODO_REMINDER_ENTRY_TYPE } from "../src/restore.ts";
|
|
8
|
+
import { Value } from "typebox/value";
|
|
9
|
+
import type { TodoParams } from "../src/tool.ts";
|
|
10
|
+
import type { TodoPhase, TodoToolDetails } from "../src/state.ts";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Fakes (pi-ask-user test style: drive the default export with a fake host)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
interface FakeHost {
|
|
19
|
+
pi: ExtensionAPI;
|
|
20
|
+
tools: Array<Record<string, unknown>>;
|
|
21
|
+
commands: Record<string, { handler: (args: string, ctx: unknown) => Promise<void> }>;
|
|
22
|
+
handlers: Map<string, Array<(event: unknown, ctx: unknown) => unknown>>;
|
|
23
|
+
entryRenderers: Map<string, (entry: unknown, options: unknown, theme: unknown) => unknown>;
|
|
24
|
+
entries: Array<{ type: string; customType: string; data?: unknown }>;
|
|
25
|
+
emitted: Array<{ channel: string; data: unknown }>;
|
|
26
|
+
sentMessages: Array<{
|
|
27
|
+
customType: string;
|
|
28
|
+
content: string;
|
|
29
|
+
display: boolean;
|
|
30
|
+
options?: { triggerTurn?: boolean; deliverAs?: string };
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fakeHost(): FakeHost {
|
|
35
|
+
const host: FakeHost = {
|
|
36
|
+
pi: {} as ExtensionAPI,
|
|
37
|
+
tools: [],
|
|
38
|
+
commands: {},
|
|
39
|
+
handlers: new Map(),
|
|
40
|
+
entryRenderers: new Map(),
|
|
41
|
+
entries: [],
|
|
42
|
+
emitted: [],
|
|
43
|
+
sentMessages: [],
|
|
44
|
+
};
|
|
45
|
+
host.pi = {
|
|
46
|
+
registerTool: (tool: unknown) => {
|
|
47
|
+
host.tools.push(tool as Record<string, unknown>);
|
|
48
|
+
},
|
|
49
|
+
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => {
|
|
50
|
+
host.commands[name] = options;
|
|
51
|
+
},
|
|
52
|
+
registerEntryRenderer: (
|
|
53
|
+
customType: string,
|
|
54
|
+
renderer: (entry: unknown, options: unknown, theme: unknown) => unknown,
|
|
55
|
+
) => {
|
|
56
|
+
host.entryRenderers.set(customType, renderer);
|
|
57
|
+
},
|
|
58
|
+
on: (event: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
|
59
|
+
const list = host.handlers.get(event) ?? [];
|
|
60
|
+
list.push(handler);
|
|
61
|
+
host.handlers.set(event, list);
|
|
62
|
+
},
|
|
63
|
+
appendEntry: (customType: string, data?: unknown) => {
|
|
64
|
+
host.entries.push({ type: "custom", customType, data });
|
|
65
|
+
},
|
|
66
|
+
sendMessage: (
|
|
67
|
+
message: { customType: string; content: string; display: boolean },
|
|
68
|
+
options?: { triggerTurn?: boolean; deliverAs?: string },
|
|
69
|
+
) => {
|
|
70
|
+
host.sentMessages.push({ ...message, options });
|
|
71
|
+
},
|
|
72
|
+
events: {
|
|
73
|
+
emit: (channel: string, data: unknown) => {
|
|
74
|
+
host.emitted.push({ channel, data });
|
|
75
|
+
},
|
|
76
|
+
on: () => () => {},
|
|
77
|
+
},
|
|
78
|
+
} as unknown as ExtensionAPI;
|
|
79
|
+
return host;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sessionManagerFake(entries: unknown[], branch?: unknown[]) {
|
|
83
|
+
return {
|
|
84
|
+
getEntries: () => entries,
|
|
85
|
+
getBranch: () => branch ?? entries,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const passthroughTheme = {
|
|
90
|
+
fg: (_color: string, text: string) => text,
|
|
91
|
+
bold: (text: string) => text,
|
|
92
|
+
strikethrough: (text: string) => text,
|
|
93
|
+
dim: (text: string) => text,
|
|
94
|
+
} as never;
|
|
95
|
+
|
|
96
|
+
async function executeTool(tool: Record<string, unknown>, params: unknown): Promise<AgentToolResult<TodoToolDetails>> {
|
|
97
|
+
const execute = tool.execute as (id: string, params: unknown) => Promise<AgentToolResult<TodoToolDetails>>;
|
|
98
|
+
return execute("call-1", params);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Schema
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
describe("todo tool schema", () => {
|
|
106
|
+
it("rejects empty items inside init list entries (minItems 1)", () => {
|
|
107
|
+
const ok = Value.Check(TodoParamsSchema, { op: "init", list: [{ phase: "A", items: ["one"] }] });
|
|
108
|
+
const bad = Value.Check(TodoParamsSchema, { op: "init", list: [{ phase: "A", items: [] }] });
|
|
109
|
+
expect(ok).toBe(true);
|
|
110
|
+
expect(bad).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("accepts a stray empty top-level items array (op-specific errors handle it)", () => {
|
|
114
|
+
expect(Value.Check(TodoParamsSchema, { op: "view", items: [] })).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects unknown operations", () => {
|
|
118
|
+
expect(Value.Check(TodoParamsSchema, { op: "explode" })).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// prepareArguments (missing-op inference)
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe("todo tool prepareArguments", () => {
|
|
127
|
+
function setup(initial: TodoPhase[] = []) {
|
|
128
|
+
let state = initial;
|
|
129
|
+
const tool = createTodoTool({
|
|
130
|
+
getPhases: () => state,
|
|
131
|
+
setPhases: next => {
|
|
132
|
+
state = next;
|
|
133
|
+
},
|
|
134
|
+
persist: () => {},
|
|
135
|
+
broadcast: () => {},
|
|
136
|
+
});
|
|
137
|
+
return { tool, getState: () => state };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it("infers init from a non-empty list", () => {
|
|
141
|
+
const { tool } = setup();
|
|
142
|
+
const prepared = (tool.prepareArguments as (args: unknown) => TodoParams)({ list: [{ phase: "A", items: ["a"] }] });
|
|
143
|
+
expect(prepared.op).toBe("init");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("infers append from items plus phase", () => {
|
|
147
|
+
const { tool } = setup([{ name: "A", tasks: [{ content: "x", status: "in_progress" }] }]);
|
|
148
|
+
const prepared = (tool.prepareArguments as (args: unknown) => TodoParams)({ items: ["y"], phase: "A" });
|
|
149
|
+
expect(prepared.op).toBe("append");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("infers init from bare items only when nothing exists", () => {
|
|
153
|
+
const empty = setup();
|
|
154
|
+
expect((empty.tool.prepareArguments as (args: unknown) => TodoParams)({ items: ["y"] }).op).toBe("init");
|
|
155
|
+
const occupied = setup([{ name: "A", tasks: [{ content: "x", status: "pending" }] }]);
|
|
156
|
+
expect((occupied.tool.prepareArguments as (args: unknown) => TodoParams)({ items: ["y"] }).op).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("leaves explicit ops untouched", () => {
|
|
160
|
+
const { tool } = setup();
|
|
161
|
+
const args = { op: "view", items: [] };
|
|
162
|
+
expect((tool.prepareArguments as (args: unknown) => TodoParams)(args)).toEqual(args);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Execute end-to-end
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
describe("todo tool execute", () => {
|
|
171
|
+
it("applies init, persists a snapshot, and broadcasts once", async () => {
|
|
172
|
+
const host = fakeHost();
|
|
173
|
+
defaultExport(host.pi);
|
|
174
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
175
|
+
const result = await executeTool(tool, {
|
|
176
|
+
op: "init",
|
|
177
|
+
list: [{ phase: "Foundation", items: ["scaffold", "wire"] }],
|
|
178
|
+
});
|
|
179
|
+
expect(result.details?.op).toBe("init");
|
|
180
|
+
expect(result.details?.storage).toBe("session");
|
|
181
|
+
expect(result.details?.phases[0].tasks[0].status).toBe("in_progress");
|
|
182
|
+
expect(result.content[0]).toMatchObject({ type: "text" });
|
|
183
|
+
expect(host.entries).toHaveLength(1);
|
|
184
|
+
expect(host.entries[0].customType).toBe(TODO_PHASES_ENTRY_TYPE);
|
|
185
|
+
expect(host.emitted).toEqual([{ channel: "todo_updated", data: { phases: result.details?.phases.map((p: TodoPhase) => ({ ...p, tasks: [...p.tasks] })) } }]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("discards failing batches wholesale: no persist, no broadcast, throws", async () => {
|
|
189
|
+
const host = fakeHost();
|
|
190
|
+
defaultExport(host.pi);
|
|
191
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
192
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1"] }] });
|
|
193
|
+
host.entries.length = 0;
|
|
194
|
+
host.emitted.length = 0;
|
|
195
|
+
|
|
196
|
+
const thrown = await executeTool(tool, { op: "append", phase: "B", items: ["novel", "a1"] }).then(
|
|
197
|
+
() => null,
|
|
198
|
+
(err: unknown) => (err instanceof Error ? err : new Error(String(err))),
|
|
199
|
+
);
|
|
200
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
201
|
+
// The thrown message carries the unchanged list for the model's retry.
|
|
202
|
+
expect(thrown?.message).toContain("already exists");
|
|
203
|
+
expect(thrown?.message).toContain("a1");
|
|
204
|
+
expect(host.entries).toHaveLength(0);
|
|
205
|
+
expect(host.emitted).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("treats view as a read: no persist, no broadcast", async () => {
|
|
209
|
+
const host = fakeHost();
|
|
210
|
+
defaultExport(host.pi);
|
|
211
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
212
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1"] }] });
|
|
213
|
+
host.entries.length = 0;
|
|
214
|
+
host.emitted.length = 0;
|
|
215
|
+
|
|
216
|
+
const result = await executeTool(tool, { op: "view" });
|
|
217
|
+
expect(result.details?.op).toBe("view");
|
|
218
|
+
expect(host.entries).toHaveLength(0);
|
|
219
|
+
expect(host.emitted).toHaveLength(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("reports completion transitions for done", async () => {
|
|
223
|
+
const host = fakeHost();
|
|
224
|
+
defaultExport(host.pi);
|
|
225
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
226
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1", "a2"] }] });
|
|
227
|
+
const result = await executeTool(tool, { op: "done", task: "a1" });
|
|
228
|
+
expect(result.details?.completedTasks).toEqual([{ phase: "A", content: "a1" }]);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Restore
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
describe("todo restore", () => {
|
|
237
|
+
it("restores the latest valid snapshot and skips malformed entries", () => {
|
|
238
|
+
const entries = [
|
|
239
|
+
{ type: "message", role: "user" },
|
|
240
|
+
{ type: "custom", customType: TODO_PHASES_ENTRY_TYPE, data: { phases: [{ name: "A", tasks: [{ content: "old", status: "completed" }] }] } },
|
|
241
|
+
{ type: "custom", customType: TODO_PHASES_ENTRY_TYPE, data: { phases: "garbage" } },
|
|
242
|
+
{ type: "custom", customType: TODO_PHASES_ENTRY_TYPE, data: { phases: [{ name: "B", tasks: [{ content: "new", status: "pending" }] }] } },
|
|
243
|
+
];
|
|
244
|
+
const restored = restorePhasesFromEntries(entries);
|
|
245
|
+
expect(restored).toEqual([{ name: "B", tasks: [{ content: "new", status: "pending" }] }]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns empty for sessions without todo entries", () => {
|
|
249
|
+
expect(restorePhasesFromEntries([{ type: "message", role: "user" }])).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Session wiring: session_start restore + stop reminder
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
describe("todo extension wiring", () => {
|
|
258
|
+
it("restores state on session_start", async () => {
|
|
259
|
+
const host = fakeHost();
|
|
260
|
+
defaultExport(host.pi);
|
|
261
|
+
const entries = [
|
|
262
|
+
{ type: "custom", customType: TODO_PHASES_ENTRY_TYPE, data: { phases: [{ name: "A", tasks: [{ content: "kept", status: "in_progress" }] }] } },
|
|
263
|
+
];
|
|
264
|
+
const handlers = host.handlers.get("session_start") ?? [];
|
|
265
|
+
for (const handler of handlers) {
|
|
266
|
+
await handler({ type: "session_start", reason: "resume" }, { sessionManager: sessionManagerFake(entries) });
|
|
267
|
+
}
|
|
268
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
269
|
+
const result = await executeTool(tool, { op: "view" });
|
|
270
|
+
expect(result.details?.phases).toEqual([{ name: "A", tasks: [{ content: "kept", status: "in_progress" }] }]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("restore is branch-aware: abandoned-branch snapshots never win", async () => {
|
|
274
|
+
const host = fakeHost();
|
|
275
|
+
defaultExport(host.pi);
|
|
276
|
+
// Full file contains a LATER snapshot written on an abandoned branch
|
|
277
|
+
// (created by /fork or /tree); the current branch ends at the older one.
|
|
278
|
+
const allEntries = [
|
|
279
|
+
{ type: "custom", customType: TODO_PHASES_ENTRY_TYPE, data: { phases: [{ name: "Current", tasks: [{ content: "current-task", status: "in_progress" }] }] } },
|
|
280
|
+
{ type: "custom", customType: TODO_PHASES_ENTRY_TYPE, data: { phases: [{ name: "Abandoned", tasks: [{ content: "abandoned-task", status: "pending" }] }] } },
|
|
281
|
+
];
|
|
282
|
+
const currentBranch = [allEntries[0]];
|
|
283
|
+
const handlers = host.handlers.get("session_start") ?? [];
|
|
284
|
+
for (const handler of handlers) {
|
|
285
|
+
await handler({ type: "session_start", reason: "resume" }, { sessionManager: sessionManagerFake(allEntries, currentBranch) });
|
|
286
|
+
}
|
|
287
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
288
|
+
const result = await executeTool(tool, { op: "view" });
|
|
289
|
+
expect(result.details?.phases).toEqual([{ name: "Current", tasks: [{ content: "current-task", status: "in_progress" }] }]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("stop reminder matches omp checkCompletion: nag text, followUp turn, 3-attempt cycle", async () => {
|
|
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 () => {
|
|
358
|
+
const host = fakeHost();
|
|
359
|
+
defaultExport(host.pi);
|
|
360
|
+
await executeTool(host.tools[0] as Record<string, unknown>, {
|
|
361
|
+
op: "init",
|
|
362
|
+
list: [{ phase: "A", items: ["a1"] }],
|
|
363
|
+
});
|
|
364
|
+
const endHandlers = host.handlers.get("agent_end") ?? [];
|
|
365
|
+
for (const handler of endHandlers) {
|
|
366
|
+
await handler(
|
|
367
|
+
{
|
|
368
|
+
type: "agent_end",
|
|
369
|
+
messages: [{ role: "assistant", content: [{ type: "text", text: "Which database should I use?" }] }],
|
|
370
|
+
},
|
|
371
|
+
{},
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
expect(host.sentMessages.filter(m => m.customType === "todo-reminder")).toHaveLength(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("registers an entry renderer for reminders and a /todo command", () => {
|
|
378
|
+
const host = fakeHost();
|
|
379
|
+
defaultExport(host.pi);
|
|
380
|
+
expect(host.entryRenderers.has(TODO_REMINDER_ENTRY_TYPE)).toBe(true);
|
|
381
|
+
expect(host.commands.todo).toBeDefined();
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// /todo command (omp todo-command-controller verb set)
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
describe("todo command", () => {
|
|
390
|
+
function commandHost() {
|
|
391
|
+
const host = fakeHost();
|
|
392
|
+
defaultExport(host.pi);
|
|
393
|
+
const tool = host.tools[0] as Record<string, unknown>;
|
|
394
|
+
return { host, run: host.commands.todo.handler, tool };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface UiStub {
|
|
398
|
+
notifications: Array<{ text: string; type?: string }>;
|
|
399
|
+
notify(text: string, type?: string): void;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function uiStub(): UiStub {
|
|
403
|
+
const notifications: Array<{ text: string; type?: string }> = [];
|
|
404
|
+
return {
|
|
405
|
+
notifications,
|
|
406
|
+
notify: (text: string, type?: string) => {
|
|
407
|
+
notifications.push({ text, type });
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function makeCtx(ui: UiStub, extra: Record<string, unknown> = {}) {
|
|
413
|
+
return {
|
|
414
|
+
hasUI: true,
|
|
415
|
+
ui: { ...ui, confirm: async () => true, editor: async () => undefined },
|
|
416
|
+
cwd: "/tmp",
|
|
417
|
+
...extra,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
it("empty /todo shows the Markdown checklist (omp phasesToMarkdown view)", async () => {
|
|
422
|
+
const { run, tool } = commandHost();
|
|
423
|
+
await executeTool(tool, {
|
|
424
|
+
op: "init",
|
|
425
|
+
list: [{ phase: "Auth", items: ["a1", "a2"] }],
|
|
426
|
+
});
|
|
427
|
+
const ui = uiStub();
|
|
428
|
+
await run("", makeCtx(ui));
|
|
429
|
+
expect(ui.notifications[0]?.text).toContain("# Auth");
|
|
430
|
+
// omp normalizeInProgressTask auto-promotes the first pending task.
|
|
431
|
+
expect(ui.notifications[0]?.text).toContain("- [/] a1");
|
|
432
|
+
expect(ui.notifications[0]?.text).toContain("- [ ] a2");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("empty state reports the omp hint", async () => {
|
|
436
|
+
const { run } = commandHost();
|
|
437
|
+
const ui = uiStub();
|
|
438
|
+
await run("", makeCtx(ui));
|
|
439
|
+
expect(ui.notifications[0]?.text).toBe("No todos. Use /todo append <task> to start one.");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("append fuzzy-matches the phase and title-cases the task (omp #append)", async () => {
|
|
443
|
+
const { run, tool } = commandHost();
|
|
444
|
+
await executeTool(tool, { op: "init", list: [{ phase: "Auth Work", items: ["a1"] }] });
|
|
445
|
+
const ui = uiStub();
|
|
446
|
+
await run("append auth wire oauth", makeCtx(ui));
|
|
447
|
+
expect(ui.notifications[0]?.text).toBe("Appended to Auth Work: Wire oauth");
|
|
448
|
+
const view: string[] = [];
|
|
449
|
+
await run("", { hasUI: true, ui: { notify: (m: string) => view.push(m) } });
|
|
450
|
+
expect(view.join("\n")).toContain("- [ ] Wire oauth");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("append without a phase targets the last phase; quoted tasks tokenize", async () => {
|
|
454
|
+
const { run, tool } = commandHost();
|
|
455
|
+
await executeTool(tool, {
|
|
456
|
+
op: "init",
|
|
457
|
+
list: [
|
|
458
|
+
{ phase: "One", items: ["x"] },
|
|
459
|
+
{ phase: "Two", items: ["y"] },
|
|
460
|
+
],
|
|
461
|
+
});
|
|
462
|
+
const ui = uiStub();
|
|
463
|
+
// omp tokenizer: backslash-escaped quotes survive inside a quoted run.
|
|
464
|
+
await run('append "fix the \\"quoted\\" thing"', makeCtx(ui));
|
|
465
|
+
expect(ui.notifications[0]?.text).toBe('Appended to Two: Fix the "quoted" thing');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("start fuzzy-matches a task (omp #start)", async () => {
|
|
469
|
+
const { run, tool } = commandHost();
|
|
470
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["Wire OAuth providers"] }] });
|
|
471
|
+
const ui = uiStub();
|
|
472
|
+
await run("start oauth", makeCtx(ui));
|
|
473
|
+
expect(ui.notifications[0]?.text).toBe("Started: Wire OAuth providers");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("start without a match reports the omp error", async () => {
|
|
477
|
+
const { run } = commandHost();
|
|
478
|
+
const ui = uiStub();
|
|
479
|
+
await run("start nonexistent", makeCtx(ui));
|
|
480
|
+
expect(ui.notifications[0]?.text).toBe('No task matched "nonexistent". Use /todo to list current tasks.');
|
|
481
|
+
expect(ui.notifications[0]?.type).toBe("error");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("done marks a task / a phase / everything (omp #mutateStatus)", async () => {
|
|
485
|
+
const { host, run, tool } = commandHost();
|
|
486
|
+
await executeTool(tool, {
|
|
487
|
+
op: "init",
|
|
488
|
+
list: [
|
|
489
|
+
{ phase: "One", items: ["t1", "t2"] },
|
|
490
|
+
{ phase: "Two", items: ["t3"] },
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const ui = uiStub();
|
|
495
|
+
await run("done t1", makeCtx(ui));
|
|
496
|
+
expect(ui.notifications[0]?.text).toBe("Marked completed: t1");
|
|
497
|
+
|
|
498
|
+
await run("done two", makeCtx(ui));
|
|
499
|
+
expect(ui.notifications[1]?.text).toBe("Marked phase Two completed.");
|
|
500
|
+
|
|
501
|
+
await run("done", makeCtx(ui));
|
|
502
|
+
expect(ui.notifications[2]?.text).toBe("Marked all tasks completed.");
|
|
503
|
+
|
|
504
|
+
const view: string[] = [];
|
|
505
|
+
await run("", { hasUI: true, ui: { notify: (m: string) => view.push(m) } });
|
|
506
|
+
// omp #showCurrent renders the Markdown checklist.
|
|
507
|
+
expect(view.join("\n")).toContain("- [x] t1");
|
|
508
|
+
expect(view.join("\n")).toContain("- [x] t3");
|
|
509
|
+
expect(host.entries.some(e => e.customType === TODO_PHASES_ENTRY_TYPE)).toBe(true);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("drop marks abandoned; rm removes tasks and phases (omp verbs)", async () => {
|
|
513
|
+
const { host, run, tool } = commandHost();
|
|
514
|
+
await executeTool(tool, {
|
|
515
|
+
op: "init",
|
|
516
|
+
list: [
|
|
517
|
+
{ phase: "One", items: ["t1", "t2"] },
|
|
518
|
+
{ phase: "Two", items: ["t3"] },
|
|
519
|
+
],
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const ui = uiStub();
|
|
523
|
+
await run("drop t1", makeCtx(ui));
|
|
524
|
+
expect(ui.notifications[0]?.text).toBe("Marked abandoned: t1");
|
|
525
|
+
|
|
526
|
+
await run("rm t2", makeCtx(ui));
|
|
527
|
+
expect(ui.notifications[1]?.text).toBe("Removed: t2");
|
|
528
|
+
|
|
529
|
+
await run("rm two", makeCtx(ui));
|
|
530
|
+
expect(ui.notifications[2]?.text).toBe("Removed phase: Two");
|
|
531
|
+
|
|
532
|
+
// /todo rm (no arg) clears everything with the omp removed-intent reminder.
|
|
533
|
+
host.sentMessages.length = 0;
|
|
534
|
+
await run("rm", makeCtx(ui));
|
|
535
|
+
expect(ui.notifications[3]?.text).toBe("Cleared all todos.");
|
|
536
|
+
const reminder = host.sentMessages.find(m => m.customType === "todo-user-edit");
|
|
537
|
+
expect(reminder?.content).toContain("The user manually modified the todo list (/todo rm (all)).");
|
|
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);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("unknown task/phase targets report the omp error", async () => {
|
|
545
|
+
const { run, tool } = commandHost();
|
|
546
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1"] }] });
|
|
547
|
+
const ui = uiStub();
|
|
548
|
+
await run("done zap", makeCtx(ui));
|
|
549
|
+
expect(ui.notifications[0]?.text).toBe('No task or phase matched "zap".');
|
|
550
|
+
expect(ui.notifications[0]?.type).toBe("error");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("copy prints the Markdown checklist (omp ACP fallback text)", async () => {
|
|
554
|
+
const { run, tool } = commandHost();
|
|
555
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1"] }] });
|
|
556
|
+
const ui = uiStub();
|
|
557
|
+
await run("copy", makeCtx(ui));
|
|
558
|
+
expect(ui.notifications[0]?.text).toContain("Copy not available");
|
|
559
|
+
expect(ui.notifications[0]?.text).toContain("- [/] a1");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("help prints the omp usage block; unknown verbs error with it", async () => {
|
|
563
|
+
const { run } = commandHost();
|
|
564
|
+
const ui = uiStub();
|
|
565
|
+
await run("help", makeCtx(ui));
|
|
566
|
+
expect(ui.notifications[0]?.text).toContain("Usage: /todo <verb> [args]");
|
|
567
|
+
expect(ui.notifications[0]?.text).toContain("/todo append [<phase>] <task...>");
|
|
568
|
+
|
|
569
|
+
await run("explode", makeCtx(ui));
|
|
570
|
+
expect(ui.notifications[1]?.type).toBe("error");
|
|
571
|
+
expect(ui.notifications[1]?.text).toContain('Unknown /todo verb "explode".');
|
|
572
|
+
expect(ui.notifications[1]?.text).toContain("Usage: /todo <verb> [args]");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("export writes the Markdown file; import restores it (omp round-trip)", async () => {
|
|
576
|
+
const { host, run, tool } = commandHost();
|
|
577
|
+
await executeTool(tool, {
|
|
578
|
+
op: "init",
|
|
579
|
+
list: [
|
|
580
|
+
{ phase: "One", items: ["t1"] },
|
|
581
|
+
{ phase: "Two", items: ["t2", "t3"] },
|
|
582
|
+
],
|
|
583
|
+
});
|
|
584
|
+
await executeTool(tool, { op: "done", task: "t1" });
|
|
585
|
+
|
|
586
|
+
const ui = uiStub();
|
|
587
|
+
const target = path.join(tmpdir(), `pi-todo-test-${Date.now()}.md`);
|
|
588
|
+
await run(`export ${target}`, makeCtx(ui));
|
|
589
|
+
expect(ui.notifications[0]?.text).toBe(`Wrote todos to ${target}`);
|
|
590
|
+
const written = readFileSync(target, "utf8");
|
|
591
|
+
expect(written).toContain("# One");
|
|
592
|
+
expect(written).toContain("- [x] t1");
|
|
593
|
+
|
|
594
|
+
// Wipe state, then import.
|
|
595
|
+
await run("rm", makeCtx(ui));
|
|
596
|
+
ui.notifications.length = 0;
|
|
597
|
+
await run(`import ${target}`, makeCtx(ui));
|
|
598
|
+
expect(ui.notifications[0]?.text).toBe(`Imported 2 phase(s), 3 task(s) from ${target}.`);
|
|
599
|
+
const view: string[] = [];
|
|
600
|
+
await run("", { hasUI: true, ui: { notify: (m: string) => view.push(m) } });
|
|
601
|
+
expect(view.join("\n")).toContain("[x] t1");
|
|
602
|
+
expect(view.join("\n")).toContain("# Two");
|
|
603
|
+
void host;
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("import failure reports the omp read error", async () => {
|
|
607
|
+
const { run } = commandHost();
|
|
608
|
+
const ui = uiStub();
|
|
609
|
+
await run("import /nonexistent/pi-todo-missing.md", makeCtx(ui));
|
|
610
|
+
expect(ui.notifications[0]?.text).toContain("Failed to read todos:");
|
|
611
|
+
expect(ui.notifications[0]?.type).toBe("error");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("edit round-trips through the dialog editor (omp $EDITOR adaptation)", async () => {
|
|
615
|
+
const { host, run, tool } = commandHost();
|
|
616
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1"] }] });
|
|
617
|
+
const edited = "# A\n- [/] a1\n- [ ] a2\n";
|
|
618
|
+
const ui = uiStub();
|
|
619
|
+
const ctx = makeCtx(ui, {});
|
|
620
|
+
(ctx.ui as Record<string, unknown>).editor = async () => edited;
|
|
621
|
+
await run("edit", ctx);
|
|
622
|
+
expect(ui.notifications[0]?.text).toBe("Todos updated from editor: 1 phase(s), 2 task(s).");
|
|
623
|
+
// Reminder tells the agent about the manual edit (omp #commit step 3).
|
|
624
|
+
const reminder = host.sentMessages.find(m => m.customType === "todo-user-edit");
|
|
625
|
+
expect(reminder?.content).toContain("The user manually modified the todo list (/todo edit).");
|
|
626
|
+
expect(reminder?.content).toContain("<system-reminder>");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("edit cancel leaves todos unchanged with the omp warning", async () => {
|
|
630
|
+
const { host, run, tool } = commandHost();
|
|
631
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1"] }] });
|
|
632
|
+
host.sentMessages.length = 0;
|
|
633
|
+
const ui = uiStub();
|
|
634
|
+
const ctx = makeCtx(ui);
|
|
635
|
+
(ctx.ui as Record<string, unknown>).editor = async () => undefined;
|
|
636
|
+
await run("edit", ctx);
|
|
637
|
+
expect(ui.notifications[0]?.text).toBe("Editor exited without saving; todos unchanged.");
|
|
638
|
+
expect(ui.notifications[0]?.type).toBe("warning");
|
|
639
|
+
expect(host.sentMessages).toHaveLength(0);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("headless mode degrades to stderr text", async () => {
|
|
643
|
+
const { run, tool } = commandHost();
|
|
644
|
+
await executeTool(tool, { op: "init", list: [{ phase: "A", items: ["a1"] }] });
|
|
645
|
+
const stderr: string[] = [];
|
|
646
|
+
const spy = vi.spyOn(console, "error").mockImplementation((message: unknown) => {
|
|
647
|
+
stderr.push(String(message));
|
|
648
|
+
});
|
|
649
|
+
await run("", { hasUI: false, ui: { notify: () => {} } });
|
|
650
|
+
expect(stderr.at(-1)).toContain("- [/] a1");
|
|
651
|
+
spy.mockRestore();
|
|
652
|
+
});
|
|
653
|
+
});
|