@pi-archimedes/subagent 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,8 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "2.3.0"
14
+ "@pi-archimedes/core": "2.5.0",
15
+ "@pi-archimedes/todo": "2.5.0"
15
16
  },
16
17
  "peerDependencies": {
17
18
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -20,9 +21,9 @@
20
21
  "typebox": ">=1.1.0"
21
22
  },
22
23
  "devDependencies": {
23
- "@earendil-works/pi-ai": "^0.84.2",
24
- "@earendil-works/pi-coding-agent": "^0.84.2",
25
- "@earendil-works/pi-tui": "^0.84.2",
24
+ "@earendil-works/pi-ai": "^0.84.4",
25
+ "@earendil-works/pi-coding-agent": "^0.84.4",
26
+ "@earendil-works/pi-tui": "^0.84.4",
26
27
  "@types/node": "^22.0.0",
27
28
  "typebox": "^1.1.38",
28
29
  "typescript": "^6.0.3"
@@ -2,7 +2,8 @@ import { EventEmitter } from "node:events";
2
2
  import { PassThrough } from "node:stream";
3
3
  import type { ChildProcess } from "node:child_process";
4
4
  import { describe, expect, it } from "vitest";
5
- import { streamEvents } from "./stream.js";
5
+ import { getBus, Events } from "@pi-archimedes/core/bus";
6
+ import { streamEvents, type StreamCallbacks } from "./stream.js";
6
7
 
7
8
  type FakeChild = ChildProcess & { stdout: PassThrough; stderr: PassThrough };
8
9
 
@@ -16,9 +17,12 @@ function fakeChild(): FakeChild {
16
17
  return child;
17
18
  }
18
19
 
19
- async function finishWith(events: Array<Record<string, unknown>>) {
20
+ async function finishWith(
21
+ events: Array<Record<string, unknown>>,
22
+ callbacks: StreamCallbacks = {},
23
+ ) {
20
24
  const child = fakeChild();
21
- const result = streamEvents(child);
25
+ const result = streamEvents(child, callbacks);
22
26
  for (const event of events) {
23
27
  child.stdout.write(`${JSON.stringify(event)}\n`);
24
28
  }
@@ -26,6 +30,7 @@ async function finishWith(events: Array<Record<string, unknown>>) {
26
30
  return result;
27
31
  }
28
32
 
33
+
29
34
  describe("streamEvents session identity", () => {
30
35
  it("returns the logical child Pi session ID", async () => {
31
36
  const result = await finishWith([{
@@ -45,3 +50,262 @@ describe("streamEvents session identity", () => {
45
50
  expect(result.childSessionId).toBeUndefined();
46
51
  });
47
52
  });
53
+
54
+ describe("streamEvents todo mirroring", () => {
55
+ const start = {
56
+ type: "tool_execution_start",
57
+ toolCallId: "t1",
58
+ toolName: "manage_todo_list",
59
+ args: {
60
+ operation: "write",
61
+ todoList: [{ id: 1, title: "Fix auth", status: "not-started" }],
62
+ },
63
+ } as unknown as Record<string, unknown>;
64
+
65
+ it("mirrors the child-accepted state from result.details.todos", async () => {
66
+ const updates: Array<{ source: string; todos: unknown[] }> = [];
67
+ const clears: Array<{ source: string }> = [];
68
+ const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
69
+ updates.push(p as (typeof updates)[number]),
70
+ );
71
+ const offClear = getBus().on(Events.TODOS_CLEAR, (p: unknown) =>
72
+ clears.push(p as { source: string }),
73
+ );
74
+
75
+ await finishWith(
76
+ [
77
+ start,
78
+ {
79
+ type: "tool_execution_end",
80
+ toolCallId: "t1",
81
+ toolName: "manage_todo_list",
82
+ isError: false,
83
+ result: {
84
+ content: [{ type: "text", text: "ok" }],
85
+ details: {
86
+ operation: "write",
87
+ todos: [{ content: "Fix auth", status: "pending" }],
88
+ },
89
+ },
90
+ },
91
+ ],
92
+ { agent: "t-acc" },
93
+ );
94
+ off();
95
+ offClear();
96
+
97
+ expect(updates).toHaveLength(1);
98
+ expect(updates[0]!.source).toBe("subagent:t-acc");
99
+ expect(updates[0]!.todos).toEqual([
100
+ { content: "Fix auth", status: "pending" },
101
+ ]);
102
+ // Bus re-drains queued events (emitted while unsubscribed) to the next
103
+ // subscriber, so stale "subagent:general" clears from earlier tests may
104
+ // be present — assert on the per-agent source instead of the raw count.
105
+ expect(clears.filter((c) => c.source === "subagent:t-acc")).toHaveLength(1);
106
+ });
107
+
108
+ it("mirrors nothing when the tool returned a validation rejection (result.isError)", async () => {
109
+ const updates: Array<{ source: string; todos: unknown[] }> = [];
110
+ const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
111
+ updates.push(p as (typeof updates)[number]),
112
+ );
113
+
114
+ await finishWith(
115
+ [
116
+ start,
117
+ {
118
+ type: "tool_execution_end",
119
+ toolCallId: "t1",
120
+ toolName: "manage_todo_list",
121
+ isError: false,
122
+ result: {
123
+ content: [{ type: "text", text: "Validation failed" }],
124
+ details: {
125
+ operation: "write",
126
+ todos: [{ content: "Old item", status: "completed" }],
127
+ error: "Item 1: missing or invalid 'content'",
128
+ },
129
+ isError: true,
130
+ },
131
+ },
132
+ ],
133
+ { agent: "t-rej" },
134
+ );
135
+ off();
136
+
137
+ expect(updates).toHaveLength(0);
138
+ });
139
+
140
+ it("mirrors nothing when the harness reported an error (event.isError)", async () => {
141
+ const updates: Array<{ source: string; todos: unknown[] }> = [];
142
+ const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
143
+ updates.push(p as (typeof updates)[number]),
144
+ );
145
+
146
+ await finishWith(
147
+ [
148
+ start,
149
+ {
150
+ type: "tool_execution_end",
151
+ toolCallId: "t1",
152
+ toolName: "manage_todo_list",
153
+ isError: true,
154
+ result: {
155
+ content: [{ type: "text", text: "Aborted" }],
156
+ details: {},
157
+ },
158
+ },
159
+ ],
160
+ { agent: "t-rej2" },
161
+ );
162
+ off();
163
+
164
+ expect(updates).toHaveLength(0);
165
+ });
166
+
167
+ it("falls back to normalized raw args when the accepted result has no details", async () => {
168
+ const updates: Array<{ source: string; todos: unknown[] }> = [];
169
+ const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
170
+ updates.push(p as (typeof updates)[number]),
171
+ );
172
+
173
+ await finishWith(
174
+ [
175
+ start,
176
+ {
177
+ type: "tool_execution_end",
178
+ toolCallId: "t1",
179
+ toolName: "manage_todo_list",
180
+ isError: false,
181
+ result: { content: [{ type: "text", text: "ok" }] },
182
+ },
183
+ ],
184
+ { agent: "t-nodetails" },
185
+ );
186
+ off();
187
+
188
+ expect(updates).toHaveLength(1);
189
+ expect(updates[0]!.source).toBe("subagent:t-nodetails");
190
+ expect(updates[0]!.todos).toEqual([
191
+ { content: "Fix auth", status: "pending" },
192
+ ]);
193
+ });
194
+
195
+ it("does not crash or emit when the end event has no matching start", async () => {
196
+ const updates: Array<{ source: string; todos: unknown[] }> = [];
197
+ const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
198
+ updates.push(p as (typeof updates)[number]),
199
+ );
200
+
201
+ const result = await finishWith(
202
+ [
203
+ {
204
+ type: "tool_execution_end",
205
+ toolCallId: "orphan",
206
+ toolName: "manage_todo_list",
207
+ isError: false,
208
+ result: { content: [{ type: "text", text: "ok" }] },
209
+ },
210
+ ],
211
+ { agent: "t-orphan" },
212
+ );
213
+ off();
214
+
215
+ expect(result.exitCode).toBe(0);
216
+ expect(updates).toHaveLength(0);
217
+ });
218
+
219
+ it("does not emit for non-todo tools with todoList-shaped args", async () => {
220
+ const updates: Array<{ source: string; todos: unknown[] }> = [];
221
+ const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
222
+ updates.push(p as (typeof updates)[number]),
223
+ );
224
+
225
+ await finishWith(
226
+ [
227
+ {
228
+ type: "tool_execution_start",
229
+ toolCallId: "b1",
230
+ toolName: "bash",
231
+ args: {
232
+ operation: "write",
233
+ todoList: [{ content: "Fix auth", status: "pending" }],
234
+ },
235
+ },
236
+ {
237
+ type: "tool_execution_end",
238
+ toolCallId: "b1",
239
+ toolName: "bash",
240
+ isError: false,
241
+ result: {
242
+ content: [{ type: "text", text: "ok" }],
243
+ details: {
244
+ operation: "write",
245
+ todos: [{ content: "Fix auth", status: "pending" }],
246
+ },
247
+ },
248
+ },
249
+ ],
250
+ { agent: "t-bash" },
251
+ );
252
+ off();
253
+
254
+ expect(updates).toHaveLength(0);
255
+ });
256
+
257
+ it("uses a unique per-child source when the child provides a session id", async () => {
258
+ const updates: Array<{ source: string; todos: unknown[] }> = [];
259
+ const clears: Array<{ source: string }> = [];
260
+ const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
261
+ updates.push(p as (typeof updates)[number]),
262
+ );
263
+ const offClear = getBus().on(Events.TODOS_CLEAR, (p: unknown) =>
264
+ clears.push(p as { source: string }),
265
+ );
266
+
267
+ const agent = "t-unique";
268
+ const idA = "11111111-1111-7111-8111-111111111111";
269
+ const idB = "22222222-2222-7222-8222-222222222222";
270
+
271
+ const accepted = {
272
+ type: "tool_execution_end",
273
+ toolCallId: "t1",
274
+ toolName: "manage_todo_list",
275
+ isError: false,
276
+ result: {
277
+ content: [{ type: "text", text: "ok" }],
278
+ details: {
279
+ operation: "write",
280
+ todos: [{ content: "Fix auth", status: "pending" }],
281
+ },
282
+ },
283
+ } as unknown as Record<string, unknown>;
284
+
285
+ // Two concurrent children, same agent, distinct session ids.
286
+ await finishWith(
287
+ [{ type: "session", id: idA }, start, accepted],
288
+ { agent },
289
+ );
290
+ await finishWith(
291
+ [{ type: "session", id: idB }, start, accepted],
292
+ { agent },
293
+ );
294
+ off();
295
+ offClear();
296
+
297
+ const sources = updates.map((u) => u.source);
298
+ // Each child gets its own suffixed source equal to subagent:<agent>:<uuid>.
299
+ expect(sources).toContain(`subagent:${agent}:${idA}`);
300
+ expect(sources).toContain(`subagent:${agent}:${idB}`);
301
+ // The two concurrent children must NOT share a single source.
302
+ expect(sources.filter((s) => s.startsWith(`subagent:${agent}:`))).toEqual([
303
+ `subagent:${agent}:${idA}`,
304
+ `subagent:${agent}:${idB}`,
305
+ ]);
306
+ // The clear on close carries the same suffixed source for each child.
307
+ expect(clears.filter((c) => c.source === `subagent:${agent}:${idA}`)).toHaveLength(1);
308
+ expect(clears.filter((c) => c.source === `subagent:${agent}:${idB}`)).toHaveLength(1);
309
+ });
310
+ });
311
+
package/src/stream.ts CHANGED
@@ -2,6 +2,7 @@ import { createInterface } from "node:readline";
2
2
  import type { ChildProcess } from "node:child_process";
3
3
  import type { StreamState, SubagentProgress, SubagentResult } from "./types.js";
4
4
  import { getBus, Events } from "@pi-archimedes/core/bus";
5
+ import { normalizeTodoItems } from "@pi-archimedes/todo/prepare-args";
5
6
  import {
6
7
  type JsonEvent,
7
8
  handleToolStart,
@@ -111,6 +112,12 @@ export function streamEvents(
111
112
 
112
113
  const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
113
114
 
115
+ // toolCallId → raw todoList from tool_execution_start, consumed by the
116
+ // matching tool_execution_end. pi emits tool_execution_start with the
117
+ // raw (pre-prepareToolCall) arguments, so these may be unrepaired.
118
+ const pendingTodoArgs = new Map<string, unknown[]>();
119
+ let subagentSource = `subagent:${callbacks.agent ?? "general"}`;
120
+
114
121
  rl.on("line", (line) => {
115
122
  const trimmed = line.trim();
116
123
  if (!trimmed) return;
@@ -130,26 +137,46 @@ export function streamEvents(
130
137
  case "session": {
131
138
  if (typeof event.id === "string" && event.id) {
132
139
  state.childSessionId = event.id;
140
+ // A child with a session id gets a unique per-child todo source so
141
+ // concurrent children sharing an agent name don't clobber each
142
+ // other's accepted state when one clears on exit. Children without
143
+ // a session id (e.g. tests) keep the legacy subagent:<agent> form.
144
+ subagentSource = `subagent:${callbacks.agent ?? "general"}:${event.id}`;
133
145
  }
134
146
  break;
135
147
  }
136
148
  case "tool_execution_start": {
137
149
  handleToolStart(state, event);
138
150
  emitProgress();
139
- // Forward manage_todo_list writes to the parent's todo widget
140
- if (event.toolName === "manage_todo_list") {
151
+ if (event.toolName === "manage_todo_list" && typeof event.toolCallId === "string") {
141
152
  const args = event.args as Record<string, unknown> | undefined;
142
- const todoList = args?.todoList as Array<unknown> | undefined;
153
+ const todoList = args?.todoList;
143
154
  if (Array.isArray(todoList)) {
144
- getBus().emit(Events.TODOS_UPDATE, {
145
- source: `subagent:${callbacks.agent ?? "general"}`,
146
- todos: todoList,
147
- });
155
+ pendingTodoArgs.set(event.toolCallId, todoList);
148
156
  }
149
157
  }
150
158
  break;
151
159
  }
152
160
  case "tool_execution_end": {
161
+ if (event.toolName === "manage_todo_list") {
162
+ const id = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
163
+ const stowed = id ? pendingTodoArgs.get(id) : undefined;
164
+ if (id) pendingTodoArgs.delete(id);
165
+ const result = event.result as Record<string, unknown> | undefined;
166
+ // Tool-level failure (todo's execute() RETURNS {isError:true} on validation
167
+ // rejection — it does not throw) arrives as event.isError=false with
168
+ // result.isError=true. Harness failures (abort, not-found, block, throw)
169
+ // arrive as event.isError=true. Check BOTH — the same convention
170
+ // handleToolResult in handlers.ts already uses.
171
+ const failed = event.isError === true || result?.isError === true;
172
+ if (!failed) {
173
+ const detailsTodos = (result?.details as Record<string, unknown> | undefined)?.todos;
174
+ const todos = normalizeTodoItems(Array.isArray(detailsTodos) ? detailsTodos : stowed);
175
+ if (todos) {
176
+ getBus().emit(Events.TODOS_UPDATE, { source: subagentSource, todos });
177
+ }
178
+ }
179
+ }
153
180
  handleToolEnd(state);
154
181
  emitProgress();
155
182
  handleToolResult(state, event);
@@ -218,7 +245,7 @@ export function streamEvents(
218
245
 
219
246
  // Clear subagent todos from the bus on exit
220
247
  getBus().emit(Events.TODOS_CLEAR, {
221
- source: `subagent:${callbacks.agent ?? "general"}`,
248
+ source: subagentSource,
222
249
  });
223
250
 
224
251
  resolve(result);