@pi-archimedes/todo 2.10.4 → 2.11.1
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 +2 -2
- package/package.json +10 -8
- package/src/host-bus.test.ts +216 -0
- package/src/index.test.ts +31 -7
- package/src/index.ts +10 -5
- package/src/tool.output-schema.test.ts +117 -0
- package/src/tool.test.ts +6 -1
- package/src/tool.ts +70 -10
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Two operations, `write` and `read`:
|
|
|
44
44
|
"todoList": [
|
|
45
45
|
{ "content": "Parse config files", "description": "Read and validate settings", "status": "completed" },
|
|
46
46
|
{ "content": "Build state manager", "description": "Implement reactive store", "status": "in_progress" },
|
|
47
|
-
{ "content": "Connect UI widget", "description": "Bind to
|
|
47
|
+
{ "content": "Connect UI widget", "description": "Bind to the shared event channels", "status": "pending" }
|
|
48
48
|
]
|
|
49
49
|
}
|
|
50
50
|
```
|
|
@@ -68,6 +68,6 @@ When every task reaches `completed`, the widget shows a brief confirmation, then
|
|
|
68
68
|
|
|
69
69
|
## Part of the suite
|
|
70
70
|
|
|
71
|
-
With subagents running alongside in the [full suite](https://github.com/danielcherubini/pi-archimedes), each child gets its own named column to the right of yours — it appears on the child's first non-empty todo update over
|
|
71
|
+
With subagents running alongside in the [full suite](https://github.com/danielcherubini/pi-archimedes), each child gets its own named column to the right of yours — it appears on the child's first non-empty todo update over the host's event bus, `pi.events` (empty updates are ignored; it does not appear when the worker merely starts), and it is removed when the child exits. The 2-second auto-clear is local to whatever list completed — a child clearing its own list does not dismiss the column in your session. On/off is managed by the suite: toggle via `/plugins` (`archimedes.todo.enabled`, default on).
|
|
72
72
|
|
|
73
73
|
← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/todo",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -19,17 +19,19 @@
|
|
|
19
19
|
"./prepare-args": "./src/prepare-args.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pi-archimedes/core": "2.
|
|
22
|
+
"@pi-archimedes/core": "2.11.1"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@earendil-works/pi-ai": ">=0.
|
|
26
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
27
|
-
"@earendil-works/pi-tui": ">=0.
|
|
25
|
+
"@earendil-works/pi-ai": ">=0.99.0",
|
|
26
|
+
"@earendil-works/pi-coding-agent": ">=0.99.0",
|
|
27
|
+
"@earendil-works/pi-tui": ">=0.99.0",
|
|
28
|
+
"typebox": ">=1.1.0"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
|
-
"@earendil-works/pi-ai": "
|
|
31
|
-
"@earendil-works/pi-coding-agent": "
|
|
32
|
-
"@earendil-works/pi-tui": "
|
|
31
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
32
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
33
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
34
|
+
"typebox": "^1.1.38",
|
|
33
35
|
"typescript": "^6.0.0"
|
|
34
36
|
},
|
|
35
37
|
"pi": {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-package transport: `subagent` emits, `todo` receives — on ONE host
|
|
3
|
+
* event bus, which is the property the retired `globalThis` bus used to
|
|
4
|
+
* provide by being process-wide.
|
|
5
|
+
*
|
|
6
|
+
* This is the unit-level shape of the headless proof in the migration report:
|
|
7
|
+
* two packages, two `ExtensionAPI` handles, one delivery. The bus object here
|
|
8
|
+
* stands in for the host's `createEventBus()`; what it must not be is a third
|
|
9
|
+
* package — nothing may own the transport any more.
|
|
10
|
+
*
|
|
11
|
+
* Red-first note: before the migration this failed because both sides talked to
|
|
12
|
+
* `getBus()` (the `globalThis` singleton) and ignored the handle handed to
|
|
13
|
+
* them, so the shared bus below saw nothing.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from "vitest";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { registerTodo } from "./index.js";
|
|
18
|
+
|
|
19
|
+
/** The channel literal — the wire contract, not an import, on purpose. */
|
|
20
|
+
const TODOS_UPDATE = "archimedes:todos_update";
|
|
21
|
+
const TODOS_CLEAR = "archimedes:todos_clear";
|
|
22
|
+
|
|
23
|
+
function fakeBus() {
|
|
24
|
+
const listeners = new Map<string, Array<(data: unknown) => void>>();
|
|
25
|
+
return {
|
|
26
|
+
emit(channel: string, data: unknown): void {
|
|
27
|
+
for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
|
|
28
|
+
},
|
|
29
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
30
|
+
const subs = listeners.get(channel) ?? [];
|
|
31
|
+
subs.push(handler);
|
|
32
|
+
listeners.set(channel, subs);
|
|
33
|
+
return () => {
|
|
34
|
+
const current = listeners.get(channel) ?? [];
|
|
35
|
+
const idx = current.indexOf(handler);
|
|
36
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
listenerCount(channel: string): number {
|
|
40
|
+
return (listeners.get(channel) ?? []).length;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fakePi(bus: ReturnType<typeof fakeBus>) {
|
|
46
|
+
const handlers = new Map<string, Array<(e: unknown, ctx: unknown) => unknown>>();
|
|
47
|
+
const captured: { widget?: unknown; tools: Record<string, any> } = { tools: {} };
|
|
48
|
+
const ctx = {
|
|
49
|
+
ui: { setWidget: (_id: string, factory: unknown) => { captured.widget = factory; } },
|
|
50
|
+
sessionManager: { getBranch: () => [] },
|
|
51
|
+
};
|
|
52
|
+
const pi = {
|
|
53
|
+
on(event: string, handler: (e: unknown, ctx: unknown) => unknown) {
|
|
54
|
+
const list = handlers.get(event) ?? [];
|
|
55
|
+
list.push(handler);
|
|
56
|
+
handlers.set(event, list);
|
|
57
|
+
return () => {
|
|
58
|
+
const current = handlers.get(event) ?? [];
|
|
59
|
+
const idx = current.indexOf(handler);
|
|
60
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
registerTool(tool: any) {
|
|
64
|
+
captured.tools[tool.name] = tool;
|
|
65
|
+
},
|
|
66
|
+
registerCommand() {},
|
|
67
|
+
events: bus,
|
|
68
|
+
};
|
|
69
|
+
const fire = async (event: string, e: unknown = { type: event }) => {
|
|
70
|
+
for (const handler of [...(handlers.get(event) ?? [])]) await handler(e, ctx);
|
|
71
|
+
};
|
|
72
|
+
return { pi: pi as unknown as ExtensionAPI, fire, ctx, captured };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const theme: any = { fg: (_c: unknown, s: unknown) => String(s), strikethrough: (s: unknown) => String(s) };
|
|
76
|
+
|
|
77
|
+
function widgetLines(widget: unknown): string[] {
|
|
78
|
+
expect(typeof widget).toBe("function");
|
|
79
|
+
return (widget as (tui: unknown, t: unknown) => { render(w: number): string[] })(null, theme).render(200);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("todo receives subagent todos over the host bus", () => {
|
|
83
|
+
it("a subscriber registered before the emitter receives the update", async () => {
|
|
84
|
+
const bus = fakeBus();
|
|
85
|
+
const todo = fakePi(bus);
|
|
86
|
+
|
|
87
|
+
// todo subscribes FIRST (registration time, as it does today) ...
|
|
88
|
+
registerTodo(todo.pi);
|
|
89
|
+
await todo.fire("session_start");
|
|
90
|
+
expect(bus.listenerCount(TODOS_UPDATE)).toBe(1);
|
|
91
|
+
|
|
92
|
+
// ... and only then does a producer emit, from another package's handle.
|
|
93
|
+
bus.emit(TODOS_UPDATE, {
|
|
94
|
+
source: "subagent:explorer",
|
|
95
|
+
todos: [{ content: "Read the bridge", status: "in_progress" }],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const lines = widgetLines(todo.captured.widget).join("\n");
|
|
99
|
+
expect(lines).toContain("Read the bridge");
|
|
100
|
+
// The widget renders the agent NAME for a subagent column, not the raw
|
|
101
|
+
// "subagent:<name>" source — that mapping is the todo package's contract.
|
|
102
|
+
expect(lines).toContain("subagent (explorer)");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("TODOS_CLEAR drops the column", async () => {
|
|
106
|
+
const bus = fakeBus();
|
|
107
|
+
const todo = fakePi(bus);
|
|
108
|
+
registerTodo(todo.pi);
|
|
109
|
+
await todo.fire("session_start");
|
|
110
|
+
// A main todo keeps the widget installed, so the assertion below is about
|
|
111
|
+
// the subagent column disappearing and not about the widget going away.
|
|
112
|
+
await todo.captured.tools.manage_todo_list.execute(
|
|
113
|
+
"c1",
|
|
114
|
+
{ operation: "write", todoList: [{ content: "Keep the bar up", status: "pending" }] },
|
|
115
|
+
undefined,
|
|
116
|
+
undefined,
|
|
117
|
+
todo.ctx as any,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
bus.emit(TODOS_UPDATE, { source: "subagent:explorer", todos: [{ content: "Temp", status: "pending" }] });
|
|
121
|
+
expect(widgetLines(todo.captured.widget).join("\n")).toContain("Temp");
|
|
122
|
+
|
|
123
|
+
bus.emit(TODOS_CLEAR, { source: "subagent:explorer" });
|
|
124
|
+
const after = widgetLines(todo.captured.widget).join("\n");
|
|
125
|
+
expect(after).not.toContain("Temp");
|
|
126
|
+
expect(after).toContain("Keep the bar up");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The AGENTS.md accumulation rule, made observable. Two `session_start`s in
|
|
131
|
+
* one session must not stack a second subscription — that is how a per-session
|
|
132
|
+
* `events.on()` double-delivers.
|
|
133
|
+
*/
|
|
134
|
+
it("two session_starts do not cause two deliveries of one event", async () => {
|
|
135
|
+
const bus = fakeBus();
|
|
136
|
+
const todo = fakePi(bus);
|
|
137
|
+
registerTodo(todo.pi);
|
|
138
|
+
await todo.fire("session_start");
|
|
139
|
+
await todo.fire("session_start");
|
|
140
|
+
expect(bus.listenerCount(TODOS_UPDATE)).toBe(1);
|
|
141
|
+
|
|
142
|
+
const seen: unknown[] = [];
|
|
143
|
+
bus.on(TODOS_UPDATE, (p) => seen.push(p));
|
|
144
|
+
bus.emit(TODOS_UPDATE, { source: "subagent:x", todos: [{ content: "Once", status: "pending" }] });
|
|
145
|
+
expect(seen).toHaveLength(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("session_shutdown unsubscribes, so a dead session stops receiving", async () => {
|
|
149
|
+
const bus = fakeBus();
|
|
150
|
+
const todo = fakePi(bus);
|
|
151
|
+
registerTodo(todo.pi);
|
|
152
|
+
await todo.fire("session_start");
|
|
153
|
+
await todo.fire("session_shutdown", { type: "session_shutdown", reason: "new" });
|
|
154
|
+
expect(bus.listenerCount(TODOS_UPDATE)).toBe(0);
|
|
155
|
+
expect(bus.listenerCount(TODOS_CLEAR)).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Three session generations on one handle, the pessimistic shape: pi gives
|
|
160
|
+
* each session its own bus today (a fresh resource loader per session), but a
|
|
161
|
+
* `session_start` also fires twice per replacement in RPC mode, and a dropped
|
|
162
|
+
* unsubscribe is invisible until something accumulates. Each generation gets a
|
|
163
|
+
* fresh `registerTodo` (pi re-runs the factory), two `session_start`s, and one
|
|
164
|
+
* emit — and must see exactly one delivery, with nothing left alive at the end.
|
|
165
|
+
*/
|
|
166
|
+
it("three generations on one bus: one delivery each, nothing left subscribed", async () => {
|
|
167
|
+
const bus = fakeBus();
|
|
168
|
+
for (let generation = 1; generation <= 3; generation += 1) {
|
|
169
|
+
const todo = fakePi(bus);
|
|
170
|
+
registerTodo(todo.pi);
|
|
171
|
+
await todo.fire("session_start");
|
|
172
|
+
await todo.fire("session_start");
|
|
173
|
+
expect(bus.listenerCount(TODOS_UPDATE)).toBe(1);
|
|
174
|
+
expect(bus.listenerCount(TODOS_CLEAR)).toBe(1);
|
|
175
|
+
|
|
176
|
+
const seen: unknown[] = [];
|
|
177
|
+
const unsub = bus.on(TODOS_UPDATE, (p) => seen.push(p));
|
|
178
|
+
bus.emit(TODOS_UPDATE, {
|
|
179
|
+
source: `subagent:gen${generation}`,
|
|
180
|
+
todos: [{ content: `gen${generation}`, status: "pending" }],
|
|
181
|
+
});
|
|
182
|
+
expect(seen).toHaveLength(1);
|
|
183
|
+
unsub();
|
|
184
|
+
|
|
185
|
+
await todo.fire("session_shutdown", { type: "session_shutdown", reason: "new" });
|
|
186
|
+
expect(bus.listenerCount(TODOS_UPDATE)).toBe(0);
|
|
187
|
+
expect(bus.listenerCount(TODOS_CLEAR)).toBe(0);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("todo's own writes reach the bus a subscriber already holds", () => {
|
|
193
|
+
it("the tool emits TODOS_UPDATE on the handle it was given", async () => {
|
|
194
|
+
const bus = fakeBus();
|
|
195
|
+
const todo = fakePi(bus);
|
|
196
|
+
registerTodo(todo.pi);
|
|
197
|
+
await todo.fire("session_start");
|
|
198
|
+
|
|
199
|
+
const seen: unknown[] = [];
|
|
200
|
+
bus.on(TODOS_UPDATE, (p) => seen.push(p));
|
|
201
|
+
|
|
202
|
+
await todo.captured.tools.manage_todo_list.execute(
|
|
203
|
+
"c1",
|
|
204
|
+
{ operation: "write", todoList: [{ content: "Write the bus out", status: "pending" }] },
|
|
205
|
+
undefined,
|
|
206
|
+
undefined,
|
|
207
|
+
todo.ctx as any,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(seen).toHaveLength(1);
|
|
211
|
+
expect((seen[0] as { source: string }).source).toBe("main");
|
|
212
|
+
expect((seen[0] as { todos: unknown[] }).todos).toEqual([
|
|
213
|
+
{ content: "Write the bus out", status: "pending" },
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
});
|
package/src/index.test.ts
CHANGED
|
@@ -9,14 +9,37 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import {
|
|
12
|
+
import { Events } from "@pi-archimedes/core/events";
|
|
13
13
|
import { registerTodo } from "./index.js";
|
|
14
14
|
|
|
15
|
+
/** Stands in for the host's `createEventBus()` (the `pi.events` contract). */
|
|
16
|
+
function fakeBus() {
|
|
17
|
+
const listeners = new Map<string, Array<(data: unknown) => void>>();
|
|
18
|
+
return {
|
|
19
|
+
emit(channel: string, data: unknown): void {
|
|
20
|
+
for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
|
|
21
|
+
},
|
|
22
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
23
|
+
const subs = listeners.get(channel) ?? [];
|
|
24
|
+
subs.push(handler);
|
|
25
|
+
listeners.set(channel, subs);
|
|
26
|
+
return () => {
|
|
27
|
+
const current = listeners.get(channel) ?? [];
|
|
28
|
+
const idx = current.indexOf(handler);
|
|
29
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
describe("todo index — bus consumer normalization", () => {
|
|
16
36
|
const captured: Record<string, unknown> = {};
|
|
17
37
|
const handlers: Record<string, Function> = {};
|
|
18
38
|
const registered: { tool?: any; commands: Record<string, unknown> } = { commands: {} };
|
|
19
39
|
|
|
40
|
+
/** The handle `registerTodo` subscribes on and the tool emits on. */
|
|
41
|
+
const bus = fakeBus();
|
|
42
|
+
|
|
20
43
|
const pi = {
|
|
21
44
|
on(evt: string, fn: Function) {
|
|
22
45
|
handlers[evt] = fn;
|
|
@@ -27,6 +50,7 @@ describe("todo index — bus consumer normalization", () => {
|
|
|
27
50
|
registerCommand(name: string, def: unknown) {
|
|
28
51
|
registered.commands[name] = def;
|
|
29
52
|
},
|
|
53
|
+
events: bus,
|
|
30
54
|
} as unknown as ExtensionAPI;
|
|
31
55
|
|
|
32
56
|
const ctx = {
|
|
@@ -81,7 +105,7 @@ describe("todo index — bus consumer normalization", () => {
|
|
|
81
105
|
expect(result.isError).toBeFalsy();
|
|
82
106
|
|
|
83
107
|
// Legacy (old-schema) subagent payload: id/title/not-started.
|
|
84
|
-
|
|
108
|
+
bus.emit(Events.TODOS_UPDATE, {
|
|
85
109
|
source: "subagent:fake1",
|
|
86
110
|
todos: [{ id: 1, title: "Old shape task", status: "not-started" }],
|
|
87
111
|
});
|
|
@@ -96,8 +120,8 @@ describe("todo index — bus consumer normalization", () => {
|
|
|
96
120
|
it("ignores unrecoverable payloads without crashing (no phantom column)", () => {
|
|
97
121
|
// State from the previous test persists (same registered instance).
|
|
98
122
|
expect(() => {
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
bus.emit(Events.TODOS_UPDATE, { source: "subagent:fake2", todos: "garbage" });
|
|
124
|
+
bus.emit(Events.TODOS_UPDATE, {
|
|
101
125
|
source: "subagent:fake2",
|
|
102
126
|
todos: [{ status: "pending" }],
|
|
103
127
|
});
|
|
@@ -110,7 +134,7 @@ describe("todo index — bus consumer normalization", () => {
|
|
|
110
134
|
});
|
|
111
135
|
|
|
112
136
|
it("TODOS_CLEAR removes the subagent column", () => {
|
|
113
|
-
|
|
137
|
+
bus.emit(Events.TODOS_CLEAR, { source: "subagent:fake1" });
|
|
114
138
|
|
|
115
139
|
const lines = renderLines();
|
|
116
140
|
const joined = lines.join("\n");
|
|
@@ -138,7 +162,7 @@ describe("todo index — bus consumer normalization", () => {
|
|
|
138
162
|
|
|
139
163
|
// Per-child source now carries a suffix; the header must still render
|
|
140
164
|
// just the agent name, not the uuid.
|
|
141
|
-
|
|
165
|
+
bus.emit(Events.TODOS_UPDATE, {
|
|
142
166
|
source: "subagent:fake1:aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
143
167
|
todos: [{ id: 1, title: "Old shape task", status: "not-started" }],
|
|
144
168
|
});
|
|
@@ -152,7 +176,7 @@ describe("todo index — bus consumer normalization", () => {
|
|
|
152
176
|
});
|
|
153
177
|
|
|
154
178
|
afterAll(() => {
|
|
155
|
-
// Unsubscribes the
|
|
179
|
+
// Unsubscribes the handle listeners registered by registerTodo and
|
|
156
180
|
// clears state (fixture uses a pending item, so no auto-clear timer
|
|
157
181
|
// is pending, but cancel defensively).
|
|
158
182
|
handlers.session_shutdown?.(undefined, ctx as any);
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { Events } from "@pi-archimedes/core/events";
|
|
3
3
|
import { TodoStateManager } from "./state-manager.js";
|
|
4
4
|
import { createManageTodoListTool } from "./tool.js";
|
|
5
5
|
import { normalizeTodoItems } from "./prepare-args.js";
|
|
@@ -22,8 +22,13 @@ export function registerTodo(pi: ExtensionAPI): void {
|
|
|
22
22
|
}
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
// Subscribe
|
|
26
|
-
|
|
25
|
+
// Subscribe on the host handle for subagent todos. Registered here, at
|
|
26
|
+
// extension-load time, not inside a session handler: the host drops these
|
|
27
|
+
// subscriptions when the runtime is invalidated, and the factories run again
|
|
28
|
+
// for the next session, so this is exactly once per session — and it keeps
|
|
29
|
+
// the subscription live before the first turn, which is when a child can
|
|
30
|
+
// first report todos. `session_shutdown` below is its counterpart.
|
|
31
|
+
const unsubTodosUpdate = pi.events.on(Events.TODOS_UPDATE, (payload: unknown) => {
|
|
27
32
|
const data = payload as { source: string; todos: TodoItem[] };
|
|
28
33
|
if (data.source === "main") return; // main handled locally
|
|
29
34
|
// The consumer is the guarantee: producers (subagent stream, older
|
|
@@ -40,7 +45,7 @@ export function registerTodo(pi: ExtensionAPI): void {
|
|
|
40
45
|
});
|
|
41
46
|
unsubscribes.push(unsubTodosUpdate);
|
|
42
47
|
|
|
43
|
-
const unsubTodosClear =
|
|
48
|
+
const unsubTodosClear = pi.events.on(Events.TODOS_CLEAR, (payload: unknown) => {
|
|
44
49
|
const data = payload as { source: string };
|
|
45
50
|
subagentTodos.delete(data.source);
|
|
46
51
|
refreshWidget();
|
|
@@ -80,7 +85,7 @@ export function registerTodo(pi: ExtensionAPI): void {
|
|
|
80
85
|
});
|
|
81
86
|
|
|
82
87
|
// Register the manage_todo_list tool
|
|
83
|
-
const tool = createManageTodoListTool(state, refreshWidget);
|
|
88
|
+
const tool = createManageTodoListTool(state, refreshWidget, pi.events);
|
|
84
89
|
pi.registerTool(tool);
|
|
85
90
|
|
|
86
91
|
// Register /todos command
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 11 item 2 — the machine-readable half of `manage_todo_list`.
|
|
3
|
+
*
|
|
4
|
+
* pi 1.2.0 hands a codemode script the tool's `structuredContent` instead of its
|
|
5
|
+
* text, but only when the tool declares an `outputSchema`
|
|
6
|
+
* (`pi: dist/extensions/codemode/execute.js`, `toScriptValue()`), and it never
|
|
7
|
+
* validates one against the other. So the schema is a promise with no runtime
|
|
8
|
+
* referee: these tests are the referee. Every payload below is produced by
|
|
9
|
+
* running the real handler, and checked against the schema the same registered
|
|
10
|
+
* tool declares — not against a hand-copied shape.
|
|
11
|
+
*
|
|
12
|
+
* The failure paths are asserted too, and one of them is a decision rather than
|
|
13
|
+
* a fact: a failure result carries NO structured content, so a script keeps
|
|
14
|
+
* rejecting with the text it rejects with today. See `tool.ts`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from "vitest";
|
|
18
|
+
import { Value } from "typebox/value";
|
|
19
|
+
import { createManageTodoListTool } from "./tool.js";
|
|
20
|
+
import { TodoStateManager } from "./state-manager.js";
|
|
21
|
+
|
|
22
|
+
const noopEvents = { emit: () => {}, on: () => () => {} } as never;
|
|
23
|
+
|
|
24
|
+
function harness() {
|
|
25
|
+
const tool = createManageTodoListTool(new TodoStateManager(), () => {}, noopEvents);
|
|
26
|
+
return {
|
|
27
|
+
tool,
|
|
28
|
+
call: (params: Record<string, unknown>) =>
|
|
29
|
+
tool.execute("call-1", params as never, undefined, undefined, {} as never),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Validate a real result's structured content against the schema on the tool. */
|
|
34
|
+
function check(tool: { outputSchema?: unknown }, value: unknown): boolean {
|
|
35
|
+
expect(tool.outputSchema, "tool declares no outputSchema").toBeDefined();
|
|
36
|
+
return Value.Check(tool.outputSchema as never, value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("manage_todo_list outputSchema declaration", () => {
|
|
40
|
+
it("declares an output schema shaped like the structured content", () => {
|
|
41
|
+
const { tool } = harness();
|
|
42
|
+
expect(tool.outputSchema).toBeDefined();
|
|
43
|
+
expect((tool.outputSchema as { properties?: Record<string, unknown> }).properties).toHaveProperty("todos");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("manage_todo_list structuredContent", () => {
|
|
48
|
+
it("read with an empty list → { operation: read, todos: [] }, valid against the schema", async () => {
|
|
49
|
+
const { tool, call } = harness();
|
|
50
|
+
const result = await call({ operation: "read" });
|
|
51
|
+
expect(result.structuredContent).toEqual({ operation: "read", todos: [] });
|
|
52
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("read after a write → the stored list, items exactly as the model wrote them", async () => {
|
|
56
|
+
const { tool, call } = harness();
|
|
57
|
+
await call({
|
|
58
|
+
operation: "write",
|
|
59
|
+
todoList: [
|
|
60
|
+
{ content: "One", status: "pending" },
|
|
61
|
+
{ content: "Two", status: "in_progress", description: "with context" },
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
const result = await call({ operation: "read" });
|
|
65
|
+
expect(result.structuredContent).toEqual({
|
|
66
|
+
operation: "read",
|
|
67
|
+
todos: [
|
|
68
|
+
{ content: "One", status: "pending" },
|
|
69
|
+
{ content: "Two", status: "in_progress", description: "with context" },
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("write success → { operation: write, todos } matching the schema, content text untouched", async () => {
|
|
76
|
+
const { tool, call } = harness();
|
|
77
|
+
const result = await call({
|
|
78
|
+
operation: "write",
|
|
79
|
+
todoList: [{ content: "Ship it", status: "completed" }],
|
|
80
|
+
});
|
|
81
|
+
expect(result.isError).toBeUndefined();
|
|
82
|
+
expect(result.structuredContent).toEqual({
|
|
83
|
+
operation: "write",
|
|
84
|
+
todos: [{ content: "Ship it", status: "completed" }],
|
|
85
|
+
});
|
|
86
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
87
|
+
// The model-facing text is unchanged: models and this suite's other tests read it.
|
|
88
|
+
expect(result.content[0]!.text).toContain("1/1 completed");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("write without a todoList → isError, and NO structured content (scripts keep rejecting with the text)", async () => {
|
|
92
|
+
const { call } = harness();
|
|
93
|
+
const result = await call({ operation: "write" });
|
|
94
|
+
expect(result.isError).toBe(true);
|
|
95
|
+
expect(result.content[0]!.text).toBe("Error: todoList is required for write operation.");
|
|
96
|
+
expect("structuredContent" in result).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("write that fails validation → isError, no structured content, content text untouched", async () => {
|
|
100
|
+
const { call } = harness();
|
|
101
|
+
const result = await call({
|
|
102
|
+
operation: "write",
|
|
103
|
+
todoList: [{ content: " ", status: "pending" }],
|
|
104
|
+
});
|
|
105
|
+
expect(result.isError).toBe(true);
|
|
106
|
+
expect(result.content[0]!.text).toContain("Validation failed:");
|
|
107
|
+
expect("structuredContent" in result).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("write of an empty list → { operation: write, todos: [] }, valid (a cleared list is an answer)", async () => {
|
|
111
|
+
const { tool, call } = harness();
|
|
112
|
+
const result = await call({ operation: "write", todoList: [] });
|
|
113
|
+
expect(result.isError).toBeUndefined();
|
|
114
|
+
expect(result.structuredContent).toEqual({ operation: "write", todos: [] });
|
|
115
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/tool.test.ts
CHANGED
|
@@ -22,7 +22,12 @@ const theme = {
|
|
|
22
22
|
strikethrough: (text: unknown) => `[s]${text}`,
|
|
23
23
|
} as unknown as Theme;
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// The tool emits on the handle it is handed; this one has no listeners, so the
|
|
26
|
+
// emits are inert here (the emit contract lives in `host-bus.test.ts`).
|
|
27
|
+
const tool = createManageTodoListTool(new TodoStateManager(), () => {}, {
|
|
28
|
+
emit: () => {},
|
|
29
|
+
on: () => () => {},
|
|
30
|
+
});
|
|
26
31
|
|
|
27
32
|
function renderLines(result: AgentToolResult<TodoDetails | undefined>, expanded: boolean): string {
|
|
28
33
|
const view = tool.renderResult(result, { expanded, isPartial: false }, theme) as {
|
package/src/tool.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { type Static, StringEnum, Type } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { AgentToolResult, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentToolResult, EventBus, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
-
import {
|
|
4
|
+
import { Events, emitOn } from "@pi-archimedes/core/events";
|
|
5
5
|
import { renderToolHeader, renderStatusLabel } from "@pi-archimedes/core/tool-render";
|
|
6
6
|
import { prepareTodoArguments } from "./prepare-args.js";
|
|
7
7
|
import type { TodoStateManager } from "./state-manager.js";
|
|
8
8
|
import { STATUS_ICONS } from "./types.js";
|
|
9
|
-
import type { TodoDetails } from "./types.js";
|
|
9
|
+
import type { TodoDetails, TodoItem, TodoStatus } from "./types.js";
|
|
10
10
|
|
|
11
11
|
const TodoItemSchema = Type.Object({
|
|
12
12
|
content: Type.String({
|
|
@@ -33,6 +33,55 @@ export const ManageTodoListParams = Type.Object({
|
|
|
33
33
|
|
|
34
34
|
export type ManageTodoListInput = Static<typeof ManageTodoListParams>;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The machine-readable result, for programmatic callers.
|
|
38
|
+
*
|
|
39
|
+
* `structuredContent` mirrors `details` on success — the list as it now stands,
|
|
40
|
+
* which is the whole answer for both operations. pi hands it to a codemode
|
|
41
|
+
* script *instead of* the text, but only when the tool declares this schema, and
|
|
42
|
+
* it never checks one against the other (`pi: dist/extensions/codemode/execute.js`,
|
|
43
|
+
* `toScriptValue()`), so `tool.output-schema.test.ts` is the referee: it runs the
|
|
44
|
+
* handler and validates the payload against this schema.
|
|
45
|
+
*
|
|
46
|
+
* Failure results carry none of it. pi's own `bash` does attach structured
|
|
47
|
+
* content to a non-zero exit, because there the failing command *is* the
|
|
48
|
+
* observation; here every failure is a call that produced no observation (no
|
|
49
|
+
* list supplied, or a list the validator rejected), and pi's codemode script
|
|
50
|
+
* keeps rejecting with the text — same as before this schema existed.
|
|
51
|
+
*/
|
|
52
|
+
const TodoItemOutputSchema = Type.Object({
|
|
53
|
+
content: Type.String(),
|
|
54
|
+
status: StringEnum(["pending", "in_progress", "completed"] as const),
|
|
55
|
+
description: Type.Optional(Type.String()),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const ManageTodoListOutput = Type.Object({
|
|
59
|
+
/** Which operation ran; the shape is the same for both. */
|
|
60
|
+
operation: StringEnum(["read", "write"] as const),
|
|
61
|
+
/** The list as it stands after the call. */
|
|
62
|
+
todos: Type.Array(TodoItemOutputSchema),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The JSON-shaped copy of a stored item. `TodoItem` is an interface, which
|
|
67
|
+
* TypeScript will not match against `JsonValue`'s index signature, and copying
|
|
68
|
+
* also pins the payload to exactly the three fields the schema declares.
|
|
69
|
+
*/
|
|
70
|
+
type TodoItemOutput = { content: string; status: TodoStatus; description?: string };
|
|
71
|
+
|
|
72
|
+
function toOutputTodos(todos: TodoItem[]): TodoItemOutput[] {
|
|
73
|
+
return todos.map((item) => ({
|
|
74
|
+
content: item.content,
|
|
75
|
+
status: item.status,
|
|
76
|
+
...(item.description !== undefined && { description: item.description }),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `{ operation, todos }` — the successful result of either operation. */
|
|
81
|
+
function todoOutput(operation: "read" | "write", todos: TodoItem[]): { operation: "read" | "write"; todos: TodoItemOutput[] } {
|
|
82
|
+
return { operation, todos: toOutputTodos(todos) };
|
|
83
|
+
}
|
|
84
|
+
|
|
36
85
|
export const TOOL_DESCRIPTION = `Manage a structured todo list to track progress and plan tasks throughout your coding session. Use this tool VERY frequently to ensure task visibility and proper planning.
|
|
37
86
|
|
|
38
87
|
When to use this tool:
|
|
@@ -69,12 +118,22 @@ Todo states:
|
|
|
69
118
|
IMPORTANT: Mark todos completed as soon as they are done. Do not batch completions.
|
|
70
119
|
When all todos are completed, the list auto-clears after a brief delay.`;
|
|
71
120
|
|
|
72
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Build the `manage_todo_list` tool.
|
|
123
|
+
*
|
|
124
|
+
* @param events the caller's host handle (`pi.events`). The tool is a factory,
|
|
125
|
+
* not an extension, so it has no `pi` of its own — the handle is
|
|
126
|
+
* injected rather than reached for, which is what keeps the suite
|
|
127
|
+
* free of a `globalThis` bus. A child pi runs the same tool with
|
|
128
|
+
* its OWN handle, and its writes stay inside the child.
|
|
129
|
+
*/
|
|
130
|
+
export function createManageTodoListTool(state: TodoStateManager, onUpdate: () => void, events: EventBus) {
|
|
73
131
|
return {
|
|
74
132
|
name: "manage_todo_list",
|
|
75
133
|
label: "Todo List",
|
|
76
134
|
description: TOOL_DESCRIPTION,
|
|
77
135
|
parameters: ManageTodoListParams,
|
|
136
|
+
outputSchema: ManageTodoListOutput,
|
|
78
137
|
|
|
79
138
|
// Repair common model mistakes (stringified arrays, missing
|
|
80
139
|
// title/status, bare-string items) BEFORE schema validation. See
|
|
@@ -100,6 +159,7 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
|
|
|
100
159
|
},
|
|
101
160
|
],
|
|
102
161
|
details: { operation: "read", todos } as TodoDetails,
|
|
162
|
+
structuredContent: todoOutput("read", todos),
|
|
103
163
|
};
|
|
104
164
|
}
|
|
105
165
|
|
|
@@ -125,12 +185,11 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
|
|
|
125
185
|
state.write(todoList);
|
|
126
186
|
onUpdate();
|
|
127
187
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
188
|
+
// Announce the new list on the handle: the bridge relays it to the
|
|
189
|
+
// desktop as a `todos_update` frame (and any other in-process listener,
|
|
190
|
+
// e.g. a second todo instance, sees the same payload). `onUpdate()` above
|
|
191
|
+
// is the local widget path; this is the outward one.
|
|
192
|
+
emitOn(events, Events.TODOS_UPDATE, { source: "main", todos: state.read() });
|
|
134
193
|
|
|
135
194
|
const stats = state.getStats();
|
|
136
195
|
const todos = state.read();
|
|
@@ -144,6 +203,7 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
|
|
|
144
203
|
return {
|
|
145
204
|
content: [{ type: "text" as const, text: message }],
|
|
146
205
|
details: { operation: "write", todos } as TodoDetails,
|
|
206
|
+
structuredContent: todoOutput("write", todos),
|
|
147
207
|
};
|
|
148
208
|
},
|
|
149
209
|
|