pi-agenticoding 0.1.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.
@@ -0,0 +1,2079 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { registerHandoffCommand } from "./handoff/command.js";
5
+ import { registerHandoffTool } from "./handoff/tool.js";
6
+ import { registerHandoffCompaction } from "./handoff/compact.js";
7
+ import { registerWatchdog } from "./watchdog.js";
8
+ import { createState, resetState } from "./state.js";
9
+ import {
10
+ buildChildToolNames,
11
+ createChildTools,
12
+ executeSpawn,
13
+ registerSpawnTool,
14
+ } from "./spawn/index.js";
15
+ import { renderSpawnResult } from "./spawn/renderer.js";
16
+ import { registerLedgerRehydration } from "./ledger/rehydration.js";
17
+ import { createLedgerToolDefinitions } from "./ledger/tools.js";
18
+ import registerAgenticoding from "./index.js";
19
+
20
+ type Handler = (args: any, ctx: any) => any;
21
+
22
+ const theme = {
23
+ fg: (_name: string, text: string) => text,
24
+ bold: (text: string) => text,
25
+ } as unknown as Theme;
26
+
27
+ function createRenderContext(overrides: Record<string, unknown> = {}): Record<string, unknown> {
28
+ return {
29
+ expanded: false,
30
+ showImages: true,
31
+ toolCallId: "tool-call-1",
32
+ lastComponent: undefined,
33
+ invalidate: () => {},
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function createSession(messages: any[]) {
39
+ return {
40
+ messages,
41
+ subscribe: () => () => {},
42
+ getToolDefinition: () => undefined,
43
+ sessionManager: { getCwd: () => process.cwd() },
44
+ abort: async () => {},
45
+ } as unknown as import("@earendil-works/pi-coding-agent").AgentSession;
46
+ }
47
+
48
+ function stripAnsi(text: string): string {
49
+ return text.replace(/\u001b\[[0-9;]*m/g, "").replace(/\u001b\][^\u0007]*\u0007/g, "");
50
+ }
51
+
52
+ class MockPi {
53
+ commands = new Map<string, { description?: string; handler: Handler }>();
54
+ tools = new Map<string, any>();
55
+ handlers = new Map<string, Handler[]>();
56
+ activeTools: string[] = [];
57
+ toolSources = new Map<string, string>();
58
+ sentUserMessages: Array<{ content: string; options: any }> = [];
59
+ appendedEntries: Array<{ customType: string; data: any }> = [];
60
+
61
+ registerCommand(name: string, definition: { description?: string; handler: Handler }) {
62
+ this.commands.set(name, definition);
63
+ }
64
+
65
+ registerTool(definition: any) {
66
+ this.tools.set(definition.name, definition);
67
+ }
68
+
69
+ on(event: string, handler: Handler) {
70
+ const handlers = this.handlers.get(event) ?? [];
71
+ handlers.push(handler);
72
+ this.handlers.set(event, handlers);
73
+ }
74
+
75
+ getActiveTools() {
76
+ return [...this.activeTools];
77
+ }
78
+
79
+ setActiveTools(tools: string[]) {
80
+ this.activeTools = [...tools];
81
+ for (const tool of tools) {
82
+ if (!this.toolSources.has(tool)) {
83
+ this.toolSources.set(tool, "builtin");
84
+ }
85
+ }
86
+ }
87
+
88
+ setToolSource(name: string, source: string) {
89
+ this.toolSources.set(name, source);
90
+ }
91
+
92
+ getAllTools() {
93
+ return this.activeTools.map((name) => ({
94
+ name,
95
+ description: "",
96
+ parameters: {},
97
+ sourceInfo: {
98
+ path: `<${this.toolSources.get(name) ?? "builtin"}:${name}>`,
99
+ source: this.toolSources.get(name) ?? "builtin",
100
+ scope: "temporary",
101
+ origin: "top-level",
102
+ },
103
+ }));
104
+ }
105
+
106
+ getThinkingLevel() {
107
+ return "medium";
108
+ }
109
+
110
+ sendUserMessage(content: string, options?: any) {
111
+ this.sentUserMessages.push({ content, options });
112
+ }
113
+
114
+ appendEntry(customType: string, data: any) {
115
+ this.appendedEntries.push({ customType, data });
116
+ }
117
+ }
118
+
119
+ test("/handoff sends the direction back through the LLM without opening the editor", async () => {
120
+ const pi = new MockPi();
121
+ const state = createState();
122
+ registerHandoffCommand(pi as any, state);
123
+
124
+ await pi.commands.get("handoff")!.handler("implement auth", {
125
+ hasUI: true,
126
+ isIdle: () => true,
127
+ ui: { notify: (_message: string) => {} },
128
+ });
129
+
130
+ assert.deepEqual(state.pendingRequestedHandoff, {
131
+ direction: "implement auth",
132
+ enforcementAttempts: 0,
133
+ toolCalled: false,
134
+ });
135
+ assert.deepEqual(pi.sentUserMessages, [
136
+ {
137
+ content:
138
+ "Handoff direction: implement auth\n\nPrepare a real handoff in the current session and current context. Before calling the handoff tool, capture any reusable state in the ledger if needed. Then complete the picture in a concise but sufficiently detailed handoff brief and call the handoff tool in this turn. Preserve the important knowledge that is still only present in the current context so the next clean context can start well without re-deriving it. Use any structure that makes the next work unambiguous. Include findings, current state, unresolved questions, failed paths worth avoiding, next steps, refs, constraints, and spawn ideas when useful. Reference ledger entries by name when relevant.",
139
+ options: undefined,
140
+ },
141
+ ]);
142
+ });
143
+
144
+ test("/handoff requires a direction", async () => {
145
+ const pi = new MockPi();
146
+ const state = createState();
147
+ registerHandoffCommand(pi as any, state);
148
+
149
+ const notifications: string[] = [];
150
+ await pi.commands.get("handoff")!.handler(" ", {
151
+ hasUI: true,
152
+ isIdle: () => true,
153
+ ui: { notify: (message: string) => notifications.push(message) },
154
+ });
155
+
156
+ assert.deepEqual(notifications, ["Usage: /handoff <direction>"]);
157
+ assert.deepEqual(pi.sentUserMessages, []);
158
+ });
159
+
160
+ test("handoff tool triggers compaction and resumes with the compacted task", async () => {
161
+ const pi = new MockPi();
162
+ const state = createState();
163
+ state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false };
164
+ registerHandoffTool(pi as any, state);
165
+
166
+ let compactOptions: any;
167
+ const result = await pi.tools.get("handoff").execute(
168
+ "1",
169
+ { task: "Goal: continue" },
170
+ undefined,
171
+ undefined,
172
+ {
173
+ compact: (options: any) => {
174
+ compactOptions = options;
175
+ },
176
+ },
177
+ );
178
+
179
+ assert.equal(state.pendingHandoff?.source, "tool");
180
+ assert.match(state.pendingHandoff?.task ?? "", /## Handoff — Continue Previous Work/);
181
+ assert.match(state.pendingHandoff?.task ?? "", /Goal: continue/);
182
+ assert.equal(state.pendingRequestedHandoff?.toolCalled, true);
183
+ assert.equal(typeof compactOptions?.onComplete, "function");
184
+ assert.equal(result.content[0].text, "Handoff started.");
185
+ assert.equal(result.terminate, true);
186
+
187
+ compactOptions.onComplete({});
188
+ assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]);
189
+ });
190
+
191
+ test("handoff compaction replaces old context with the queued task", async () => {
192
+ const pi = new MockPi();
193
+ const state = createState();
194
+ state.pendingHandoff = { task: "Goal: continue", source: "tool" };
195
+ state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true };
196
+ registerHandoffCompaction(pi as any, state);
197
+
198
+ const [handler] = pi.handlers.get("session_before_compact")!;
199
+ const result = await handler(
200
+ {
201
+ preparation: { tokensBefore: 123 },
202
+ branchEntries: [{ id: "leaf-1" }],
203
+ },
204
+ {},
205
+ );
206
+
207
+ assert.equal(state.pendingHandoff, null);
208
+ assert.equal(state.pendingRequestedHandoff, null);
209
+ assert.equal(result.compaction.summary, "Goal: continue");
210
+ assert.equal(result.compaction.tokensBefore, 123);
211
+ assert.equal(result.compaction.firstKeptEntryId, "leaf-1-handoff-cut");
212
+ assert.deepEqual(result.compaction.details, { handoff: true, task: "Goal: continue" });
213
+ });
214
+
215
+ test("watchdog records context usage without user notifications", async () => {
216
+ const pi = new MockPi();
217
+ const state = createState();
218
+ registerWatchdog(pi as any, state);
219
+ const [handler] = pi.handlers.get("agent_end")!;
220
+
221
+ const notifications: string[] = [];
222
+ await handler(
223
+ {},
224
+ {
225
+ hasUI: true,
226
+ ui: { notify: (message: string) => notifications.push(message) },
227
+ getContextUsage: () => ({ percent: 70 }),
228
+ },
229
+ );
230
+
231
+ assert.equal(state.lastContextPercent, 70);
232
+ assert.deepEqual(notifications, []);
233
+ });
234
+
235
+ test("context injects watchdog reminder before each LLM call", async () => {
236
+ const pi = new MockPi();
237
+ registerAgenticoding(pi as any);
238
+ const [handler] = pi.handlers.get("context")!;
239
+
240
+ const result = await handler(
241
+ { messages: [{ role: "user", content: "hi", timestamp: 1 }] },
242
+ {
243
+ getContextUsage: () => ({ percent: 70 }),
244
+ },
245
+ );
246
+
247
+ assert.equal(result.messages.length, 2);
248
+ assert.deepEqual(result.messages[0], { role: "user", content: "hi", timestamp: 1 });
249
+ assert.equal(result.messages[1].role, "custom");
250
+ assert.equal(result.messages[1].customType, "agenticoding-watchdog");
251
+ assert.equal(result.messages[1].display, false);
252
+ assert.match(result.messages[1].content, /Context at 70%/);
253
+ });
254
+
255
+ test("watchdog stays advisory when a requested handoff is not completed", async () => {
256
+ const pi = new MockPi();
257
+ const state = createState();
258
+ state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false };
259
+ registerWatchdog(pi as any, state);
260
+ const [handler] = pi.handlers.get("agent_end")!;
261
+
262
+ const notifications: string[] = [];
263
+ await handler(
264
+ {},
265
+ {
266
+ hasUI: true,
267
+ ui: { notify: (message: string) => notifications.push(message) },
268
+ getContextUsage: () => ({ percent: 20 }),
269
+ },
270
+ );
271
+
272
+ assert.equal(state.pendingRequestedHandoff, null);
273
+ assert.deepEqual(notifications, []);
274
+ assert.deepEqual(pi.sentUserMessages, []);
275
+ });
276
+
277
+ test("collapsed nested spawn render shows preview and stats", () => {
278
+ const state = createState();
279
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
280
+ const session = createSession([
281
+ { role: "assistant", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix\nseven" }] },
282
+ ]);
283
+ state.childSessions.set("tool-call-1", session);
284
+
285
+ const component = childSpawnTool.renderResult(
286
+ {
287
+ content: [{ type: "text", text: "ignored" }],
288
+ details: {
289
+ depth: 1,
290
+ model: "mock-model",
291
+ thinking: "medium",
292
+ truncated: true,
293
+ stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 },
294
+ },
295
+ },
296
+ { expanded: false },
297
+ theme,
298
+ createRenderContext(),
299
+ ) as any;
300
+
301
+ const lines = component.render(120);
302
+ assert.ok(lines.some((l: string) => l.includes("mock-model • medium")));
303
+ assert.ok(lines.some((l: string) => l.includes("one")));
304
+ assert.ok(lines.some((l: string) => l.includes("five")));
305
+ assert.ok(lines.some((l: string) => l.includes("... 2 more lines")));
306
+ assert.ok(lines.some((l: string) => l.includes("tokens: 12/34")));
307
+ assert.ok(lines.some((l: string) => l.includes("[truncated]")));
308
+ });
309
+
310
+ test("collapsed nested spawn render keeps all text blocks from the last assistant message", () => {
311
+ const state = createState();
312
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
313
+ const session = createSession([
314
+ { role: "assistant", content: [{ type: "text", text: "first" }, { type: "text", text: "second" }] },
315
+ ]);
316
+ state.childSessions.set("tool-call-1", session);
317
+
318
+ const component = childSpawnTool.renderResult(
319
+ {
320
+ content: [{ type: "text", text: "ignored" }],
321
+ details: { depth: 1, model: "mock-model", thinking: "medium", truncated: false },
322
+ },
323
+ { expanded: false },
324
+ theme,
325
+ createRenderContext(),
326
+ ) as any;
327
+
328
+ const lines = component.render(120);
329
+ assert.ok(lines.some((l: string) => l.includes("first")));
330
+ assert.ok(lines.some((l: string) => l.includes("second")));
331
+ });
332
+
333
+ test("nested spawn render is safe without details", () => {
334
+ const state = createState();
335
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
336
+ const session = createSession([
337
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
338
+ ]);
339
+ state.childSessions.set("tool-call-1", session);
340
+
341
+ const component = childSpawnTool.renderResult(
342
+ { content: [{ type: "text", text: "ignored" }] },
343
+ { expanded: false },
344
+ theme,
345
+ createRenderContext(),
346
+ ) as any;
347
+
348
+ const lines = component.render(120);
349
+ assert.ok(lines.some((l: string) => l.includes("hello")));
350
+ });
351
+
352
+ test("expanded nested spawn header stays within width after indent", () => {
353
+ const state = createState();
354
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
355
+ const session = createSession([
356
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
357
+ ]);
358
+ state.childSessions.set("tool-call-1", session);
359
+
360
+ const component = childSpawnTool.renderResult(
361
+ {
362
+ content: [{ type: "text", text: "ignored" }],
363
+ details: { depth: 2, model: "model-name", thinking: "medium", truncated: false },
364
+ },
365
+ { expanded: true },
366
+ theme,
367
+ createRenderContext({ expanded: true }),
368
+ ) as any;
369
+
370
+ const lines = component.render(24);
371
+ assert.ok(lines[0].startsWith(" "));
372
+ assert.ok(stripAnsi(lines[0]).length <= 24);
373
+ });
374
+
375
+ test("nested spawn clears cached render when showImages changes", () => {
376
+ const state = createState();
377
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
378
+ const session = createSession([
379
+ { role: "assistant", content: [{ type: "text", text: "hello" }, { type: "image", data: "iVBOR", mimeType: "image/png" }] },
380
+ ]);
381
+ state.childSessions.set("tool-call-1", session);
382
+
383
+ const component = childSpawnTool.renderResult(
384
+ {
385
+ content: [{ type: "text", text: "ignored" }],
386
+ details: { depth: 1, model: "mock-model", thinking: "medium", truncated: false },
387
+ },
388
+ { expanded: true },
389
+ theme,
390
+ createRenderContext({ expanded: true, showImages: true }),
391
+ ) as any;
392
+ const linesWithImages = component.render(120);
393
+
394
+ const sameComponent = childSpawnTool.renderResult(
395
+ {
396
+ content: [{ type: "text", text: "ignored" }],
397
+ details: { depth: 1, model: "mock-model", thinking: "medium", truncated: false },
398
+ },
399
+ { expanded: true },
400
+ theme,
401
+ createRenderContext({ expanded: true, showImages: false, lastComponent: component }),
402
+ ) as any;
403
+ const linesWithoutImages = sameComponent.render(120);
404
+
405
+ assert.equal(sameComponent, component);
406
+ assert.notEqual(linesWithImages, linesWithoutImages);
407
+ });
408
+
409
+ test("nested spawn rerenders when stats become unavailable", () => {
410
+ const state = createState();
411
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
412
+ const session = createSession([
413
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
414
+ ]);
415
+ state.childSessions.set("tool-call-1", session);
416
+
417
+ const component = childSpawnTool.renderResult(
418
+ {
419
+ content: [{ type: "text", text: "ignored" }],
420
+ details: { depth: 1, model: "mock-model", thinking: "medium", truncated: false },
421
+ },
422
+ { expanded: false },
423
+ theme,
424
+ createRenderContext(),
425
+ ) as any;
426
+ const before = component.render(120);
427
+ assert.equal(before.some((l: string) => l.includes("stats unavailable")), false);
428
+
429
+ const sameComponent = childSpawnTool.renderResult(
430
+ {
431
+ content: [{ type: "text", text: "ignored" }],
432
+ details: { depth: 1, model: "mock-model", thinking: "medium", truncated: false, outcome: "success", statsUnavailable: true },
433
+ },
434
+ { expanded: false },
435
+ theme,
436
+ createRenderContext({ lastComponent: component }),
437
+ ) as any;
438
+ const after = sameComponent.render(120);
439
+
440
+ assert.equal(sameComponent, component);
441
+ assert.ok(after.some((l: string) => l.includes("stats unavailable")));
442
+ assert.equal(after.some((l: string) => l.includes("initializing")), false);
443
+ });
444
+
445
+ test("spawn execute propagates only executable parent tools to child session", async () => {
446
+ const pi = new MockPi();
447
+ pi.setActiveTools(["read", "bash", "spawn", "handoff", "future_tool"]);
448
+ pi.setToolSource("future_tool", "project");
449
+ const state = createState();
450
+
451
+ let seenConfig: any;
452
+ const mockFactory = async (config: any) => {
453
+ seenConfig = config;
454
+ const session = {
455
+ messages: [] as any[],
456
+ prompt: async () => {
457
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }];
458
+ },
459
+ abort: async () => {},
460
+ getSessionStats: () => undefined,
461
+ };
462
+ return { session: session as any };
463
+ };
464
+
465
+ registerSpawnTool(pi as any, state, mockFactory as any);
466
+
467
+ await pi.tools.get("spawn").execute(
468
+ "spawn-1",
469
+ { prompt: "Do the task", thinking: "high" },
470
+ undefined,
471
+ undefined,
472
+ { model: { id: "mock-model" }, cwd: "/tmp" },
473
+ );
474
+
475
+ assert.equal(seenConfig.model.id, "mock-model");
476
+ assert.equal(seenConfig.thinkingLevel, "high");
477
+ assert.equal(seenConfig.cwd, "/tmp");
478
+ assert.equal(seenConfig.tools.includes("read"), true);
479
+ assert.equal(seenConfig.tools.includes("bash"), true);
480
+ assert.equal(seenConfig.tools.includes("future_tool"), false);
481
+ assert.equal(seenConfig.tools.includes("handoff"), false);
482
+ assert.equal(seenConfig.tools.includes("spawn"), false);
483
+ });
484
+
485
+ test("spawn execute builds prompt with ledger and task", async () => {
486
+ const pi = new MockPi();
487
+ pi.setActiveTools(["read", "bash", "spawn"]);
488
+ const state = createState();
489
+ state.ledger.set("entry-a", "preview line\nfull body");
490
+
491
+ let seenPrompt = "";
492
+ const mockFactory = async (config: any) => {
493
+ const session = {
494
+ messages: [] as any[],
495
+ prompt: async (prompt: string) => {
496
+ seenPrompt = prompt;
497
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }];
498
+ },
499
+ abort: async () => {},
500
+ getSessionStats: () => undefined,
501
+ };
502
+ return { session: session as any };
503
+ };
504
+
505
+ registerSpawnTool(pi as any, state, mockFactory as any);
506
+
507
+ await pi.tools.get("spawn").execute(
508
+ "spawn-1",
509
+ { prompt: "Do the task" },
510
+ undefined,
511
+ undefined,
512
+ { model: { id: "mock-model" }, cwd: "/tmp" },
513
+ );
514
+
515
+ // Verify user-facing invariants: task text is included, ledger entries are referenced
516
+ assert.match(seenPrompt, /Do the task/);
517
+ assert.match(seenPrompt, /entry-a: preview line/);
518
+ });
519
+
520
+ test("spawn renderResult falls back to static text when no live session is stored", () => {
521
+ const state = createState();
522
+ const pi = new MockPi();
523
+ registerSpawnTool(pi as any, state);
524
+
525
+ const result = pi.tools.get("spawn").renderResult(
526
+ {
527
+ content: [{ type: "text", text: "fallback output" }],
528
+ details: { depth: 1, model: "m", thinking: "low", truncated: false },
529
+ },
530
+ { expanded: false },
531
+ theme,
532
+ createRenderContext(),
533
+ ) as any;
534
+
535
+ const lines = result.render(120);
536
+ assert.ok(lines.some((l: string) => l.includes("m • low")));
537
+ assert.ok(lines.some((l: string) => l.includes("fallback output")));
538
+ });
539
+
540
+ test("spawn renderResult distinguishes aborted and error outcomes", () => {
541
+ const state = createState();
542
+ const pi = new MockPi();
543
+ registerSpawnTool(pi as any, state);
544
+
545
+ const aborted = pi.tools.get("spawn").renderResult(
546
+ {
547
+ content: [{ type: "text", text: "stopped" }],
548
+ details: { depth: 1, model: "m", thinking: "low", truncated: false, outcome: "aborted" },
549
+ },
550
+ { expanded: false },
551
+ theme,
552
+ createRenderContext(),
553
+ ) as any;
554
+ const error = pi.tools.get("spawn").renderResult(
555
+ {
556
+ content: [{ type: "text", text: "failed" }],
557
+ details: { depth: 1, model: "m", thinking: "low", truncated: false, outcome: "error" },
558
+ },
559
+ { expanded: false },
560
+ theme,
561
+ createRenderContext(),
562
+ ) as any;
563
+
564
+ const abortedLines = aborted.render(120);
565
+ const errorLines = error.render(120);
566
+ assert.ok(abortedLines.some((l: string) => l.includes("✗ m • low")));
567
+ assert.ok(abortedLines.some((l: string) => l.includes("aborted")));
568
+ assert.ok(errorLines.some((l: string) => l.includes("⚠ m • low")));
569
+ assert.ok(errorLines.some((l: string) => l.includes("error")));
570
+ });
571
+
572
+ test("spawn execute returns result and stats", async () => {
573
+ const pi = new MockPi();
574
+ pi.setActiveTools(["read", "bash", "spawn"]);
575
+ const state = createState();
576
+
577
+ const updates: any[] = [];
578
+ const mockFactory = async () => {
579
+ const session = {
580
+ messages: [] as any[],
581
+ prompt: async () => {
582
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }];
583
+ },
584
+ abort: async () => {},
585
+ getSessionStats: () => ({
586
+ tokens: { input: 11, output: 22, cacheRead: 3, cacheWrite: 4, total: 40 },
587
+ cost: 0.5,
588
+ assistantMessages: 2,
589
+ }),
590
+ };
591
+ return { session: session as any };
592
+ };
593
+
594
+ registerSpawnTool(pi as any, state, mockFactory as any);
595
+
596
+ const result = await pi.tools.get("spawn").execute(
597
+ "spawn-1",
598
+ { prompt: "Do the task", thinking: "high" },
599
+ undefined,
600
+ (update: any) => updates.push(update),
601
+ { model: { id: "mock-model" }, cwd: "/tmp" },
602
+ );
603
+
604
+ assert.deepEqual(updates, [{
605
+ content: [],
606
+ details: { depth: 1, model: "mock-model", thinking: "high", truncated: false, outcome: "running" },
607
+ }]);
608
+ assert.equal(result.content[0].text, "child result");
609
+ assert.equal(result.details.outcome, "success");
610
+ assert.deepEqual(result.details.stats, {
611
+ inputTokens: 11,
612
+ outputTokens: 22,
613
+ cacheReadTokens: 3,
614
+ cacheWriteTokens: 4,
615
+ totalTokens: 40,
616
+ cost: 0.5,
617
+ turns: 2,
618
+ });
619
+ });
620
+
621
+ test("spawn execute marks stats unavailable when stats collection throws", async () => {
622
+ const pi = new MockPi();
623
+ pi.setActiveTools(["read", "bash", "spawn"]);
624
+ const state = createState();
625
+
626
+ const warnings: any[] = [];
627
+ const originalWarn = console.warn;
628
+ console.warn = (...args: any[]) => {
629
+ warnings.push(args);
630
+ };
631
+
632
+ try {
633
+ const mockFactory = async () => {
634
+ const session = {
635
+ messages: [] as any[],
636
+ prompt: async () => {
637
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }];
638
+ },
639
+ abort: async () => {},
640
+ getSessionStats: () => {
641
+ throw new Error("stats failed");
642
+ },
643
+ };
644
+ return { session: session as any };
645
+ };
646
+
647
+ registerSpawnTool(pi as any, state, mockFactory as any);
648
+ const result = await pi.tools.get("spawn").execute(
649
+ "spawn-1",
650
+ { prompt: "Do the task" },
651
+ undefined,
652
+ undefined,
653
+ { model: { id: "mock-model" }, cwd: "/tmp" },
654
+ );
655
+
656
+ assert.equal(result.details.stats, undefined);
657
+ assert.equal(result.details.statsUnavailable, true);
658
+ assert.equal(warnings.length, 1);
659
+ assert.match(String(warnings[0][1]), /stats failed/);
660
+ assert.equal(warnings[0][2], "spawn-1");
661
+ } finally {
662
+ console.warn = originalWarn;
663
+ }
664
+ });
665
+
666
+ test("spawn execute throws when child produces no output", async () => {
667
+ const pi = new MockPi();
668
+ pi.setActiveTools(["read", "bash", "spawn"]);
669
+ const state = createState();
670
+
671
+ const mockFactory = async () => {
672
+ const session = {
673
+ messages: [] as any[],
674
+ prompt: async () => {},
675
+ abort: async () => {},
676
+ getSessionStats: () => undefined,
677
+ };
678
+ return { session: session as any };
679
+ };
680
+
681
+ registerSpawnTool(pi as any, state, mockFactory as any);
682
+
683
+ await assert.rejects(
684
+ () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }),
685
+ /Child agent produced no output\./,
686
+ );
687
+ });
688
+
689
+ test("spawn execute clears childSessions when prompt throws", async () => {
690
+ const pi = new MockPi();
691
+ pi.setActiveTools(["read", "bash", "spawn"]);
692
+ const state = createState();
693
+
694
+ const mockFactory = async () => {
695
+ const session = {
696
+ messages: [] as any[],
697
+ prompt: async () => {
698
+ throw new Error("prompt failed");
699
+ },
700
+ abort: async () => {},
701
+ getSessionStats: () => undefined,
702
+ };
703
+ return { session: session as any };
704
+ };
705
+
706
+ registerSpawnTool(pi as any, state, mockFactory as any);
707
+
708
+ await assert.rejects(
709
+ () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }),
710
+ /prompt failed/,
711
+ );
712
+ assert.equal(state.childSessions.size, 0);
713
+ });
714
+
715
+ test("spawn execute clears childSessions after successful completion when unrendered", async () => {
716
+ const pi = new MockPi();
717
+ pi.setActiveTools(["read", "bash", "spawn"]);
718
+ const state = createState();
719
+
720
+ const mockFactory = async () => {
721
+ const session = {
722
+ messages: [] as any[],
723
+ prompt: async () => {
724
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }];
725
+ },
726
+ abort: async () => {},
727
+ getSessionStats: () => undefined,
728
+ };
729
+ return { session: session as any };
730
+ };
731
+
732
+ registerSpawnTool(pi as any, state, mockFactory as any);
733
+ const result = await pi.tools.get("spawn").execute(
734
+ "spawn-1",
735
+ { prompt: "Do the task" },
736
+ undefined,
737
+ undefined,
738
+ { model: { id: "mock-model" }, cwd: "/tmp" },
739
+ );
740
+
741
+ assert.equal(result.content[0].text, "child result");
742
+ assert.equal(state.childSessions.size, 0);
743
+ });
744
+
745
+ test("spawn execute fails explicitly without a configured model", async () => {
746
+ const pi = new MockPi();
747
+ const state = createState();
748
+ registerSpawnTool(pi as any, state);
749
+ await assert.rejects(
750
+ () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { cwd: "/tmp" }),
751
+ /No model configured\. Cannot spawn child agent\./,
752
+ );
753
+ });
754
+
755
+ test("child tool set omits spawn and handoff at max depth", () => {
756
+ const state = createState();
757
+ const childTools = createChildTools(new MockPi() as any, state, "medium", 1);
758
+ assert.equal(childTools.some(t => t.name === "spawn"), false);
759
+ const maxDepthToolNames = buildChildToolNames(
760
+ ["read", "bash", "spawn", "handoff", "future_tool"],
761
+ childTools,
762
+ [
763
+ { name: "read", sourceInfo: { source: "builtin" } },
764
+ { name: "bash", sourceInfo: { source: "builtin" } },
765
+ { name: "spawn", sourceInfo: { source: "builtin" } },
766
+ { name: "handoff", sourceInfo: { source: "builtin" } },
767
+ { name: "future_tool", sourceInfo: { source: "project" } },
768
+ ] as any,
769
+ );
770
+ assert.equal(maxDepthToolNames.includes("spawn"), false);
771
+ assert.equal(maxDepthToolNames.includes("handoff"), false);
772
+ assert.equal(maxDepthToolNames.includes("future_tool"), false);
773
+
774
+ const nestedTools = createChildTools(new MockPi() as any, state, "medium", 0);
775
+ assert.ok(nestedTools.some(t => t.name === "spawn"));
776
+ });
777
+
778
+ test("spawn renderResult transfers session ownership out of shared state", () => {
779
+ const state = createState();
780
+ const session = createSession([
781
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
782
+ ]);
783
+ state.childSessions.set("tool-call-1", session);
784
+
785
+ const pi = new MockPi();
786
+ registerSpawnTool(pi as any, state);
787
+
788
+ const component = pi.tools.get("spawn").renderResult(
789
+ { content: [{ type: "text", text: "hello" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
790
+ { expanded: false },
791
+ theme,
792
+ createRenderContext(),
793
+ ) as any;
794
+
795
+ assert.equal(state.childSessions.has("tool-call-1"), false);
796
+ const lines = component.render(120);
797
+ assert.ok(lines.some((l: string) => l.includes("hello")));
798
+ });
799
+
800
+ test("spawn renderResult reuses lastComponent", () => {
801
+ const state = createState();
802
+ const session = createSession([
803
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
804
+ ]);
805
+ state.childSessions.set("tool-call-1", session);
806
+
807
+ const pi = new MockPi();
808
+ registerSpawnTool(pi as any, state);
809
+
810
+ const first = pi.tools.get("spawn").renderResult(
811
+ { content: [{ type: "text", text: "hello" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
812
+ { expanded: false },
813
+ theme,
814
+ createRenderContext(),
815
+ );
816
+ const second = pi.tools.get("spawn").renderResult(
817
+ { content: [{ type: "text", text: "hello" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
818
+ { expanded: false },
819
+ theme,
820
+ createRenderContext({ lastComponent: first }),
821
+ );
822
+ assert.equal(first, second);
823
+ });
824
+
825
+ test("resetState aborts and clears child session registries", () => {
826
+ const state = createState();
827
+ let abortCalls = 0;
828
+ const session = {
829
+ ...createSession([]),
830
+ abort: async () => {
831
+ abortCalls++;
832
+ },
833
+ } as any;
834
+ state.childSessions.set("tool-call-1", session);
835
+ state.liveChildSessions.set("tool-call-1", session);
836
+ resetState(state);
837
+ assert.equal(abortCalls, 1);
838
+ assert.equal(state.childSessions.size, 0);
839
+ assert.equal(state.liveChildSessions.size, 0);
840
+ });
841
+
842
+ test("resetState aborts a claimed child session after render ownership transfer", () => {
843
+ const state = createState();
844
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
845
+ let abortCalls = 0;
846
+ const session = {
847
+ ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]),
848
+ abort: async () => {
849
+ abortCalls++;
850
+ },
851
+ } as any;
852
+ state.childSessions.set("tool-call-1", session);
853
+ state.liveChildSessions.set("tool-call-1", session);
854
+
855
+ childSpawnTool.renderResult(
856
+ { content: [{ type: "text", text: "ignored" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
857
+ { expanded: false },
858
+ theme,
859
+ createRenderContext(),
860
+ );
861
+
862
+ assert.equal(state.childSessions.has("tool-call-1"), false);
863
+ assert.equal(state.liveChildSessions.has("tool-call-1"), true);
864
+
865
+ resetState(state);
866
+
867
+ assert.equal(abortCalls, 1);
868
+ assert.equal(state.childSessions.size, 0);
869
+ assert.equal(state.liveChildSessions.size, 0);
870
+ });
871
+
872
+ test("executeSpawn suppresses stale child sessions after resetState during async setup", async () => {
873
+ const pi = new MockPi();
874
+ pi.setActiveTools(["read", "bash", "spawn"]);
875
+ const state = createState();
876
+
877
+ let resolveFactory!: (value: any) => void;
878
+ const factoryReady = new Promise<any>((resolve) => {
879
+ resolveFactory = resolve;
880
+ });
881
+ let promptCalled = false;
882
+ let abortCalls = 0;
883
+ let onUpdateCalled = false;
884
+ const staleSession = {
885
+ messages: [] as any[],
886
+ prompt: async () => {
887
+ promptCalled = true;
888
+ staleSession.messages = [{ role: "assistant", content: [{ type: "text", text: "stale result" }] }];
889
+ },
890
+ abort: async () => {
891
+ abortCalls++;
892
+ },
893
+ getSessionStats: () => undefined,
894
+ };
895
+
896
+ const executePromise = executeSpawn(
897
+ "spawn-1",
898
+ pi as any,
899
+ { model: { id: "mock-model" }, cwd: "/tmp" } as any,
900
+ state,
901
+ { prompt: "Do the task" },
902
+ undefined,
903
+ () => {
904
+ onUpdateCalled = true;
905
+ },
906
+ "medium",
907
+ 0,
908
+ async () => factoryReady,
909
+ );
910
+
911
+ resetState(state);
912
+ const freshSession = createSession([{ role: "assistant", content: [{ type: "text", text: "fresh result" }] }]);
913
+ state.childSessions.set("spawn-1", freshSession);
914
+ state.liveChildSessions.set("spawn-1", freshSession);
915
+ resolveFactory({ session: staleSession as any });
916
+
917
+ await assert.rejects(() => executePromise, /invalidated by reset/i);
918
+ assert.equal(onUpdateCalled, false);
919
+ assert.equal(promptCalled, false);
920
+ assert.equal(abortCalls, 1);
921
+ assert.equal(state.childSessions.get("spawn-1"), freshSession);
922
+ assert.equal(state.liveChildSessions.get("spawn-1"), freshSession);
923
+ });
924
+
925
+ test("child tool names inherit builtin parent tools, exclude handoff, and keep spawn when depth allows", () => {
926
+ const state = createState();
927
+ const childTools = createChildTools(new MockPi() as any, state, "medium", 0);
928
+ assert.ok(childTools.some(t => t.name === "spawn"), "depth-0 child tool definitions should still expose spawn");
929
+ const toolNames = buildChildToolNames(
930
+ ["read", "bash", "handoff", "future_tool"],
931
+ childTools,
932
+ [
933
+ { name: "read", sourceInfo: { source: "builtin" } },
934
+ { name: "bash", sourceInfo: { source: "builtin" } },
935
+ { name: "handoff", sourceInfo: { source: "builtin" } },
936
+ { name: "future_tool", sourceInfo: { source: "project" } },
937
+ ] as any,
938
+ );
939
+
940
+ assert.ok(toolNames.includes("read"));
941
+ assert.ok(toolNames.includes("bash"));
942
+ assert.equal(toolNames.includes("future_tool"), false);
943
+ assert.ok(toolNames.includes("ledger_add"));
944
+ assert.ok(toolNames.includes("ledger_get"));
945
+ assert.ok(toolNames.includes("ledger_list"));
946
+ assert.equal(toolNames.includes("handoff"), false);
947
+ assert.equal(toolNames.includes("spawn"), true);
948
+ });
949
+
950
+ function createSubscribableSession(messages: any[] = []) {
951
+ let handler: ((event: any) => void) | undefined;
952
+ return {
953
+ session: {
954
+ messages,
955
+ subscribe: (cb: (event: any) => void) => {
956
+ handler = cb;
957
+ return () => { handler = undefined; };
958
+ },
959
+ getToolDefinition: () => undefined,
960
+ sessionManager: { getCwd: () => process.cwd() },
961
+ abort: async () => {},
962
+ } as unknown as import("@earendil-works/pi-coding-agent").AgentSession,
963
+ emit: (event: any) => handler?.(event),
964
+ };
965
+ }
966
+
967
+ test("nested spawn live action tracks tool execution events", () => {
968
+ const state = createState();
969
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
970
+ const { session, emit } = createSubscribableSession([]);
971
+ state.childSessions.set("tool-call-1", session);
972
+
973
+ // Mock console.warn to suppress any expected-but-harmless warnings
974
+ // (e.g., streaming component errors in headless test env).
975
+ const originalWarn = console.warn;
976
+ console.warn = () => {};
977
+
978
+ try {
979
+ const component = childSpawnTool.renderResult(
980
+ { content: [{ type: "text", text: "ignored" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
981
+ { expanded: false },
982
+ theme,
983
+ createRenderContext(),
984
+ ) as any;
985
+
986
+ // message_start → thinking
987
+ emit({ type: "message_start", message: { role: "assistant", content: [] } });
988
+ let lines = component.render(120);
989
+ assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking, got: ${lines.join("\n")}`);
990
+
991
+ // message_update with text → live preview
992
+ emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "writing code now" }] } });
993
+ lines = component.render(120);
994
+ assert.ok(lines.some((l: string) => l.includes("writing code now")), `expected live text preview, got: ${lines.join("\n")}`);
995
+
996
+ // message_end → success marker in identity line
997
+ emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "summary" }], stopReason: "end_turn" } });
998
+ lines = component.render(120);
999
+ assert.ok(lines.some((l: string) => l.includes("✅")), `expected success marker, got: ${lines.join("\n")}`);
1000
+
1001
+ // Tool events degrade gracefully in minimal test env and still update live action
1002
+ emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } });
1003
+ lines = component.render(120);
1004
+ assert.ok(lines.some((l: string) => l.includes("[bash]")), `expected tool live action, got: ${lines.join("\n")}`);
1005
+ } finally {
1006
+ console.warn = originalWarn;
1007
+ }
1008
+ });
1009
+
1010
+ test("nested spawn handleEvent recovers from malformed events", () => {
1011
+ const state = createState();
1012
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1013
+ const { session, emit } = createSubscribableSession([]);
1014
+ state.childSessions.set("tool-call-1", session);
1015
+
1016
+ const warnings: any[] = [];
1017
+ const originalWarn = console.warn;
1018
+ console.warn = (...args: any[]) => warnings.push(args);
1019
+
1020
+ try {
1021
+ const component = childSpawnTool.renderResult(
1022
+ { content: [{ type: "text", text: "ignored" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1023
+ { expanded: false },
1024
+ theme,
1025
+ createRenderContext(),
1026
+ ) as any;
1027
+
1028
+ // Emit a malformed event that will throw inside handleEvent
1029
+ emit({ type: "message_start", message: null });
1030
+ assert.equal(warnings.length, 1);
1031
+ assert.match(String(warnings[0][1]), /message_start/);
1032
+
1033
+ // Subsequent valid events still process
1034
+ emit({ type: "message_start", message: { role: "assistant", content: [] } });
1035
+ const lines = component.render(120);
1036
+ assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking after recovery, got: ${lines.join("\n")}`);
1037
+ } finally {
1038
+ console.warn = originalWarn;
1039
+ }
1040
+ });
1041
+
1042
+ test("nested spawn message_end with aborted stopReason clears pending tools", () => {
1043
+ const state = createState();
1044
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1045
+ const { session, emit } = createSubscribableSession([]);
1046
+ state.childSessions.set("tool-call-1", session);
1047
+
1048
+ const component = childSpawnTool.renderResult(
1049
+ { content: [{ type: "text", text: "ignored" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1050
+ { expanded: false },
1051
+ theme,
1052
+ createRenderContext(),
1053
+ ) as any;
1054
+
1055
+ // Start an assistant message
1056
+ emit({ type: "message_start", message: { role: "assistant", content: [] } });
1057
+ // End it with aborted — sets lastAction to "aborted"
1058
+ emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "partial" }], stopReason: "aborted", errorMessage: "killed" } });
1059
+
1060
+ const lines = component.render(120);
1061
+ assert.ok(lines.some((l: string) => l.includes("aborted")), `expected aborted, got: ${lines.join("\n")}`);
1062
+ });
1063
+
1064
+ test("nested spawn dispose stops event processing", () => {
1065
+ const state = createState();
1066
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1067
+ const { session, emit } = createSubscribableSession([]);
1068
+ state.childSessions.set("tool-call-1", session);
1069
+
1070
+ const component = childSpawnTool.renderResult(
1071
+ { content: [{ type: "text", text: "ignored" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1072
+ { expanded: false },
1073
+ theme,
1074
+ createRenderContext(),
1075
+ ) as any;
1076
+
1077
+ component.dispose();
1078
+
1079
+ // Emit event after dispose — should not update state or crash
1080
+ emit({ type: "message_start", message: { role: "assistant", content: [] } });
1081
+ const after = component.render(120);
1082
+
1083
+ assert.ok(after.every((line: string) => !line.includes("thinking")), `unexpected post-dispose update: ${after.join("\n")}`);
1084
+ });
1085
+
1086
+ test("nested spawn dispose aborts a claimed live child session", () => {
1087
+ const state = createState();
1088
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1089
+ let abortCalls = 0;
1090
+ const session = {
1091
+ ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]),
1092
+ abort: async () => {
1093
+ abortCalls++;
1094
+ },
1095
+ } as any;
1096
+ state.childSessions.set("tool-call-1", session);
1097
+ state.liveChildSessions.set("tool-call-1", session);
1098
+
1099
+ const component = childSpawnTool.renderResult(
1100
+ { content: [{ type: "text", text: "ignored" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1101
+ { expanded: false },
1102
+ theme,
1103
+ createRenderContext(),
1104
+ ) as any;
1105
+
1106
+ assert.equal(state.childSessions.has("tool-call-1"), false);
1107
+ assert.equal(state.liveChildSessions.has("tool-call-1"), true);
1108
+
1109
+ component.dispose();
1110
+
1111
+ assert.equal(abortCalls, 1);
1112
+ assert.equal(state.liveChildSessions.has("tool-call-1"), false);
1113
+ });
1114
+
1115
+ test("spawn execute short-circuits when signal is already aborted", async () => {
1116
+ const pi = new MockPi();
1117
+ pi.setActiveTools(["read", "bash", "spawn"]);
1118
+ const state = createState();
1119
+
1120
+ let abortCalled = false;
1121
+ let promptCalled = false;
1122
+ let onUpdateCalled = false;
1123
+ const mockFactory = async () => {
1124
+ const session = {
1125
+ messages: [] as any[],
1126
+ prompt: async () => {
1127
+ promptCalled = true;
1128
+ },
1129
+ abort: async () => { abortCalled = true; },
1130
+ getSessionStats: () => undefined,
1131
+ };
1132
+ return { session: session as any };
1133
+ };
1134
+
1135
+ registerSpawnTool(pi as any, state, mockFactory as any);
1136
+
1137
+ const controller = new AbortController();
1138
+ controller.abort();
1139
+
1140
+ await assert.rejects(
1141
+ () => pi.tools.get("spawn").execute(
1142
+ "spawn-1",
1143
+ { prompt: "Do the task" },
1144
+ controller.signal,
1145
+ () => { onUpdateCalled = true; },
1146
+ { model: { id: "mock-model" }, cwd: "/tmp" },
1147
+ ),
1148
+ /abort/i,
1149
+ );
1150
+
1151
+ assert.equal(abortCalled, true);
1152
+ assert.equal(promptCalled, false);
1153
+ assert.equal(onUpdateCalled, false);
1154
+ assert.equal(state.childSessions.size, 0);
1155
+ assert.equal(state.liveChildSessions.size, 0);
1156
+ });
1157
+
1158
+ test("spawn execute truncates very long child output", async () => {
1159
+ const pi = new MockPi();
1160
+ pi.setActiveTools(["read", "bash", "spawn"]);
1161
+ const state = createState();
1162
+
1163
+ // Generate > 2000 lines of output
1164
+ const longText = Array.from({ length: 2100 }, (_, i) => `Line ${i + 1}`).join("\n");
1165
+
1166
+ const mockFactory = async () => {
1167
+ const session = {
1168
+ messages: [] as any[],
1169
+ prompt: async () => {
1170
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: longText }] }];
1171
+ },
1172
+ abort: async () => {},
1173
+ getSessionStats: () => undefined,
1174
+ };
1175
+ return { session: session as any };
1176
+ };
1177
+
1178
+ registerSpawnTool(pi as any, state, mockFactory as any);
1179
+
1180
+ const result = await pi.tools.get("spawn").execute(
1181
+ "spawn-1",
1182
+ { prompt: "Generate lots of output" },
1183
+ undefined,
1184
+ undefined,
1185
+ { model: { id: "mock-model" }, cwd: "/tmp" },
1186
+ );
1187
+
1188
+ assert.equal(result.details.truncated, true);
1189
+ assert.ok(result.content[0].text.includes("[Result truncated"));
1190
+ assert.equal(state.liveChildSessions.size, 0);
1191
+ });
1192
+
1193
+ test("spawn execute truncates child output by byte limit", async () => {
1194
+ const pi = new MockPi();
1195
+ pi.setActiveTools(["read", "bash", "spawn"]);
1196
+ const state = createState();
1197
+ const longText = "🙂".repeat(20_000);
1198
+
1199
+ const mockFactory = async () => {
1200
+ const session = {
1201
+ messages: [] as any[],
1202
+ prompt: async () => {
1203
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: longText }] }];
1204
+ },
1205
+ abort: async () => {},
1206
+ getSessionStats: () => undefined,
1207
+ };
1208
+ return { session: session as any };
1209
+ };
1210
+
1211
+ registerSpawnTool(pi as any, state, mockFactory as any);
1212
+
1213
+ const result = await pi.tools.get("spawn").execute(
1214
+ "spawn-1",
1215
+ { prompt: "Generate byte-heavy output" },
1216
+ undefined,
1217
+ undefined,
1218
+ { model: { id: "mock-model" }, cwd: "/tmp" },
1219
+ );
1220
+
1221
+ assert.equal(result.details.truncated, true);
1222
+ assert.ok(result.content[0].text.includes("[Result truncated"));
1223
+ assert.ok(result.content[0].text.length < longText.length);
1224
+ assert.equal(result.content[0].text.includes("\n"), true);
1225
+ });
1226
+
1227
+ test("spawn execute tells children when no ledger entries exist", async () => {
1228
+ const pi = new MockPi();
1229
+ pi.setActiveTools(["read", "bash", "spawn"]);
1230
+ const state = createState();
1231
+ let promptText = "";
1232
+ const mockFactory = async () => {
1233
+ const session = {
1234
+ messages: [] as any[],
1235
+ prompt: async (text: string) => {
1236
+ promptText = text;
1237
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }];
1238
+ },
1239
+ abort: async () => {},
1240
+ getSessionStats: () => undefined,
1241
+ };
1242
+ return { session: session as any };
1243
+ };
1244
+
1245
+ registerSpawnTool(pi as any, state, mockFactory as any);
1246
+
1247
+ await pi.tools.get("spawn").execute(
1248
+ "spawn-1",
1249
+ { prompt: "Do the task" },
1250
+ undefined,
1251
+ undefined,
1252
+ { model: { id: "mock-model" }, cwd: "/tmp" },
1253
+ );
1254
+
1255
+ assert.match(promptText, /No ledger entries\./);
1256
+ assert.doesNotMatch(promptText, /Available ledger entries:/);
1257
+ });
1258
+
1259
+ test("executeSpawn → onUpdate → renderResult chains session ownership", async () => {
1260
+ const pi = new MockPi();
1261
+ pi.setActiveTools(["read", "bash", "spawn"]);
1262
+ const state = createState();
1263
+
1264
+ let onUpdateCalled = false;
1265
+ let renderComponent: any = null;
1266
+ const mockFactory = async () => {
1267
+ const session = {
1268
+ messages: [] as any[],
1269
+ prompt: async () => {
1270
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: "result" }] }];
1271
+ },
1272
+ abort: async () => {},
1273
+ getSessionStats: () => undefined,
1274
+ };
1275
+ return { session: session as any };
1276
+ };
1277
+
1278
+ registerSpawnTool(pi as any, state, mockFactory as any);
1279
+
1280
+ const executePromise = pi.tools.get("spawn").execute(
1281
+ "spawn-1",
1282
+ { prompt: "Do the task" },
1283
+ undefined,
1284
+ (update: any) => {
1285
+ onUpdateCalled = true;
1286
+ // Simulate pi rendering during execution by calling renderResult
1287
+ // with the same toolCallId the execute call is using.
1288
+ renderComponent = pi.tools.get("spawn").renderResult(
1289
+ { content: [], details: update.details },
1290
+ { expanded: false },
1291
+ theme,
1292
+ { toolCallId: "spawn-1", expanded: false, showImages: true, lastComponent: undefined, invalidate: () => {} },
1293
+ );
1294
+ },
1295
+ { model: { id: "mock-model" }, cwd: "/tmp" },
1296
+ );
1297
+
1298
+ const result = await executePromise;
1299
+
1300
+ // onUpdate was called
1301
+ assert.equal(onUpdateCalled, true);
1302
+
1303
+ // renderComponent from onUpdate has a live session attached
1304
+ assert.equal(typeof renderComponent.hasSession, "function");
1305
+ assert.equal(renderComponent.hasSession(), true);
1306
+
1307
+ // Session ownership was transferred out of the render handoff queue
1308
+ assert.equal(state.childSessions.has("spawn-1"), false);
1309
+ assert.equal(state.liveChildSessions.has("spawn-1"), false);
1310
+
1311
+ // Component renders session content
1312
+ const lines = renderComponent.render(120);
1313
+ const text = lines.join(" ");
1314
+ assert.ok(text.includes("result"), `expected result in render, got: ${text}`);
1315
+
1316
+ // Final execute result is also correct
1317
+ assert.equal(result.content[0].text, "result");
1318
+ });
1319
+
1320
+ test("spawn render shows success state when stats are unavailable", () => {
1321
+ const state = createState();
1322
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1323
+ const session = createSession([
1324
+ { role: "assistant", content: [{ type: "text", text: "final summary" }] },
1325
+ ]);
1326
+ state.childSessions.set("tool-call-1", session);
1327
+
1328
+ const component = childSpawnTool.renderResult(
1329
+ {
1330
+ content: [{ type: "text", text: "ignored" }],
1331
+ details: { depth: 1, model: "mock-model", thinking: "medium", truncated: false, outcome: "success", statsUnavailable: true },
1332
+ },
1333
+ { expanded: false },
1334
+ theme,
1335
+ createRenderContext(),
1336
+ ) as any;
1337
+
1338
+ const lines = component.render(120);
1339
+ assert.ok(lines.some((l: string) => l.includes("✅ [depth 1] mock-model • medium")));
1340
+ assert.ok(lines.some((l: string) => l.includes("stats unavailable")));
1341
+ assert.equal(lines.some((l: string) => l.includes("initializing")), false);
1342
+ });
1343
+
1344
+ test("spawn execute aborts child session when signal fires during execution", async () => {
1345
+ const pi = new MockPi();
1346
+ pi.setActiveTools(["read", "bash", "spawn"]);
1347
+ const state = createState();
1348
+
1349
+ let abortCalled = false;
1350
+ let resolvePrompt!: () => void;
1351
+ let promptStarted!: () => void;
1352
+ const started = new Promise<void>((resolve) => { promptStarted = resolve; });
1353
+ const mockFactory = async () => {
1354
+ const session = {
1355
+ messages: [] as any[],
1356
+ prompt: async () => {
1357
+ promptStarted();
1358
+ await new Promise<void>((resolve) => { resolvePrompt = resolve; });
1359
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: "aborted mid-flight" }] }];
1360
+ },
1361
+ abort: async () => {
1362
+ abortCalled = true;
1363
+ resolvePrompt();
1364
+ },
1365
+ getSessionStats: () => undefined,
1366
+ };
1367
+ return { session: session as any };
1368
+ };
1369
+
1370
+ registerSpawnTool(pi as any, state, mockFactory as any);
1371
+
1372
+ const controller = new AbortController();
1373
+ const executePromise = pi.tools.get("spawn").execute(
1374
+ "spawn-1",
1375
+ { prompt: "Do the task" },
1376
+ controller.signal,
1377
+ undefined,
1378
+ { model: { id: "mock-model" }, cwd: "/tmp" },
1379
+ );
1380
+
1381
+ await started;
1382
+ controller.abort();
1383
+
1384
+ const result = await executePromise;
1385
+ assert.equal(abortCalled, true);
1386
+ assert.equal(state.childSessions.size, 0);
1387
+ assert.equal(state.liveChildSessions.size, 0);
1388
+ assert.equal(result.content[0].text, "aborted mid-flight");
1389
+ assert.equal(result.details.outcome, "aborted");
1390
+ });
1391
+
1392
+ test("spawn renderCall shows prompt preview and thinking level", () => {
1393
+ const state = createState();
1394
+ const pi = new MockPi();
1395
+ registerSpawnTool(pi as any, state);
1396
+
1397
+ const tool = pi.tools.get("spawn");
1398
+
1399
+ // Collapsed: short prompt
1400
+ const collapsed = tool.renderCall({ prompt: "Do X" }, theme, { expanded: false });
1401
+ const collapsedLines = collapsed.render(120);
1402
+ assert.ok(collapsedLines.some((l: string) => l.includes("spawn")));
1403
+ assert.ok(collapsedLines.some((l: string) => l.includes("Do X")));
1404
+
1405
+ // Collapsed: long prompt shows truncation hint
1406
+ const longPrompt = Array.from({ length: 10 }, (_, i) => `Line ${i}`).join("\n");
1407
+ const truncated = tool.renderCall({ prompt: longPrompt }, theme, { expanded: false });
1408
+ const truncatedLines = truncated.render(120);
1409
+ assert.ok(truncatedLines.some((l: string) => l.includes("more lines")));
1410
+
1411
+ // With thinking level
1412
+ const withThinking = tool.renderCall({ prompt: "Do X", thinking: "high" }, theme, { expanded: false });
1413
+ const thinkingLines = withThinking.render(120);
1414
+ assert.ok(thinkingLines.some((l: string) => l.includes("high")));
1415
+
1416
+ // Expanded: shows full prompt
1417
+ const expanded = tool.renderCall({ prompt: longPrompt }, theme, { expanded: true });
1418
+ const expandedLines = expanded.render(120);
1419
+ assert.ok(!expandedLines.some((l: string) => l.includes("more lines")));
1420
+ });
1421
+
1422
+ test("spawn execute rejects at max spawn depth", async () => {
1423
+ const pi = new MockPi();
1424
+ pi.setActiveTools(["read", "bash", "spawn"]);
1425
+ const state = createState();
1426
+
1427
+ // At max depth, spawn is excluded from child tools
1428
+ const childTools = createChildTools(pi as any, state, "medium", 1);
1429
+ assert.equal(childTools.some(t => t.name === "spawn"), false);
1430
+
1431
+ // executeSpawn directly called at max depth throws
1432
+ await assert.rejects(
1433
+ executeSpawn(
1434
+ "spawn-1",
1435
+ pi as any,
1436
+ {} as any,
1437
+ state,
1438
+ { prompt: "Do the task" },
1439
+ undefined,
1440
+ undefined,
1441
+ "medium",
1442
+ 1,
1443
+ ),
1444
+ /Max spawn depth/,
1445
+ );
1446
+ });
1447
+
1448
+ test("ledger rehydration rebuilds the latest epoch and enables ledger tools", async () => {
1449
+ const pi = new MockPi();
1450
+ const state = createState();
1451
+ registerLedgerRehydration(pi as any, state);
1452
+ const [handler] = pi.handlers.get("session_start")!;
1453
+
1454
+ await handler(
1455
+ {},
1456
+ {
1457
+ sessionManager: {
1458
+ getBranch: () => [
1459
+ { type: "custom", customType: "ledger-entry", data: { epoch: 1, name: "old", content: "old" } },
1460
+ { type: "custom", customType: "ledger-entry", data: { epoch: 2, name: "keep", content: "new" } },
1461
+ { type: "custom", customType: "ledger-entry", data: { epoch: 2, name: "keep", content: "newer" } },
1462
+ ],
1463
+ },
1464
+ },
1465
+ );
1466
+
1467
+ assert.equal(state.epoch, 2);
1468
+ assert.deepEqual(Array.from(state.ledger.entries()), [["keep", "newer"]]);
1469
+ assert.deepEqual(pi.activeTools, ["ledger_get", "ledger_list"]);
1470
+ });
1471
+
1472
+ test("ledger tools add/get/list return stable contract details", async () => {
1473
+ const pi = new MockPi();
1474
+ const state = createState();
1475
+ const [ledgerAdd, ledgerGet, ledgerList] = createLedgerToolDefinitions(pi as any, state);
1476
+
1477
+ const addResult = await ledgerAdd.execute("1", { name: "entry-a", content: "first line\nsecond line" }, undefined, undefined, {} as any);
1478
+ assert.deepEqual(addResult.details, { entries: ["entry-a"] });
1479
+ assert.equal(state.ledger.get("entry-a"), "first line\nsecond line");
1480
+ assert.equal(pi.appendedEntries.length, 1);
1481
+ assert.equal(pi.appendedEntries[0].customType, "ledger-entry");
1482
+ assert.equal(pi.appendedEntries[0].data.name, "entry-a");
1483
+
1484
+ const getResult = await ledgerGet.execute("2", { name: "entry-a" }, undefined, undefined, {} as any);
1485
+ assert.equal(getResult.details.found, true);
1486
+ assert.deepEqual(getResult.details.entries, ["entry-a"]);
1487
+ assert.match(getResult.content[0].text, /--- entry-a ---/);
1488
+ assert.match(getResult.content[0].text, /second line/);
1489
+
1490
+ const listResult = await ledgerList.execute("3", {}, undefined, undefined, {} as any);
1491
+ assert.deepEqual(listResult.details, { entries: ["entry-a"] });
1492
+ assert.match(listResult.content[0].text, /entry-a: first line/);
1493
+ });
1494
+
1495
+ test("child ledger tools reject stale access after reset", async () => {
1496
+ const pi = new MockPi();
1497
+ const state = createState();
1498
+ state.ledger.set("entry-a", "alpha");
1499
+ let stale = false;
1500
+ const [ledgerAdd, ledgerGet, ledgerList] = createLedgerToolDefinitions(pi as any, state, { isStale: () => stale });
1501
+
1502
+ stale = true;
1503
+ await assert.rejects(
1504
+ () => ledgerAdd.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any),
1505
+ /invalidated by reset/i,
1506
+ );
1507
+ await assert.rejects(
1508
+ () => ledgerGet.execute("2", { name: "entry-a" }, undefined, undefined, {} as any),
1509
+ /invalidated by reset/i,
1510
+ );
1511
+ await assert.rejects(
1512
+ () => ledgerList.execute("3", {}, undefined, undefined, {} as any),
1513
+ /invalidated by reset/i,
1514
+ );
1515
+ assert.equal(state.ledger.get("entry-a"), "alpha");
1516
+ assert.equal(pi.appendedEntries.length, 0);
1517
+ });
1518
+
1519
+ test("child ledger_add succeeds while child session is fresh", async () => {
1520
+ const pi = new MockPi();
1521
+ const state = createState();
1522
+ const [ledgerAdd] = createLedgerToolDefinitions(pi as any, state, { isStale: () => false });
1523
+
1524
+ const result = await ledgerAdd.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any);
1525
+ assert.deepEqual(result.details, { entries: ["entry-a"] });
1526
+ assert.equal(state.ledger.get("entry-a"), "alpha");
1527
+ assert.equal(pi.appendedEntries.length, 1);
1528
+ });
1529
+
1530
+ test("ledger_get reports not found with current entry names", async () => {
1531
+ const pi = new MockPi();
1532
+ const state = createState();
1533
+ state.ledger.set("entry-a", "alpha");
1534
+ state.ledger.set("entry-b", "beta");
1535
+ const [, ledgerGet] = createLedgerToolDefinitions(pi as any, state);
1536
+
1537
+ const result = await ledgerGet.execute("1", { name: "missing" }, undefined, undefined, {} as any);
1538
+ assert.deepEqual(result.details, { entries: ["entry-a", "entry-b"], found: false });
1539
+ assert.match(result.content[0].text, /Entry "missing" not found\./);
1540
+ assert.match(result.content[0].text, /entry-a: alpha/);
1541
+ assert.match(result.content[0].text, /entry-b: beta/);
1542
+ });
1543
+
1544
+ test("ledger tools show empty-state placeholders", async () => {
1545
+ const pi = new MockPi();
1546
+ const state = createState();
1547
+ const [, ledgerGet, ledgerList] = createLedgerToolDefinitions(pi as any, state);
1548
+
1549
+ const missing = await ledgerGet.execute("1", { name: "missing" }, undefined, undefined, {} as any);
1550
+ assert.deepEqual(missing.details, { entries: [], found: false });
1551
+ assert.match(missing.content[0].text, /Entries:\n\(empty\)/);
1552
+
1553
+ const list = await ledgerList.execute("2", {}, undefined, undefined, {} as any);
1554
+ assert.deepEqual(list.details, { entries: [] });
1555
+ assert.match(list.content[0].text, /Entries:\n\(empty\)/);
1556
+ });
1557
+
1558
+ test("nested spawn invalidate() flushes render cache", () => {
1559
+ const state = createState();
1560
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1561
+ const session = createSession([
1562
+ { role: "assistant", content: [{ type: "text", text: "before" }] },
1563
+ ]);
1564
+ state.childSessions.set("tool-call-1", session);
1565
+
1566
+ const component = childSpawnTool.renderResult(
1567
+ { content: [], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1568
+ { expanded: false },
1569
+ theme,
1570
+ createRenderContext(),
1571
+ ) as any;
1572
+
1573
+ const firstRender = component.render(120);
1574
+ assert.ok(firstRender.some((l: string) => l.includes("before")));
1575
+
1576
+ // Same render call should hit cache
1577
+ const secondRender = component.render(120);
1578
+ assert.ok(secondRender.some((l: string) => l.includes("before")));
1579
+
1580
+ // Modify underlying session data and invalidate
1581
+ session.messages[0].content[0].text = "after";
1582
+ component.invalidate();
1583
+
1584
+ const thirdRender = component.render(120);
1585
+ assert.notEqual(firstRender, thirdRender);
1586
+ assert.ok(thirdRender.some((l: string) => l.includes("after")));
1587
+ assert.equal(thirdRender.some((l: string) => l.includes("before")), false);
1588
+ });
1589
+
1590
+ test("nested spawn rebuildFromSession quietly tolerates missing tool definitions", () => {
1591
+ const state = createState();
1592
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1593
+ const session = {
1594
+ messages: [{
1595
+ role: "assistant",
1596
+ content: [{ type: "toolCall", name: "bash", id: "tc-1", arguments: { command: "ls" } }],
1597
+ stopReason: "error",
1598
+ errorMessage: "boom",
1599
+ }],
1600
+ subscribe: () => () => {},
1601
+ getToolDefinition: () => { throw new Error("missing tool definition"); },
1602
+ sessionManager: { getCwd: () => process.cwd() },
1603
+ abort: async () => {},
1604
+ } as any;
1605
+ state.childSessions.set("tool-call-1", session);
1606
+
1607
+ const warnings: any[] = [];
1608
+ const originalWarn = console.warn;
1609
+ console.warn = (...args: any[]) => warnings.push(args);
1610
+
1611
+ try {
1612
+ const component = childSpawnTool.renderResult(
1613
+ { content: [], details: { depth: 1, model: "m", thinking: "low", truncated: false, outcome: "error" } },
1614
+ { expanded: false },
1615
+ theme,
1616
+ createRenderContext(),
1617
+ ) as any;
1618
+
1619
+ const lines = component.render(120);
1620
+ assert.ok(lines.some((l: string) => l.includes("⚠ [depth 1] m • low")));
1621
+ assert.ok(lines.some((l: string) => l.includes("error")));
1622
+ assert.equal(state.childSessions.has("tool-call-1"), false);
1623
+ assert.deepEqual(warnings, []);
1624
+ } finally {
1625
+ console.warn = originalWarn;
1626
+ }
1627
+ });
1628
+
1629
+ test("nested spawn attachSession recovers from subscribe throwing", () => {
1630
+ const state = createState();
1631
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1632
+
1633
+ // Session whose subscribe() throws
1634
+ const throwingSession = {
1635
+ messages: [{ role: "assistant", content: [{ type: "text", text: "hello" }] }],
1636
+ subscribe: () => { throw new Error("subscribe failed"); },
1637
+ getToolDefinition: () => undefined,
1638
+ sessionManager: { getCwd: () => process.cwd() },
1639
+ abort: async () => {},
1640
+ } as any;
1641
+ state.childSessions.set("tool-call-1", throwingSession);
1642
+
1643
+ const warnings: any[] = [];
1644
+ const originalWarn = console.warn;
1645
+ console.warn = (...args: any[]) => warnings.push(args);
1646
+
1647
+ try {
1648
+ const component = childSpawnTool.renderResult(
1649
+ { content: [], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1650
+ { expanded: false },
1651
+ theme,
1652
+ createRenderContext(),
1653
+ ) as any;
1654
+
1655
+ // Should not crash, session attached, ownership transferred
1656
+ assert.equal(state.childSessions.has("tool-call-1"), false);
1657
+ assert.equal(warnings.length, 1);
1658
+ assert.match(String(warnings[0][0]), /Failed to subscribe/);
1659
+
1660
+ // Should still render from session messages despite subscribe failure
1661
+ const lines = component.render(120);
1662
+ assert.ok(lines.some((l: string) => l.includes("hello")));
1663
+ } finally {
1664
+ console.warn = originalWarn;
1665
+ }
1666
+ });
1667
+
1668
+ test("nested spawn rapid events collapse to last state", () => {
1669
+ const state = createState();
1670
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1671
+ const { session, emit } = createSubscribableSession([]);
1672
+ state.childSessions.set("tool-call-1", session);
1673
+
1674
+ const component = childSpawnTool.renderResult(
1675
+ { content: [], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1676
+ { expanded: false },
1677
+ theme,
1678
+ createRenderContext(),
1679
+ ) as any;
1680
+
1681
+ // Start a tool execution
1682
+ emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } });
1683
+
1684
+ // Rapid burst of updates without rendering between them
1685
+ emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file1" }] } });
1686
+ emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file2" }] } });
1687
+ emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file3" }] } });
1688
+
1689
+ // Single render should reflect last state
1690
+ const lines = component.render(120);
1691
+ assert.ok(lines.some((l: string) => l.includes("file3")));
1692
+
1693
+ // End the tool and verify final state
1694
+ emit({ type: "tool_execution_end", toolCallId: "tc-1", result: { content: [{ type: "text", text: "done" }] }, isError: false });
1695
+
1696
+ const finalLines = component.render(120);
1697
+ assert.ok(finalLines.some((l: string) => l.includes("✓")));
1698
+ });
1699
+
1700
+ test("nested spawn isDetachedFromLiveState drops events when session is replaced", () => {
1701
+ const state = createState();
1702
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1703
+ const { session, emit } = createSubscribableSession([]);
1704
+ state.childSessions.set("tool-call-1", session);
1705
+ state.liveChildSessions.set("tool-call-1", session);
1706
+
1707
+ const component = childSpawnTool.renderResult(
1708
+ { content: [{ type: "text", text: "initial" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1709
+ { expanded: false },
1710
+ theme,
1711
+ createRenderContext(),
1712
+ ) as any;
1713
+
1714
+ // Replace the session in liveChildSessions with a different object
1715
+ const replacementSession = createSubscribableSession([]).session;
1716
+ state.liveChildSessions.set("tool-call-1", replacementSession);
1717
+
1718
+ // Emit a message_start event — should be silently dropped
1719
+ emit({ type: "message_start", message: { role: "assistant", content: [] } });
1720
+
1721
+ const lines = component.render(120);
1722
+ // Should NOT contain "thinking" because the event was dropped
1723
+ assert.ok(lines.every((l: string) => !l.includes("thinking")), `expected no thinking after detach, got: ${lines.join("\n")}`);
1724
+ });
1725
+
1726
+ test("concurrent spawn executions produce independent results", async () => {
1727
+ const pi = new MockPi();
1728
+ const state = createState();
1729
+
1730
+ let resolveA!: () => void;
1731
+ let resolveB!: () => void;
1732
+ let markStartedA!: () => void;
1733
+ let markStartedB!: () => void;
1734
+ const gateA = new Promise<void>((resolve) => { resolveA = resolve; });
1735
+ const gateB = new Promise<void>((resolve) => { resolveB = resolve; });
1736
+ const startedA = new Promise<void>((resolve) => { markStartedA = resolve; });
1737
+ const startedB = new Promise<void>((resolve) => { markStartedB = resolve; });
1738
+ const started: string[] = [];
1739
+ const outputs = new Map([
1740
+ ["task A", "result-alpha"],
1741
+ ["task B", "result-beta"],
1742
+ ]);
1743
+ const sharedFactory = async () => {
1744
+ const session = {
1745
+ messages: [] as any[],
1746
+ prompt: async (prompt: string) => {
1747
+ const task = /## Task\n\n([\s\S]*?)\n\nWhen complete/.exec(prompt)?.[1] ?? "";
1748
+ started.push(task);
1749
+ if (task === "task A") {
1750
+ markStartedA();
1751
+ await gateA;
1752
+ }
1753
+ if (task === "task B") {
1754
+ markStartedB();
1755
+ await gateB;
1756
+ }
1757
+ session.messages = [{ role: "assistant", content: [{ type: "text", text: outputs.get(task) ?? task }] }];
1758
+ },
1759
+ abort: async () => {},
1760
+ getSessionStats: () => undefined,
1761
+ };
1762
+ return { session: session as any };
1763
+ };
1764
+
1765
+ registerSpawnTool(pi as any, state, sharedFactory as any);
1766
+ const spawnTool = pi.tools.get("spawn");
1767
+
1768
+ const resultP1 = spawnTool.execute(
1769
+ "spawn-A", { prompt: "task A" }, undefined, undefined,
1770
+ { model: { id: "mock" }, cwd: "/tmp" },
1771
+ );
1772
+ const resultP2 = spawnTool.execute(
1773
+ "spawn-B", { prompt: "task B" }, undefined, undefined,
1774
+ { model: { id: "mock" }, cwd: "/tmp" },
1775
+ );
1776
+
1777
+ await Promise.all([startedA, startedB]);
1778
+ assert.deepEqual(started.sort(), ["task A", "task B"]);
1779
+ resolveA();
1780
+ resolveB();
1781
+
1782
+ const [r1, r2] = await Promise.all([resultP1, resultP2]);
1783
+
1784
+ assert.equal(r1.content[0].text, "result-alpha");
1785
+ assert.equal(r2.content[0].text, "result-beta");
1786
+ assert.equal(state.childSessions.has("spawn-A"), false);
1787
+ assert.equal(state.childSessions.has("spawn-B"), false);
1788
+ });
1789
+
1790
+ test("nested spawn render cache preserves stable output for identical params", () => {
1791
+ const state = createState();
1792
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1793
+ const { session } = createSubscribableSession([]);
1794
+ state.childSessions.set("tool-call-1", session);
1795
+
1796
+ const component = childSpawnTool.renderResult(
1797
+ { content: [{ type: "text", text: "hello" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1798
+ { expanded: false },
1799
+ theme,
1800
+ createRenderContext(),
1801
+ ) as any;
1802
+
1803
+ const first = component.render(120);
1804
+ const second = component.render(120);
1805
+ assert.deepEqual(second, first);
1806
+
1807
+ const wide = component.render(200);
1808
+ assert.ok(Array.isArray(wide));
1809
+ assert.ok(wide.some((l: string) => l.includes("hello") || l.includes("m • low")));
1810
+ });
1811
+
1812
+ test("ledger tool definitions include prompt hints when withPromptHints is true", () => {
1813
+ const pi = new MockPi();
1814
+ const state = createState();
1815
+ const tools = createLedgerToolDefinitions(pi as any, state, { withPromptHints: true });
1816
+
1817
+ for (const tool of tools) {
1818
+ assert.ok(typeof tool.promptSnippet === "string", `${tool.name} should have promptSnippet when withPromptHints=true`);
1819
+ }
1820
+ const ledgerAdd = tools.find(t => t.name === "ledger_add")!;
1821
+ assert.ok(Array.isArray(ledgerAdd.promptGuidelines), "ledger_add should have promptGuidelines array");
1822
+ assert.ok(ledgerAdd.promptGuidelines!.length > 0, "ledger_add promptGuidelines should not be empty");
1823
+ });
1824
+
1825
+ test("ledger tool definitions omit prompt hints by default", () => {
1826
+ const pi = new MockPi();
1827
+ const state = createState();
1828
+ const tools = createLedgerToolDefinitions(pi as any, state);
1829
+
1830
+ for (const tool of tools) {
1831
+ assert.equal(tool.promptSnippet, undefined, `${tool.name} should not have promptSnippet by default`);
1832
+ }
1833
+ const ledgerAdd = tools.find(t => t.name === "ledger_add")!;
1834
+ assert.equal(ledgerAdd.promptGuidelines, undefined, "ledger_add should not have promptGuidelines by default");
1835
+ });
1836
+
1837
+ test("executeSpawn detects stale session before session creation", async () => {
1838
+ const pi = new MockPi();
1839
+ pi.setActiveTools(["read", "bash", "spawn"]);
1840
+ const state = createState();
1841
+
1842
+ let resolveFactory!: (value: any) => void;
1843
+ const factoryReady = new Promise<any>((resolve) => {
1844
+ resolveFactory = resolve;
1845
+ });
1846
+ let factoryCalled = false;
1847
+ let abortCalls = 0;
1848
+
1849
+ const executePromise = executeSpawn(
1850
+ "spawn-1",
1851
+ pi as any,
1852
+ { model: { id: "mock-model" }, cwd: "/tmp" } as any,
1853
+ state,
1854
+ { prompt: "Do the task" },
1855
+ undefined,
1856
+ undefined,
1857
+ "medium",
1858
+ 0,
1859
+ async () => {
1860
+ factoryCalled = true;
1861
+ await factoryReady;
1862
+ return {
1863
+ session: {
1864
+ messages: [] as any[],
1865
+ prompt: async () => {},
1866
+ abort: async () => { abortCalls++; },
1867
+ getSessionStats: () => undefined,
1868
+ } as any,
1869
+ };
1870
+ },
1871
+ );
1872
+
1873
+ // Reset state while executeSpawn is awaiting the factory
1874
+ resetState(state);
1875
+ // Now allow the factory to resolve — session should be immediately stale
1876
+ resolveFactory({});
1877
+
1878
+ await assert.rejects(
1879
+ () => executePromise,
1880
+ /invalidated by reset/i,
1881
+ );
1882
+ assert.equal(factoryCalled, true);
1883
+ assert.equal(abortCalls, 1);
1884
+ assert.equal(state.childSessions.size, 0);
1885
+ assert.equal(state.liveChildSessions.size, 0);
1886
+ });
1887
+
1888
+ test("executeSpawn aborts stale child when resetState fires during prompt", async () => {
1889
+ const pi = new MockPi();
1890
+ pi.setActiveTools(["read", "bash", "spawn"]);
1891
+ const state = createState();
1892
+
1893
+ let rejectPrompt!: (err: Error) => void;
1894
+ let resolvePromptStarted!: () => void;
1895
+ const promptStartedPromise = new Promise<void>((r) => { resolvePromptStarted = r; });
1896
+ let abortCalls = 0;
1897
+
1898
+ const executePromise = executeSpawn(
1899
+ "spawn-1",
1900
+ pi as any,
1901
+ { model: { id: "mock-model" }, cwd: "/tmp" } as any,
1902
+ state,
1903
+ { prompt: "Do the task" },
1904
+ undefined,
1905
+ undefined,
1906
+ "medium",
1907
+ 0,
1908
+ async () => ({
1909
+ session: {
1910
+ messages: [] as any[],
1911
+ prompt: async () => {
1912
+ resolvePromptStarted();
1913
+ await new Promise<void>((_resolve, reject) => {
1914
+ rejectPrompt = reject;
1915
+ });
1916
+ },
1917
+ abort: async () => {
1918
+ abortCalls++;
1919
+ rejectPrompt?.(new Error("aborted"));
1920
+ },
1921
+ getSessionStats: () => undefined,
1922
+ } as any,
1923
+ }),
1924
+ );
1925
+
1926
+ // Wait for session to be created and prompt to start
1927
+ await promptStartedPromise;
1928
+ // Reset state triggers abortAndClearChildSessions which calls session.abort()
1929
+ // abort() rejects the pending prompt, which causes the stale check to fire
1930
+ resetState(state);
1931
+
1932
+ await assert.rejects(
1933
+ () => executePromise,
1934
+ /invalidated by reset/i,
1935
+ );
1936
+ // abort is called once by clearChildSession (identity match via liveChildSessions)
1937
+ assert.equal(abortCalls >= 1, true);
1938
+ assert.equal(state.childSessions.size, 0);
1939
+ assert.equal(state.liveChildSessions.size, 0);
1940
+ });
1941
+
1942
+ test("handleEvent gracefully degrades with null message events", () => {
1943
+ const state = createState();
1944
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1945
+ const { session, emit } = createSubscribableSession([]);
1946
+ state.childSessions.set("tool-call-1", session);
1947
+
1948
+ const component = childSpawnTool.renderResult(
1949
+ { content: [], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1950
+ { expanded: false },
1951
+ theme,
1952
+ createRenderContext(),
1953
+ ) as any;
1954
+
1955
+ // asToolResult is exercised indirectly through tool_execution_update
1956
+ // with null partialResult — the runtime guard should handle it without crashing
1957
+ emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } });
1958
+ emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: null });
1959
+ emit({ type: "tool_execution_end", toolCallId: "tc-1", result: null, isError: false });
1960
+
1961
+ // No crash = asToolResult guard works
1962
+ const lines = component.render(120);
1963
+ assert.ok(Array.isArray(lines));
1964
+ });
1965
+
1966
+ test("truncateText respects line limit before byte limit", async () => {
1967
+ const pi = new MockPi();
1968
+ pi.setActiveTools(["read", "bash", "spawn"]);
1969
+ const state = createState();
1970
+
1971
+ // Generate text with > 2000 lines to trigger line truncation
1972
+ const text = Array.from({ length: 2500 }, (_, i) => `Line ${i}`).join("\n");
1973
+ const mockFactory = async () => {
1974
+ const session = {
1975
+ messages: [] as any[],
1976
+ prompt: async () => {
1977
+ session.messages = [{ role: "assistant", content: [{ type: "text", text }] }];
1978
+ },
1979
+ abort: async () => {},
1980
+ getSessionStats: () => undefined,
1981
+ };
1982
+ return { session: session as any };
1983
+ };
1984
+
1985
+ registerSpawnTool(pi as any, state, mockFactory as any);
1986
+
1987
+ const result = await pi.tools.get("spawn").execute(
1988
+ "spawn-1",
1989
+ { prompt: "Generate lots of lines" },
1990
+ undefined,
1991
+ undefined,
1992
+ { model: { id: "mock-model" }, cwd: "/tmp" },
1993
+ );
1994
+
1995
+ assert.equal(result.details.truncated, true);
1996
+ const textLines = result.content[0].text.split("\n");
1997
+ assert.ok(textLines[0].startsWith("Line 0"), `expected first line, got: ${textLines[0]}`);
1998
+ assert.ok(result.content[0].text.includes("[Result truncated"));
1999
+ });
2000
+
2001
+ test("nested spawn setExpanded and setShowImages no-op when value matches", () => {
2002
+ const state = createState();
2003
+ const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
2004
+ const session = createSession([
2005
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
2006
+ ]);
2007
+ state.childSessions.set("tool-call-1", session);
2008
+
2009
+ const component = childSpawnTool.renderResult(
2010
+ { content: [], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
2011
+ { expanded: false },
2012
+ theme,
2013
+ createRenderContext(),
2014
+ ) as any;
2015
+
2016
+ // Calling setExpanded with same value should not throw or crash
2017
+ component.setExpanded(false);
2018
+ component.setExpanded(true);
2019
+ component.setShowImages(true);
2020
+ component.setShowImages(false);
2021
+
2022
+ // Component still renders
2023
+ const lines = component.render(120);
2024
+ assert.ok(lines.some((l: string) => l.includes("hello")));
2025
+ });
2026
+
2027
+ test("abortAndClearChildSessions deduplicates sessions across both maps", () => {
2028
+ const state = createState();
2029
+ let abortCalls = 0;
2030
+ const mockSession = {
2031
+ messages: [],
2032
+ abort: async () => { abortCalls++; },
2033
+ } as any;
2034
+
2035
+ // Put the same session object in both maps under the same key
2036
+ state.childSessions.set("tc-1", mockSession);
2037
+ state.liveChildSessions.set("tc-1", mockSession);
2038
+
2039
+ resetState(state);
2040
+
2041
+ // Dedup via the `seen` map ensures abort is called exactly once
2042
+ assert.equal(abortCalls, 1);
2043
+ assert.equal(state.childSessions.size, 0);
2044
+ assert.equal(state.liveChildSessions.size, 0);
2045
+ });
2046
+
2047
+ test("renderSpawnResult handles result with no details field", () => {
2048
+ const state = createState();
2049
+ const result = renderSpawnResult(
2050
+ { content: [{ type: "text", text: "hello" }] },
2051
+ false,
2052
+ theme,
2053
+ { toolCallId: "tc-1", invalidate: () => {}, showImages: false },
2054
+ state,
2055
+ );
2056
+ // Should return a Text component that renders without crashing
2057
+ assert.ok(result, "renderSpawnResult should return a component");
2058
+ const lines = (result as any).render(120);
2059
+ assert.ok(Array.isArray(lines), "render should return an array of lines");
2060
+ assert.ok(lines.some((l: string) => l.includes("hello")), `expected 'hello' in output, got: ${lines.join("\n")}`);
2061
+ });
2062
+
2063
+ test("registerSpawnTool registers a tool with correct name and metadata", () => {
2064
+ const pi = new MockPi();
2065
+ const state = createState();
2066
+ registerSpawnTool(pi as any, state);
2067
+
2068
+ const tool = pi.tools.get("spawn");
2069
+ assert.ok(tool, "spawn tool should be registered");
2070
+ assert.equal(tool.name, "spawn");
2071
+ assert.equal(tool.label, "Spawn");
2072
+ assert.equal(typeof tool.description, "string");
2073
+ assert.equal(typeof tool.execute, "function");
2074
+ assert.equal(typeof tool.renderCall, "function");
2075
+ assert.equal(typeof tool.renderResult, "function");
2076
+ // parameters are a TypeBox schema object — just verify it exists
2077
+ assert.ok(tool.parameters, "should have parameters");
2078
+ assert.equal(tool.executionMode, undefined, "spawn should not be sequential");
2079
+ });