@runfusion/fusion 0.1.3 → 0.2.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.
Files changed (44) hide show
  1. package/dist/bin.js +2113 -1017
  2. package/dist/client/assets/AgentDetailView-CDZED6Dy.css +1 -0
  3. package/dist/client/assets/AgentDetailView-zycSdnO8.js +28 -0
  4. package/dist/client/assets/AgentsView-DoQkkDLf.css +1 -0
  5. package/dist/client/assets/AgentsView-pO7WiBS5.js +522 -0
  6. package/dist/client/assets/ChatView-BOd-sxbT.js +1 -0
  7. package/dist/client/assets/DevServerView-09GQf34f.js +11 -0
  8. package/dist/client/assets/DevServerView-ZeBGQkLI.css +1 -0
  9. package/dist/client/assets/DirectoryPicker-CcdN1Zs7.js +1 -0
  10. package/dist/client/assets/DocumentsView-CS8aiwtz.js +1 -0
  11. package/dist/client/assets/DocumentsView-Co9to4Zp.css +1 -0
  12. package/dist/client/assets/InsightsView-Bu9Cv8Ol.js +11 -0
  13. package/dist/client/assets/InsightsView-Egu71gmh.css +1 -0
  14. package/dist/client/assets/MemoryView-CtqgDtV9.js +2 -0
  15. package/dist/client/assets/MemoryView-DhinauGs.css +1 -0
  16. package/dist/client/assets/NodesView-BInPcedy.js +14 -0
  17. package/dist/client/assets/NodesView-DlQZHGXA.css +1 -0
  18. package/dist/client/assets/PiExtensionsManager-COxkYM2m.js +11 -0
  19. package/dist/client/assets/PiExtensionsManager-CPgmJgDk.css +1 -0
  20. package/dist/client/assets/PluginManager-CXUWZBOc.js +1 -0
  21. package/dist/client/assets/PluginManager-D64RIzmL.css +1 -0
  22. package/dist/client/assets/RoadmapsView-BOYnyMCh.css +1 -0
  23. package/dist/client/assets/RoadmapsView-BbCexaoi.js +6 -0
  24. package/dist/client/assets/SetupWizardModal-Cakxqkad.js +1 -0
  25. package/dist/client/assets/SkillsView-Cytf009Z.css +1 -0
  26. package/dist/client/assets/SkillsView-D3iqYCVf.js +1 -0
  27. package/dist/client/assets/folder-open-kO5Hsk66.js +6 -0
  28. package/dist/client/assets/index-BiSuUXCa.css +1 -0
  29. package/dist/client/assets/index-y194HxzU.js +644 -0
  30. package/dist/client/assets/upload-DHBQat92.js +6 -0
  31. package/dist/client/index.html +2 -2
  32. package/dist/extension.js +175 -66
  33. package/dist/pi-claude-cli/src/__tests__/control-handler.test.ts +191 -0
  34. package/dist/pi-claude-cli/src/__tests__/event-bridge.test.ts +1244 -0
  35. package/dist/pi-claude-cli/src/__tests__/mcp-config.test.ts +272 -0
  36. package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +619 -0
  37. package/dist/pi-claude-cli/src/__tests__/prompt-builder.test.ts +1067 -0
  38. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +1902 -0
  39. package/dist/pi-claude-cli/src/__tests__/stream-parser.test.ts +188 -0
  40. package/dist/pi-claude-cli/src/__tests__/thinking-config.test.ts +141 -0
  41. package/dist/pi-claude-cli/src/__tests__/tool-mapping.test.ts +252 -0
  42. package/package.json +11 -5
  43. package/dist/client/assets/index-BuenKJX0.css +0 -1
  44. package/dist/client/assets/index-CjGu8HRV.js +0 -1250
@@ -0,0 +1,1902 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+
5
+ // Mock cross-spawn with PassThrough streams for readline compatibility
6
+ vi.mock("cross-spawn", () => ({
7
+ default: vi.fn(() => {
8
+ const proc = new EventEmitter();
9
+ const stdin = { write: vi.fn(), end: vi.fn() };
10
+ const stdout = new PassThrough();
11
+ const stderr = new EventEmitter();
12
+ (proc as any).stdin = stdin;
13
+ (proc as any).stdout = stdout;
14
+ (proc as any).stderr = stderr;
15
+ (proc as any).killed = false;
16
+ (proc as any).exitCode = null;
17
+ (proc as any).kill = vi.fn(() => {
18
+ (proc as any).killed = true;
19
+ });
20
+ (proc as any).pid = 99999;
21
+ return proc;
22
+ }),
23
+ }));
24
+
25
+ // Mock child_process.execSync for validateCliPresence/validateCliAuth
26
+ vi.mock("node:child_process", () => ({
27
+ execSync: vi.fn(() => Buffer.from("1.0.0")),
28
+ }));
29
+
30
+ // Mock @mariozechner/pi-ai
31
+ const mockModels = [
32
+ {
33
+ id: "claude-sonnet-4-5-20250929",
34
+ name: "Claude Sonnet 4.5",
35
+ api: "anthropic",
36
+ provider: "anthropic",
37
+ reasoning: false,
38
+ input: "text",
39
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
40
+ contextWindow: 200000,
41
+ maxTokens: 8192,
42
+ },
43
+ {
44
+ id: "claude-opus-4-6-20260301",
45
+ name: "Claude Opus 4.6",
46
+ api: "anthropic",
47
+ provider: "anthropic",
48
+ reasoning: true,
49
+ input: "text",
50
+ cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
51
+ contextWindow: 200000,
52
+ maxTokens: 16384,
53
+ },
54
+ ];
55
+
56
+ const { MockAssistantMessageEventStream } = vi.hoisted(() => {
57
+ const MockAssistantMessageEventStream: any = vi.fn(function (this: any) {
58
+ const events: any[] = [];
59
+ this.push = vi.fn((event: any) => events.push(event));
60
+ this.end = vi.fn();
61
+ this._events = events;
62
+ });
63
+ return { MockAssistantMessageEventStream };
64
+ });
65
+
66
+ vi.mock("@mariozechner/pi-ai", () => ({
67
+ getModels: vi.fn(() => mockModels),
68
+ AssistantMessageEventStream: MockAssistantMessageEventStream,
69
+ calculateCost: vi.fn(),
70
+ }));
71
+
72
+ import spawn from "cross-spawn";
73
+ import { streamViaCli } from "../provider";
74
+
75
+ describe("provider registration (default export)", () => {
76
+ it("registers provider with ID pi-claude-cli", async () => {
77
+ const registerProvider = vi.fn();
78
+ const mockPi = { registerProvider, on: vi.fn() } as any;
79
+
80
+ // Dynamic import to get the default export
81
+ const mod = await import("../../index");
82
+ mod.default(mockPi);
83
+
84
+ expect(registerProvider).toHaveBeenCalledTimes(1);
85
+ expect(registerProvider.mock.calls[0][0]).toBe("pi-claude-cli");
86
+ });
87
+
88
+ it("registers provider with correct config shape", async () => {
89
+ const registerProvider = vi.fn();
90
+ const mockPi = { registerProvider, on: vi.fn() } as any;
91
+
92
+ const mod = await import("../../index");
93
+ mod.default(mockPi);
94
+
95
+ const config = registerProvider.mock.calls[0][1];
96
+ expect(config.baseUrl).toBe("pi-claude-cli");
97
+ expect(config.apiKey).toBe("unused");
98
+ expect(config.api).toBe("pi-claude-cli");
99
+ expect(config.models).toBeDefined();
100
+ expect(Array.isArray(config.models)).toBe(true);
101
+ expect(config.streamSimple).toBeDefined();
102
+ expect(typeof config.streamSimple).toBe("function");
103
+ });
104
+
105
+ it("derives models from getModels('anthropic') with correct fields", async () => {
106
+ const registerProvider = vi.fn();
107
+ const mockPi = { registerProvider, on: vi.fn() } as any;
108
+
109
+ const mod = await import("../../index");
110
+ mod.default(mockPi);
111
+
112
+ const config = registerProvider.mock.calls[0][1];
113
+ expect(config.models.length).toBeGreaterThan(0);
114
+
115
+ const firstModel = config.models[0];
116
+ expect(firstModel.id).toBe("claude-sonnet-4-5-20250929");
117
+ expect(firstModel.name).toBe("Claude Sonnet 4.5");
118
+ expect(firstModel.contextWindow).toBe(200000);
119
+ expect(firstModel.maxTokens).toBe(8192);
120
+ expect(firstModel.cost).toBeDefined();
121
+ });
122
+ });
123
+
124
+ describe("streamViaCli", () => {
125
+ beforeEach(() => {
126
+ vi.clearAllMocks();
127
+ vi.useFakeTimers();
128
+ });
129
+
130
+ afterEach(() => {
131
+ vi.useRealTimers();
132
+ });
133
+
134
+ it("returns an AssistantMessageEventStream", () => {
135
+ const model = mockModels[0] as any;
136
+ const context = {
137
+ messages: [{ role: "user", content: "Hello" }],
138
+ systemPrompt: "Be helpful",
139
+ };
140
+
141
+ const result = streamViaCli(model, context);
142
+ expect(result).toBeDefined();
143
+ expect(result.push).toBeDefined();
144
+ expect(result.end).toBeDefined();
145
+ });
146
+
147
+ it("spawns subprocess and writes user message to stdin", async () => {
148
+ const model = mockModels[0] as any;
149
+ const context = {
150
+ messages: [{ role: "user", content: "Hello" }],
151
+ };
152
+
153
+ streamViaCli(model, context);
154
+
155
+ // Allow async IIFE to start
156
+ await vi.advanceTimersByTimeAsync(0);
157
+
158
+ // Verify spawn was called
159
+ expect(spawn).toHaveBeenCalled();
160
+
161
+ // Verify user message was written to stdin
162
+ const proc = (spawn as any).mock.results[0].value;
163
+ expect(proc.stdin.write).toHaveBeenCalledTimes(1);
164
+
165
+ const written = proc.stdin.write.mock.calls[0][0] as string;
166
+ const parsed = JSON.parse(written.trim());
167
+ expect(parsed.type).toBe("user");
168
+ expect(parsed.message.role).toBe("user");
169
+ });
170
+
171
+ it("handles full text streaming sequence via NDJSON", async () => {
172
+ const model = mockModels[0] as any;
173
+ const context = {
174
+ messages: [{ role: "user", content: "Hello" }],
175
+ };
176
+
177
+ streamViaCli(model, context);
178
+ await vi.advanceTimersByTimeAsync(0);
179
+
180
+ // Get the mock process
181
+ const proc = (spawn as any).mock.results[0].value;
182
+
183
+ // Simulate NDJSON output on stdout
184
+ const lines = [
185
+ JSON.stringify({ type: "system", subtype: "init", session_id: "test" }),
186
+ JSON.stringify({
187
+ type: "stream_event",
188
+ event: {
189
+ type: "message_start",
190
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
191
+ },
192
+ }),
193
+ JSON.stringify({
194
+ type: "stream_event",
195
+ event: {
196
+ type: "content_block_start",
197
+ index: 0,
198
+ content_block: { type: "text", text: "" },
199
+ },
200
+ }),
201
+ JSON.stringify({
202
+ type: "stream_event",
203
+ event: {
204
+ type: "content_block_delta",
205
+ index: 0,
206
+ delta: { type: "text_delta", text: "Hello" },
207
+ },
208
+ }),
209
+ JSON.stringify({
210
+ type: "stream_event",
211
+ event: {
212
+ type: "content_block_delta",
213
+ index: 0,
214
+ delta: { type: "text_delta", text: " world" },
215
+ },
216
+ }),
217
+ JSON.stringify({
218
+ type: "stream_event",
219
+ event: { type: "content_block_stop", index: 0 },
220
+ }),
221
+ JSON.stringify({
222
+ type: "stream_event",
223
+ event: {
224
+ type: "message_delta",
225
+ delta: { stop_reason: "end_turn" },
226
+ usage: { output_tokens: 5 },
227
+ },
228
+ }),
229
+ JSON.stringify({
230
+ type: "stream_event",
231
+ event: { type: "message_stop" },
232
+ }),
233
+ JSON.stringify({
234
+ type: "result",
235
+ subtype: "success",
236
+ result: "Hello world",
237
+ }),
238
+ ];
239
+
240
+ // Write each line to stdout PassThrough stream (readline reads from it)
241
+ for (const line of lines) {
242
+ proc.stdout.write(line + "\n");
243
+ }
244
+ // End the stream so readline finishes
245
+ proc.stdout.end();
246
+
247
+ // Allow async processing
248
+ await vi.advanceTimersByTimeAsync(100);
249
+
250
+ // The stream should have received events from the event bridge
251
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
252
+ const events = mockStream._events;
253
+
254
+ // Verify we got the expected event types
255
+ const eventTypes = events.map((e: any) => e.type);
256
+ expect(eventTypes).toContain("text_start");
257
+ expect(eventTypes).toContain("text_delta");
258
+ expect(eventTypes).toContain("text_end");
259
+ expect(eventTypes).toContain("done");
260
+ });
261
+
262
+ it("handles result error by pushing error event", async () => {
263
+ const model = mockModels[0] as any;
264
+ const context = {
265
+ messages: [{ role: "user", content: "Hello" }],
266
+ };
267
+
268
+ streamViaCli(model, context);
269
+ await vi.advanceTimersByTimeAsync(0);
270
+
271
+ const proc = (spawn as any).mock.results[0].value;
272
+
273
+ // Write error result to stdout
274
+ const errorLine = JSON.stringify({
275
+ type: "result",
276
+ subtype: "error",
277
+ error: "Rate limit exceeded",
278
+ });
279
+ proc.stdout.write(errorLine + "\n");
280
+ proc.stdout.end();
281
+ await vi.advanceTimersByTimeAsync(100);
282
+
283
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
284
+ const doneEvent = mockStream._events.find(
285
+ (e: any) => e.type === "done" && e.message,
286
+ );
287
+ expect(doneEvent).toBeDefined();
288
+ expect(doneEvent.message.content).toBeDefined();
289
+ expect(mockStream.end).toHaveBeenCalled();
290
+ });
291
+
292
+ it("calls cleanupProcess after receiving result", async () => {
293
+ const model = mockModels[0] as any;
294
+ const context = {
295
+ messages: [{ role: "user", content: "Hello" }],
296
+ };
297
+
298
+ streamViaCli(model, context);
299
+ await vi.advanceTimersByTimeAsync(0);
300
+
301
+ const proc = (spawn as any).mock.results[0].value;
302
+
303
+ // Write result to stdout
304
+ proc.stdout.write(
305
+ JSON.stringify({ type: "result", subtype: "success", result: "ok" }) +
306
+ "\n",
307
+ );
308
+ proc.stdout.end();
309
+ await vi.advanceTimersByTimeAsync(100);
310
+
311
+ // Advance timer past cleanup grace period (500ms after Phase 5 hardening)
312
+ vi.advanceTimersByTime(500);
313
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
314
+ });
315
+
316
+ it("kills subprocess when abort signal fires", async () => {
317
+ const model = mockModels[0] as any;
318
+ const context = {
319
+ messages: [{ role: "user", content: "Hello" }],
320
+ };
321
+ const controller = new AbortController();
322
+
323
+ streamViaCli(model, context, { signal: controller.signal });
324
+ await vi.advanceTimersByTimeAsync(0);
325
+
326
+ const proc = (spawn as any).mock.results[0].value;
327
+
328
+ // Trigger abort -- this should call kill on the process
329
+ controller.abort();
330
+ await vi.advanceTimersByTimeAsync(0);
331
+
332
+ expect(proc.kill).toHaveBeenCalled();
333
+
334
+ // End stdout to allow readline loop to finish and prevent hanging
335
+ proc.stdout.end();
336
+ await vi.advanceTimersByTimeAsync(100);
337
+ });
338
+
339
+ it("routes control_request through handleControlRequest and writes response to stdin", async () => {
340
+ const model = mockModels[0] as any;
341
+ const context = {
342
+ messages: [{ role: "user", content: "Hello" }],
343
+ };
344
+
345
+ streamViaCli(model, context);
346
+ await vi.advanceTimersByTimeAsync(0);
347
+
348
+ const proc = (spawn as any).mock.results[0].value;
349
+
350
+ // Clear initial stdin.write (user message)
351
+ proc.stdin.write.mockClear();
352
+
353
+ // Simulate a control_request NDJSON line arriving on stdout
354
+ const controlRequest = JSON.stringify({
355
+ type: "control_request",
356
+ request_id: "req_123",
357
+ request: {
358
+ subtype: "can_use_tool",
359
+ tool_name: "Read",
360
+ input: { file_path: "/foo.ts" },
361
+ },
362
+ });
363
+
364
+ // Then follow with stream events and result so stream completes
365
+ const lines = [
366
+ controlRequest,
367
+ JSON.stringify({
368
+ type: "stream_event",
369
+ event: {
370
+ type: "message_start",
371
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
372
+ },
373
+ }),
374
+ JSON.stringify({
375
+ type: "stream_event",
376
+ event: { type: "message_stop" },
377
+ }),
378
+ JSON.stringify({
379
+ type: "result",
380
+ subtype: "success",
381
+ result: "ok",
382
+ }),
383
+ ];
384
+
385
+ for (const line of lines) {
386
+ proc.stdout.write(line + "\n");
387
+ }
388
+ proc.stdout.end();
389
+ await vi.advanceTimersByTimeAsync(100);
390
+
391
+ // Verify control_response was written to stdin
392
+ expect(proc.stdin.write).toHaveBeenCalled();
393
+ const stdinCalls = proc.stdin.write.mock.calls;
394
+ const controlResponse = stdinCalls.find((call: any[]) => {
395
+ try {
396
+ const parsed = JSON.parse(call[0]);
397
+ return parsed.type === "control_response";
398
+ } catch {
399
+ return false;
400
+ }
401
+ });
402
+ expect(controlResponse).toBeDefined();
403
+ const parsed = JSON.parse(controlResponse[0]);
404
+ expect(parsed.request_id).toBe("req_123");
405
+ expect(parsed.response.response.behavior).toBe("allow");
406
+ });
407
+
408
+ describe("thinking effort wiring", () => {
409
+ it("passes effort to spawnClaude when options.reasoning is provided on non-Opus model", async () => {
410
+ const model = mockModels[0] as any; // sonnet (non-Opus)
411
+ const context = {
412
+ messages: [{ role: "user", content: "Think about this" }],
413
+ };
414
+
415
+ streamViaCli(model, context, { reasoning: "high" } as any);
416
+ await vi.advanceTimersByTimeAsync(0);
417
+
418
+ // Verify spawn was called with effort arg
419
+ const args = (spawn as any).mock.calls[0][1] as string[];
420
+ expect(args).toContain("--effort");
421
+ const idx = args.indexOf("--effort");
422
+ expect(args[idx + 1]).toBe("high");
423
+ });
424
+
425
+ it("passes elevated effort to spawnClaude when options.reasoning is provided on Opus model", async () => {
426
+ const model = mockModels[1] as any; // opus
427
+ const context = {
428
+ messages: [{ role: "user", content: "Think about this" }],
429
+ };
430
+
431
+ streamViaCli(model, context, { reasoning: "high" } as any);
432
+ await vi.advanceTimersByTimeAsync(0);
433
+
434
+ // Opus "high" should map to "max"
435
+ const args = (spawn as any).mock.calls[0][1] as string[];
436
+ expect(args).toContain("--effort");
437
+ const idx = args.indexOf("--effort");
438
+ expect(args[idx + 1]).toBe("max");
439
+ });
440
+
441
+ it("does not pass effort when reasoning is undefined", async () => {
442
+ const model = mockModels[0] as any;
443
+ const context = {
444
+ messages: [{ role: "user", content: "Hello" }],
445
+ };
446
+
447
+ streamViaCli(model, context);
448
+ await vi.advanceTimersByTimeAsync(0);
449
+
450
+ const args = (spawn as any).mock.calls[0][1] as string[];
451
+ expect(args).not.toContain("--effort");
452
+ });
453
+
454
+ it("passes medium effort for medium reasoning on non-Opus", async () => {
455
+ const model = mockModels[0] as any; // sonnet
456
+ const context = {
457
+ messages: [{ role: "user", content: "Think" }],
458
+ };
459
+
460
+ streamViaCli(model, context, { reasoning: "medium" } as any);
461
+ await vi.advanceTimersByTimeAsync(0);
462
+
463
+ const args = (spawn as any).mock.calls[0][1] as string[];
464
+ const idx = args.indexOf("--effort");
465
+ expect(args[idx + 1]).toBe("medium");
466
+ });
467
+
468
+ it("passes high effort for medium reasoning on Opus (elevated)", async () => {
469
+ const model = mockModels[1] as any; // opus
470
+ const context = {
471
+ messages: [{ role: "user", content: "Think" }],
472
+ };
473
+
474
+ streamViaCli(model, context, { reasoning: "medium" } as any);
475
+ await vi.advanceTimersByTimeAsync(0);
476
+
477
+ const args = (spawn as any).mock.calls[0][1] as string[];
478
+ const idx = args.indexOf("--effort");
479
+ expect(args[idx + 1]).toBe("high");
480
+ });
481
+ });
482
+
483
+ it("stream events continue flowing after control_request handling", async () => {
484
+ const model = mockModels[0] as any;
485
+ const context = {
486
+ messages: [{ role: "user", content: "Hello" }],
487
+ };
488
+
489
+ streamViaCli(model, context);
490
+ await vi.advanceTimersByTimeAsync(0);
491
+
492
+ const proc = (spawn as any).mock.results[0].value;
493
+
494
+ // control_request followed by normal stream events
495
+ const lines = [
496
+ JSON.stringify({
497
+ type: "control_request",
498
+ request_id: "req_456",
499
+ request: {
500
+ subtype: "can_use_tool",
501
+ tool_name: "Bash",
502
+ input: { command: "ls" },
503
+ },
504
+ }),
505
+ JSON.stringify({
506
+ type: "stream_event",
507
+ event: {
508
+ type: "message_start",
509
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
510
+ },
511
+ }),
512
+ JSON.stringify({
513
+ type: "stream_event",
514
+ event: {
515
+ type: "content_block_start",
516
+ index: 0,
517
+ content_block: { type: "text", text: "" },
518
+ },
519
+ }),
520
+ JSON.stringify({
521
+ type: "stream_event",
522
+ event: {
523
+ type: "content_block_delta",
524
+ index: 0,
525
+ delta: { type: "text_delta", text: "After control" },
526
+ },
527
+ }),
528
+ JSON.stringify({
529
+ type: "stream_event",
530
+ event: { type: "content_block_stop", index: 0 },
531
+ }),
532
+ JSON.stringify({
533
+ type: "stream_event",
534
+ event: {
535
+ type: "message_delta",
536
+ delta: { stop_reason: "end_turn" },
537
+ usage: { output_tokens: 3 },
538
+ },
539
+ }),
540
+ JSON.stringify({
541
+ type: "stream_event",
542
+ event: { type: "message_stop" },
543
+ }),
544
+ JSON.stringify({
545
+ type: "result",
546
+ subtype: "success",
547
+ result: "ok",
548
+ }),
549
+ ];
550
+
551
+ for (const line of lines) {
552
+ proc.stdout.write(line + "\n");
553
+ }
554
+ proc.stdout.end();
555
+ await vi.advanceTimersByTimeAsync(100);
556
+
557
+ // Verify the stream still received text events after the control_request
558
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
559
+ const events = mockStream._events;
560
+ const eventTypes = events.map((e: any) => e.type);
561
+ expect(eventTypes).toContain("text_start");
562
+ expect(eventTypes).toContain("text_delta");
563
+ expect(eventTypes).toContain("done");
564
+ });
565
+
566
+ describe("mcpConfigPath passthrough", () => {
567
+ it("passes mcpConfigPath to spawnClaude options", async () => {
568
+ const model = mockModels[0] as any;
569
+ const context = {
570
+ messages: [{ role: "user", content: "Hello" }],
571
+ };
572
+
573
+ streamViaCli(model, context, {
574
+ mcpConfigPath: "/tmp/mcp-config.json",
575
+ } as any);
576
+ await vi.advanceTimersByTimeAsync(0);
577
+
578
+ const args = (spawn as any).mock.calls[0][1] as string[];
579
+ expect(args).toContain("--mcp-config");
580
+ const idx = args.indexOf("--mcp-config");
581
+ expect(args[idx + 1]).toBe("/tmp/mcp-config.json");
582
+
583
+ // End stdout to prevent hanging
584
+ const proc = (spawn as any).mock.results[0].value;
585
+ proc.stdout.end();
586
+ await vi.advanceTimersByTimeAsync(100);
587
+ });
588
+ });
589
+
590
+ describe("break-early logic", () => {
591
+ it("kills subprocess at message_stop when built-in tool_use seen and emits done event", async () => {
592
+ const model = mockModels[0] as any;
593
+ const context = {
594
+ messages: [{ role: "user", content: "Read a file" }],
595
+ };
596
+
597
+ streamViaCli(model, context);
598
+ await vi.advanceTimersByTimeAsync(0);
599
+
600
+ const proc = (spawn as any).mock.results[0].value;
601
+
602
+ // Simulate tool_use stream: message_start, content_block_start (tool_use Read),
603
+ // content_block_delta (input_json_delta), content_block_stop, message_delta, message_stop
604
+ const lines = [
605
+ JSON.stringify({
606
+ type: "stream_event",
607
+ event: {
608
+ type: "message_start",
609
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
610
+ },
611
+ }),
612
+ JSON.stringify({
613
+ type: "stream_event",
614
+ event: {
615
+ type: "content_block_start",
616
+ index: 0,
617
+ content_block: {
618
+ type: "tool_use",
619
+ id: "tool_1",
620
+ name: "Read",
621
+ input: "",
622
+ },
623
+ },
624
+ }),
625
+ JSON.stringify({
626
+ type: "stream_event",
627
+ event: {
628
+ type: "content_block_delta",
629
+ index: 0,
630
+ delta: {
631
+ type: "input_json_delta",
632
+ partial_json: '{"file_path":"/foo.ts"}',
633
+ },
634
+ },
635
+ }),
636
+ JSON.stringify({
637
+ type: "stream_event",
638
+ event: { type: "content_block_stop", index: 0 },
639
+ }),
640
+ JSON.stringify({
641
+ type: "stream_event",
642
+ event: {
643
+ type: "message_delta",
644
+ delta: { stop_reason: "tool_use" },
645
+ usage: { output_tokens: 5 },
646
+ },
647
+ }),
648
+ JSON.stringify({
649
+ type: "stream_event",
650
+ event: { type: "message_stop" },
651
+ }),
652
+ ];
653
+
654
+ for (const line of lines) {
655
+ proc.stdout.write(line + "\n");
656
+ }
657
+ // End stdout to let readline close
658
+ proc.stdout.end();
659
+ await vi.advanceTimersByTimeAsync(100);
660
+
661
+ // Verify process was killed (break-early)
662
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
663
+
664
+ // Verify the stream received a done event (from event bridge handleMessageStop)
665
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
666
+ const events = mockStream._events;
667
+ const eventTypes = events.map((e: any) => e.type);
668
+ expect(eventTypes).toContain("done");
669
+ expect(eventTypes).toContain("toolcall_start");
670
+ expect(eventTypes).toContain("toolcall_end");
671
+ });
672
+
673
+ it("kills subprocess at message_stop when custom-tools MCP tool seen", async () => {
674
+ const model = mockModels[0] as any;
675
+ const context = {
676
+ messages: [{ role: "user", content: "Search for something" }],
677
+ };
678
+
679
+ streamViaCli(model, context);
680
+ await vi.advanceTimersByTimeAsync(0);
681
+
682
+ const proc = (spawn as any).mock.results[0].value;
683
+
684
+ const lines = [
685
+ JSON.stringify({
686
+ type: "stream_event",
687
+ event: {
688
+ type: "message_start",
689
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
690
+ },
691
+ }),
692
+ JSON.stringify({
693
+ type: "stream_event",
694
+ event: {
695
+ type: "content_block_start",
696
+ index: 0,
697
+ content_block: {
698
+ type: "tool_use",
699
+ id: "tool_2",
700
+ name: "mcp__custom-tools__search",
701
+ input: "",
702
+ },
703
+ },
704
+ }),
705
+ JSON.stringify({
706
+ type: "stream_event",
707
+ event: { type: "content_block_stop", index: 0 },
708
+ }),
709
+ JSON.stringify({
710
+ type: "stream_event",
711
+ event: {
712
+ type: "message_delta",
713
+ delta: { stop_reason: "tool_use" },
714
+ usage: { output_tokens: 5 },
715
+ },
716
+ }),
717
+ JSON.stringify({
718
+ type: "stream_event",
719
+ event: { type: "message_stop" },
720
+ }),
721
+ ];
722
+
723
+ for (const line of lines) {
724
+ proc.stdout.write(line + "\n");
725
+ }
726
+ proc.stdout.end();
727
+ await vi.advanceTimersByTimeAsync(100);
728
+
729
+ // Verify process was killed (break-early for custom-tools MCP)
730
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
731
+ });
732
+
733
+ it("does NOT break-early when stream has no tool_use blocks", async () => {
734
+ const model = mockModels[0] as any;
735
+ const context = {
736
+ messages: [{ role: "user", content: "Hello" }],
737
+ };
738
+
739
+ streamViaCli(model, context);
740
+ await vi.advanceTimersByTimeAsync(0);
741
+
742
+ const proc = (spawn as any).mock.results[0].value;
743
+
744
+ // Text-only stream
745
+ const lines = [
746
+ JSON.stringify({
747
+ type: "stream_event",
748
+ event: {
749
+ type: "message_start",
750
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
751
+ },
752
+ }),
753
+ JSON.stringify({
754
+ type: "stream_event",
755
+ event: {
756
+ type: "content_block_start",
757
+ index: 0,
758
+ content_block: { type: "text", text: "" },
759
+ },
760
+ }),
761
+ JSON.stringify({
762
+ type: "stream_event",
763
+ event: {
764
+ type: "content_block_delta",
765
+ index: 0,
766
+ delta: { type: "text_delta", text: "Hello!" },
767
+ },
768
+ }),
769
+ JSON.stringify({
770
+ type: "stream_event",
771
+ event: { type: "content_block_stop", index: 0 },
772
+ }),
773
+ JSON.stringify({
774
+ type: "stream_event",
775
+ event: {
776
+ type: "message_delta",
777
+ delta: { stop_reason: "end_turn" },
778
+ usage: { output_tokens: 1 },
779
+ },
780
+ }),
781
+ JSON.stringify({
782
+ type: "stream_event",
783
+ event: { type: "message_stop" },
784
+ }),
785
+ JSON.stringify({
786
+ type: "result",
787
+ subtype: "success",
788
+ result: "Hello!",
789
+ }),
790
+ ];
791
+
792
+ for (const line of lines) {
793
+ proc.stdout.write(line + "\n");
794
+ }
795
+ proc.stdout.end();
796
+ await vi.advanceTimersByTimeAsync(100);
797
+
798
+ // Process should NOT have been killed with SIGKILL immediately
799
+ // It should only be killed via cleanupProcess after result (500ms grace)
800
+ const killCalls = proc.kill.mock.calls;
801
+ const sigkillBeforeResult = killCalls.filter(
802
+ (call: any[]) => call[0] === "SIGKILL",
803
+ );
804
+ // If killed, it was only after the cleanup grace period, not at message_stop
805
+ // The kill should only happen after we advance past the 500ms timer
806
+ expect(sigkillBeforeResult).toHaveLength(0);
807
+
808
+ // Now advance past cleanup timer
809
+ vi.advanceTimersByTime(500);
810
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
811
+ });
812
+
813
+ it("does NOT break-early for internal Claude Code tools (ToolSearch, Task, etc.)", async () => {
814
+ const model = mockModels[0] as any;
815
+ const context = {
816
+ messages: [{ role: "user", content: "Use weather tool" }],
817
+ };
818
+
819
+ streamViaCli(model, context);
820
+ await vi.advanceTimersByTimeAsync(0);
821
+
822
+ const proc = (spawn as any).mock.results[0].value;
823
+
824
+ const lines = [
825
+ JSON.stringify({
826
+ type: "stream_event",
827
+ event: {
828
+ type: "message_start",
829
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
830
+ },
831
+ }),
832
+ JSON.stringify({
833
+ type: "stream_event",
834
+ event: {
835
+ type: "content_block_start",
836
+ index: 0,
837
+ content_block: {
838
+ type: "tool_use",
839
+ id: "tool_ts",
840
+ name: "ToolSearch",
841
+ },
842
+ },
843
+ }),
844
+ JSON.stringify({
845
+ type: "stream_event",
846
+ event: { type: "content_block_stop", index: 0 },
847
+ }),
848
+ JSON.stringify({
849
+ type: "stream_event",
850
+ event: {
851
+ type: "message_delta",
852
+ delta: { stop_reason: "tool_use" },
853
+ usage: { output_tokens: 5 },
854
+ },
855
+ }),
856
+ JSON.stringify({
857
+ type: "stream_event",
858
+ event: { type: "message_stop" },
859
+ }),
860
+ JSON.stringify({
861
+ type: "result",
862
+ subtype: "success",
863
+ result: "ok",
864
+ }),
865
+ ];
866
+
867
+ for (const line of lines) {
868
+ proc.stdout.write(line + "\n");
869
+ }
870
+ proc.stdout.end();
871
+ await vi.advanceTimersByTimeAsync(100);
872
+
873
+ // Process should NOT have been killed at message_stop (ToolSearch is internal)
874
+ const killCalls = proc.kill.mock.calls;
875
+ const sigkillBeforeResult = killCalls.filter(
876
+ (call: any[]) => call[0] === "SIGKILL",
877
+ );
878
+ expect(sigkillBeforeResult).toHaveLength(0);
879
+
880
+ vi.advanceTimersByTime(500);
881
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
882
+ });
883
+
884
+ it("does NOT break-early for pi-known tools inside sub-agents (parent_tool_use_id set)", async () => {
885
+ const model = mockModels[0] as any;
886
+ const context = {
887
+ messages: [{ role: "user", content: "Run an agent" }],
888
+ };
889
+
890
+ streamViaCli(model, context);
891
+ await vi.advanceTimersByTimeAsync(0);
892
+
893
+ const proc = (spawn as any).mock.results[0].value;
894
+
895
+ // Top-level: Agent tool_use (not pi-known, no break-early)
896
+ // Then sub-agent uses Read (pi-known, but parent_tool_use_id is set)
897
+ // Then sub-agent message_stop (should NOT trigger break-early)
898
+ // Then top-level text response and message_stop (no tool_use, no break-early)
899
+ const lines = [
900
+ JSON.stringify({
901
+ type: "stream_event",
902
+ event: {
903
+ type: "message_start",
904
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
905
+ },
906
+ parent_tool_use_id: null,
907
+ }),
908
+ // Top-level: Agent tool call
909
+ JSON.stringify({
910
+ type: "stream_event",
911
+ event: {
912
+ type: "content_block_start",
913
+ index: 0,
914
+ content_block: {
915
+ type: "tool_use",
916
+ id: "agent_1",
917
+ name: "Agent",
918
+ },
919
+ },
920
+ parent_tool_use_id: null,
921
+ }),
922
+ JSON.stringify({
923
+ type: "stream_event",
924
+ event: { type: "content_block_stop", index: 0 },
925
+ parent_tool_use_id: null,
926
+ }),
927
+ JSON.stringify({
928
+ type: "stream_event",
929
+ event: {
930
+ type: "message_delta",
931
+ delta: { stop_reason: "tool_use" },
932
+ usage: { output_tokens: 5 },
933
+ },
934
+ parent_tool_use_id: null,
935
+ }),
936
+ JSON.stringify({
937
+ type: "stream_event",
938
+ event: { type: "message_stop" },
939
+ parent_tool_use_id: null,
940
+ }),
941
+ // Sub-agent: Read tool (pi-known, but inside sub-agent)
942
+ JSON.stringify({
943
+ type: "stream_event",
944
+ event: {
945
+ type: "content_block_start",
946
+ index: 0,
947
+ content_block: {
948
+ type: "tool_use",
949
+ id: "read_1",
950
+ name: "Read",
951
+ },
952
+ },
953
+ parent_tool_use_id: "agent_1",
954
+ }),
955
+ JSON.stringify({
956
+ type: "stream_event",
957
+ event: { type: "message_stop" },
958
+ parent_tool_use_id: "agent_1",
959
+ }),
960
+ // Top-level: final text response
961
+ JSON.stringify({
962
+ type: "stream_event",
963
+ event: {
964
+ type: "message_start",
965
+ message: { usage: { input_tokens: 20, output_tokens: 0 } },
966
+ },
967
+ parent_tool_use_id: null,
968
+ }),
969
+ JSON.stringify({
970
+ type: "stream_event",
971
+ event: {
972
+ type: "content_block_start",
973
+ index: 0,
974
+ content_block: { type: "text", text: "" },
975
+ },
976
+ parent_tool_use_id: null,
977
+ }),
978
+ JSON.stringify({
979
+ type: "stream_event",
980
+ event: {
981
+ type: "content_block_delta",
982
+ index: 0,
983
+ delta: { type: "text_delta", text: "Agent found the code." },
984
+ },
985
+ parent_tool_use_id: null,
986
+ }),
987
+ JSON.stringify({
988
+ type: "stream_event",
989
+ event: { type: "content_block_stop", index: 0 },
990
+ parent_tool_use_id: null,
991
+ }),
992
+ JSON.stringify({
993
+ type: "stream_event",
994
+ event: {
995
+ type: "message_delta",
996
+ delta: { stop_reason: "end_turn" },
997
+ usage: { output_tokens: 10 },
998
+ },
999
+ parent_tool_use_id: null,
1000
+ }),
1001
+ JSON.stringify({
1002
+ type: "stream_event",
1003
+ event: { type: "message_stop" },
1004
+ parent_tool_use_id: null,
1005
+ }),
1006
+ JSON.stringify({
1007
+ type: "result",
1008
+ subtype: "success",
1009
+ result: "Agent found the code.",
1010
+ }),
1011
+ ];
1012
+
1013
+ for (const line of lines) {
1014
+ proc.stdout.write(line + "\n");
1015
+ }
1016
+ proc.stdout.end();
1017
+ await vi.advanceTimersByTimeAsync(100);
1018
+
1019
+ // Should NOT have been killed at any message_stop (no top-level pi-known tools)
1020
+ // The Read inside the sub-agent should be ignored for break-early
1021
+ const killBeforeResult = proc.kill.mock.calls.filter(
1022
+ (call: any[]) => call[0] === "SIGKILL",
1023
+ );
1024
+ expect(killBeforeResult).toHaveLength(0);
1025
+
1026
+ // Should have received the final text response
1027
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1028
+ const textEvents = mockStream._events.filter(
1029
+ (e: any) => e.type === "text_delta",
1030
+ );
1031
+ expect(textEvents.length).toBeGreaterThan(0);
1032
+
1033
+ vi.advanceTimersByTime(500);
1034
+ });
1035
+
1036
+ it("does NOT break-early when only user MCP tools are seen (not custom-tools)", async () => {
1037
+ const model = mockModels[0] as any;
1038
+ const context = {
1039
+ messages: [{ role: "user", content: "Use user MCP tool" }],
1040
+ };
1041
+
1042
+ streamViaCli(model, context);
1043
+ await vi.advanceTimersByTimeAsync(0);
1044
+
1045
+ const proc = (spawn as any).mock.results[0].value;
1046
+
1047
+ const lines = [
1048
+ JSON.stringify({
1049
+ type: "stream_event",
1050
+ event: {
1051
+ type: "message_start",
1052
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1053
+ },
1054
+ }),
1055
+ JSON.stringify({
1056
+ type: "stream_event",
1057
+ event: {
1058
+ type: "content_block_start",
1059
+ index: 0,
1060
+ content_block: {
1061
+ type: "tool_use",
1062
+ id: "tool_3",
1063
+ name: "mcp__user-server__tool",
1064
+ input: "",
1065
+ },
1066
+ },
1067
+ }),
1068
+ JSON.stringify({
1069
+ type: "stream_event",
1070
+ event: { type: "content_block_stop", index: 0 },
1071
+ }),
1072
+ JSON.stringify({
1073
+ type: "stream_event",
1074
+ event: {
1075
+ type: "message_delta",
1076
+ delta: { stop_reason: "tool_use" },
1077
+ usage: { output_tokens: 5 },
1078
+ },
1079
+ }),
1080
+ JSON.stringify({
1081
+ type: "stream_event",
1082
+ event: { type: "message_stop" },
1083
+ }),
1084
+ JSON.stringify({
1085
+ type: "result",
1086
+ subtype: "success",
1087
+ result: "ok",
1088
+ }),
1089
+ ];
1090
+
1091
+ for (const line of lines) {
1092
+ proc.stdout.write(line + "\n");
1093
+ }
1094
+ proc.stdout.end();
1095
+ await vi.advanceTimersByTimeAsync(100);
1096
+
1097
+ // Process should NOT have been killed at message_stop (only user MCP tool)
1098
+ const killCalls = proc.kill.mock.calls;
1099
+ const sigkillBeforeResult = killCalls.filter(
1100
+ (call: any[]) => call[0] === "SIGKILL",
1101
+ );
1102
+ expect(sigkillBeforeResult).toHaveLength(0);
1103
+
1104
+ // After cleanup grace period, process gets killed
1105
+ vi.advanceTimersByTime(500);
1106
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
1107
+ });
1108
+ });
1109
+
1110
+ describe("subprocess error handling", () => {
1111
+ it("pushes done event when subprocess emits error (e.g. spawn failure)", async () => {
1112
+ const model = mockModels[0] as any;
1113
+ const context = {
1114
+ messages: [{ role: "user", content: "Hello" }],
1115
+ };
1116
+
1117
+ streamViaCli(model, context);
1118
+ await vi.advanceTimersByTimeAsync(0);
1119
+
1120
+ const proc = (spawn as any).mock.results[0].value;
1121
+
1122
+ // Emit process error (e.g. ENOENT from failed spawn)
1123
+ proc.emit("error", new Error("spawn ENOENT"));
1124
+ proc.stdout.end();
1125
+ await vi.advanceTimersByTimeAsync(100);
1126
+
1127
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1128
+ const doneEvent = mockStream._events.find(
1129
+ (e: any) => e.type === "done" && e.message,
1130
+ );
1131
+ expect(doneEvent).toBeDefined();
1132
+ expect(doneEvent.message.content).toBeDefined();
1133
+ expect(mockStream.end).toHaveBeenCalled();
1134
+ });
1135
+
1136
+ it("pushes error event when subprocess crashes with non-zero exit code", async () => {
1137
+ const model = mockModels[0] as any;
1138
+ const context = {
1139
+ messages: [{ role: "user", content: "Hello" }],
1140
+ };
1141
+
1142
+ streamViaCli(model, context);
1143
+ await vi.advanceTimersByTimeAsync(0);
1144
+
1145
+ const proc = (spawn as any).mock.results[0].value;
1146
+
1147
+ // Emit close with non-zero exit code (no result written first)
1148
+ proc.emit("close", 1, null);
1149
+ proc.stdout.end();
1150
+ await vi.advanceTimersByTimeAsync(100);
1151
+
1152
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1153
+ const doneEvent = mockStream._events.find(
1154
+ (e: any) => e.type === "done" && e.message,
1155
+ );
1156
+ expect(doneEvent).toBeDefined();
1157
+ expect(doneEvent.message.content).toBeDefined();
1158
+ expect(mockStream.end).toHaveBeenCalled();
1159
+ });
1160
+
1161
+ it("includes stderr in error event on crash", async () => {
1162
+ const model = mockModels[0] as any;
1163
+ const context = {
1164
+ messages: [{ role: "user", content: "Hello" }],
1165
+ };
1166
+
1167
+ streamViaCli(model, context);
1168
+ await vi.advanceTimersByTimeAsync(0);
1169
+
1170
+ const proc = (spawn as any).mock.results[0].value;
1171
+
1172
+ // Emit stderr data, then close with non-zero exit
1173
+ proc.stderr.emit("data", Buffer.from("segfault in libfoo.so"));
1174
+ proc.emit("close", 139, null);
1175
+ proc.stdout.end();
1176
+ await vi.advanceTimersByTimeAsync(100);
1177
+
1178
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1179
+ const doneEvent = mockStream._events.find(
1180
+ (e: any) => e.type === "done" && e.message,
1181
+ );
1182
+ expect(doneEvent).toBeDefined();
1183
+ expect(doneEvent.message.content).toBeDefined();
1184
+ });
1185
+
1186
+ it("does not push error on normal close (code 0)", async () => {
1187
+ const model = mockModels[0] as any;
1188
+ const context = {
1189
+ messages: [{ role: "user", content: "Hello" }],
1190
+ };
1191
+
1192
+ streamViaCli(model, context);
1193
+ await vi.advanceTimersByTimeAsync(0);
1194
+
1195
+ const proc = (spawn as any).mock.results[0].value;
1196
+
1197
+ // Write result to stdout then close with code 0
1198
+ const lines = [
1199
+ JSON.stringify({
1200
+ type: "stream_event",
1201
+ event: {
1202
+ type: "message_start",
1203
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1204
+ },
1205
+ }),
1206
+ JSON.stringify({
1207
+ type: "stream_event",
1208
+ event: { type: "message_stop" },
1209
+ }),
1210
+ JSON.stringify({ type: "result", subtype: "success", result: "ok" }),
1211
+ ];
1212
+ for (const line of lines) {
1213
+ proc.stdout.write(line + "\n");
1214
+ }
1215
+ proc.emit("close", 0, null);
1216
+ proc.stdout.end();
1217
+ await vi.advanceTimersByTimeAsync(100);
1218
+
1219
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1220
+ const errorEvent = mockStream._events.find(
1221
+ (e: any) => e.type === "error",
1222
+ );
1223
+ expect(errorEvent).toBeUndefined();
1224
+ });
1225
+
1226
+ it("does not push error after break-early (broken flag)", async () => {
1227
+ const model = mockModels[0] as any;
1228
+ const context = {
1229
+ messages: [{ role: "user", content: "Read a file" }],
1230
+ };
1231
+
1232
+ streamViaCli(model, context);
1233
+ await vi.advanceTimersByTimeAsync(0);
1234
+
1235
+ const proc = (spawn as any).mock.results[0].value;
1236
+
1237
+ // Simulate tool_use break-early sequence
1238
+ const lines = [
1239
+ JSON.stringify({
1240
+ type: "stream_event",
1241
+ event: {
1242
+ type: "message_start",
1243
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1244
+ },
1245
+ }),
1246
+ JSON.stringify({
1247
+ type: "stream_event",
1248
+ event: {
1249
+ type: "content_block_start",
1250
+ index: 0,
1251
+ content_block: {
1252
+ type: "tool_use",
1253
+ id: "tool_1",
1254
+ name: "Read",
1255
+ input: "",
1256
+ },
1257
+ },
1258
+ }),
1259
+ JSON.stringify({
1260
+ type: "stream_event",
1261
+ event: { type: "content_block_stop", index: 0 },
1262
+ }),
1263
+ JSON.stringify({
1264
+ type: "stream_event",
1265
+ event: {
1266
+ type: "message_delta",
1267
+ delta: { stop_reason: "tool_use" },
1268
+ usage: { output_tokens: 5 },
1269
+ },
1270
+ }),
1271
+ JSON.stringify({
1272
+ type: "stream_event",
1273
+ event: { type: "message_stop" },
1274
+ }),
1275
+ ];
1276
+ for (const line of lines) {
1277
+ proc.stdout.write(line + "\n");
1278
+ }
1279
+ await vi.advanceTimersByTimeAsync(50);
1280
+
1281
+ // Now emit close with non-zero code (from SIGKILL)
1282
+ proc.emit("close", null, "SIGKILL");
1283
+ proc.stdout.end();
1284
+ await vi.advanceTimersByTimeAsync(100);
1285
+
1286
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1287
+ // Should have done event but no error event
1288
+ const eventTypes = mockStream._events.map((e: any) => e.type);
1289
+ expect(eventTypes).toContain("done");
1290
+ expect(eventTypes).not.toContain("error");
1291
+ });
1292
+ });
1293
+
1294
+ describe("inactivity timeout", () => {
1295
+ it("kills subprocess and pushes error after 180s of no output", async () => {
1296
+ const model = mockModels[0] as any;
1297
+ const context = {
1298
+ messages: [{ role: "user", content: "Hello" }],
1299
+ };
1300
+
1301
+ streamViaCli(model, context);
1302
+ await vi.advanceTimersByTimeAsync(0);
1303
+
1304
+ const proc = (spawn as any).mock.results[0].value;
1305
+
1306
+ // Advance timers by 180 seconds without writing to stdout
1307
+ await vi.advanceTimersByTimeAsync(180_000);
1308
+
1309
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1310
+ const doneEvent = mockStream._events.find(
1311
+ (e: any) => e.type === "done" && e.message,
1312
+ );
1313
+ expect(doneEvent).toBeDefined();
1314
+ expect(doneEvent.message.content).toBeDefined();
1315
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
1316
+
1317
+ // Clean up - end stdout so readline closes
1318
+ proc.stdout.end();
1319
+ await vi.advanceTimersByTimeAsync(100);
1320
+ });
1321
+
1322
+ it("resets timer on each stdout line", async () => {
1323
+ const model = mockModels[0] as any;
1324
+ const context = {
1325
+ messages: [{ role: "user", content: "Hello" }],
1326
+ };
1327
+
1328
+ streamViaCli(model, context);
1329
+ await vi.advanceTimersByTimeAsync(0);
1330
+
1331
+ const proc = (spawn as any).mock.results[0].value;
1332
+
1333
+ // Advance to 170s then write a line
1334
+ await vi.advanceTimersByTimeAsync(170_000);
1335
+
1336
+ // Write a stream event line
1337
+ proc.stdout.write(
1338
+ JSON.stringify({
1339
+ type: "stream_event",
1340
+ event: {
1341
+ type: "message_start",
1342
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1343
+ },
1344
+ }) + "\n",
1345
+ );
1346
+ await vi.advanceTimersByTimeAsync(0);
1347
+
1348
+ // Advance another 170s (340s total, 170s since last line) -- should NOT timeout
1349
+ await vi.advanceTimersByTimeAsync(170_000);
1350
+
1351
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1352
+ const doneEvent = mockStream._events.find(
1353
+ (e: any) => e.type === "done" && e.message,
1354
+ );
1355
+ expect(doneEvent).toBeUndefined();
1356
+
1357
+ // Advance 10 more seconds (180s since last line) -- NOW should timeout
1358
+ await vi.advanceTimersByTimeAsync(10_000);
1359
+
1360
+ const doneEvent2 = mockStream._events.find(
1361
+ (e: any) => e.type === "done" && e.message,
1362
+ );
1363
+ expect(doneEvent2).toBeDefined();
1364
+ expect(doneEvent2.message.content).toBeDefined();
1365
+
1366
+ // Clean up
1367
+ proc.stdout.end();
1368
+ await vi.advanceTimersByTimeAsync(100);
1369
+ });
1370
+
1371
+ it("clears timer on normal completion", async () => {
1372
+ const model = mockModels[0] as any;
1373
+ const context = {
1374
+ messages: [{ role: "user", content: "Hello" }],
1375
+ };
1376
+
1377
+ streamViaCli(model, context);
1378
+ await vi.advanceTimersByTimeAsync(0);
1379
+
1380
+ const proc = (spawn as any).mock.results[0].value;
1381
+
1382
+ // Write normal result to stdout
1383
+ const lines = [
1384
+ JSON.stringify({
1385
+ type: "stream_event",
1386
+ event: {
1387
+ type: "message_start",
1388
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1389
+ },
1390
+ }),
1391
+ JSON.stringify({
1392
+ type: "stream_event",
1393
+ event: { type: "message_stop" },
1394
+ }),
1395
+ JSON.stringify({ type: "result", subtype: "success", result: "ok" }),
1396
+ ];
1397
+ for (const line of lines) {
1398
+ proc.stdout.write(line + "\n");
1399
+ }
1400
+ proc.stdout.end();
1401
+ await vi.advanceTimersByTimeAsync(100);
1402
+
1403
+ // Advance past 180s -- should NOT timeout since result was received
1404
+ await vi.advanceTimersByTimeAsync(180_000);
1405
+
1406
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1407
+ const errorEvents = mockStream._events.filter(
1408
+ (e: any) => e.type === "error",
1409
+ );
1410
+ expect(errorEvents).toHaveLength(0);
1411
+ });
1412
+ });
1413
+
1414
+ describe("abort handler fix", () => {
1415
+ it("abort signal sends SIGKILL not SIGTERM", async () => {
1416
+ const model = mockModels[0] as any;
1417
+ const context = {
1418
+ messages: [{ role: "user", content: "Hello" }],
1419
+ };
1420
+ const controller = new AbortController();
1421
+
1422
+ streamViaCli(model, context, { signal: controller.signal });
1423
+ await vi.advanceTimersByTimeAsync(0);
1424
+
1425
+ const proc = (spawn as any).mock.results[0].value;
1426
+
1427
+ // Trigger abort
1428
+ controller.abort();
1429
+ await vi.advanceTimersByTimeAsync(0);
1430
+
1431
+ // Verify SIGKILL was used (not SIGTERM)
1432
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
1433
+ // Ensure SIGTERM was NOT used
1434
+ const sigTermCalls = proc.kill.mock.calls.filter(
1435
+ (call: any[]) => call[0] === "SIGTERM",
1436
+ );
1437
+ expect(sigTermCalls).toHaveLength(0);
1438
+
1439
+ // Clean up
1440
+ proc.stdout.end();
1441
+ await vi.advanceTimersByTimeAsync(100);
1442
+ });
1443
+ });
1444
+
1445
+ describe("abort signal already aborted", () => {
1446
+ it("kills subprocess immediately when signal is already aborted", async () => {
1447
+ const model = mockModels[0] as any;
1448
+ const context = {
1449
+ messages: [{ role: "user", content: "Hello" }],
1450
+ };
1451
+ const controller = new AbortController();
1452
+ controller.abort(); // Abort BEFORE calling streamViaCli
1453
+
1454
+ streamViaCli(model, context, { signal: controller.signal });
1455
+ await vi.advanceTimersByTimeAsync(0);
1456
+
1457
+ const proc = (spawn as any).mock.results[0].value;
1458
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
1459
+
1460
+ // Clean up
1461
+ proc.stdout.end();
1462
+ await vi.advanceTimersByTimeAsync(100);
1463
+ });
1464
+ });
1465
+
1466
+ describe("MCP config with custom tool results", () => {
1467
+ it("keeps MCP config even when conversation ends with custom tool result", async () => {
1468
+ const model = mockModels[0] as any;
1469
+ const context = {
1470
+ messages: [
1471
+ { role: "user", content: "deploy it" },
1472
+ {
1473
+ role: "assistant",
1474
+ content: [
1475
+ { type: "toolCall", name: "deploy", arguments: { env: "prod" } },
1476
+ ],
1477
+ },
1478
+ {
1479
+ role: "toolResult",
1480
+ content: "Deployed successfully",
1481
+ toolName: "deploy",
1482
+ },
1483
+ ],
1484
+ };
1485
+
1486
+ streamViaCli(model, context, {
1487
+ mcpConfigPath: "/tmp/mcp.json",
1488
+ } as any);
1489
+ await vi.advanceTimersByTimeAsync(0);
1490
+
1491
+ const args = (spawn as any).mock.calls[0][1] as string[];
1492
+ // MCP config should always be passed so consecutive MCP tool calls work
1493
+ expect(args).toContain("--mcp-config");
1494
+
1495
+ // Clean up
1496
+ const proc = (spawn as any).mock.results[0].value;
1497
+ proc.stdout.end();
1498
+ await vi.advanceTimersByTimeAsync(100);
1499
+ });
1500
+
1501
+ it("does NOT suppress MCP config when conversation ends with user message", async () => {
1502
+ const model = mockModels[0] as any;
1503
+ const context = {
1504
+ messages: [{ role: "user", content: "Hello" }],
1505
+ };
1506
+
1507
+ streamViaCli(model, context, {
1508
+ mcpConfigPath: "/tmp/mcp.json",
1509
+ } as any);
1510
+ await vi.advanceTimersByTimeAsync(0);
1511
+
1512
+ const args = (spawn as any).mock.calls[0][1] as string[];
1513
+ expect(args).toContain("--mcp-config");
1514
+
1515
+ // Clean up
1516
+ const proc = (spawn as any).mock.results[0].value;
1517
+ proc.stdout.end();
1518
+ await vi.advanceTimersByTimeAsync(100);
1519
+ });
1520
+ });
1521
+
1522
+ describe("effectiveReason override logic", () => {
1523
+ it("overrides toolUse stopReason to stop when no pi-known tool calls in content", async () => {
1524
+ const model = mockModels[0] as any;
1525
+ const context = {
1526
+ messages: [{ role: "user", content: "Hello" }],
1527
+ };
1528
+
1529
+ streamViaCli(model, context);
1530
+ await vi.advanceTimersByTimeAsync(0);
1531
+
1532
+ const proc = (spawn as any).mock.results[0].value;
1533
+
1534
+ // Stream a sequence where Claude calls a user MCP tool (not pi-known)
1535
+ // The event bridge filters it out so content has no toolCall items
1536
+ const lines = [
1537
+ JSON.stringify({
1538
+ type: "stream_event",
1539
+ event: {
1540
+ type: "message_start",
1541
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1542
+ },
1543
+ }),
1544
+ JSON.stringify({
1545
+ type: "stream_event",
1546
+ event: {
1547
+ type: "content_block_start",
1548
+ index: 0,
1549
+ content_block: {
1550
+ type: "tool_use",
1551
+ id: "tool_user",
1552
+ name: "mcp__user-server__tool",
1553
+ },
1554
+ },
1555
+ }),
1556
+ JSON.stringify({
1557
+ type: "stream_event",
1558
+ event: { type: "content_block_stop", index: 0 },
1559
+ }),
1560
+ JSON.stringify({
1561
+ type: "stream_event",
1562
+ event: {
1563
+ type: "message_delta",
1564
+ delta: { stop_reason: "tool_use" },
1565
+ usage: { output_tokens: 5 },
1566
+ },
1567
+ }),
1568
+ JSON.stringify({
1569
+ type: "stream_event",
1570
+ event: { type: "message_stop" },
1571
+ }),
1572
+ JSON.stringify({ type: "result", subtype: "success", result: "ok" }),
1573
+ ];
1574
+ for (const line of lines) {
1575
+ proc.stdout.write(line + "\n");
1576
+ }
1577
+ proc.stdout.end();
1578
+ await vi.advanceTimersByTimeAsync(100);
1579
+
1580
+ // Advance past cleanup
1581
+ vi.advanceTimersByTime(500);
1582
+
1583
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1584
+ const doneEvent = mockStream._events.find((e: any) => e.type === "done");
1585
+ expect(doneEvent).toBeDefined();
1586
+ // Reason should be overridden to "stop" (not "toolUse")
1587
+ expect(doneEvent.reason).toBe("stop");
1588
+ expect(doneEvent.message.stopReason).toBe("stop");
1589
+ });
1590
+
1591
+ it("keeps toolUse stopReason when pi-known tool calls are present", async () => {
1592
+ const model = mockModels[0] as any;
1593
+ const context = {
1594
+ messages: [{ role: "user", content: "Read a file" }],
1595
+ };
1596
+
1597
+ streamViaCli(model, context);
1598
+ await vi.advanceTimersByTimeAsync(0);
1599
+
1600
+ const proc = (spawn as any).mock.results[0].value;
1601
+
1602
+ // Stream a sequence where Claude calls a built-in tool (Read)
1603
+ const lines = [
1604
+ JSON.stringify({
1605
+ type: "stream_event",
1606
+ event: {
1607
+ type: "message_start",
1608
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1609
+ },
1610
+ }),
1611
+ JSON.stringify({
1612
+ type: "stream_event",
1613
+ event: {
1614
+ type: "content_block_start",
1615
+ index: 0,
1616
+ content_block: {
1617
+ type: "tool_use",
1618
+ id: "tool_read",
1619
+ name: "Read",
1620
+ input: "",
1621
+ },
1622
+ },
1623
+ }),
1624
+ JSON.stringify({
1625
+ type: "stream_event",
1626
+ event: {
1627
+ type: "content_block_delta",
1628
+ index: 0,
1629
+ delta: {
1630
+ type: "input_json_delta",
1631
+ partial_json: '{"file_path":"/foo.ts"}',
1632
+ },
1633
+ },
1634
+ }),
1635
+ JSON.stringify({
1636
+ type: "stream_event",
1637
+ event: { type: "content_block_stop", index: 0 },
1638
+ }),
1639
+ JSON.stringify({
1640
+ type: "stream_event",
1641
+ event: {
1642
+ type: "message_delta",
1643
+ delta: { stop_reason: "tool_use" },
1644
+ usage: { output_tokens: 5 },
1645
+ },
1646
+ }),
1647
+ JSON.stringify({
1648
+ type: "stream_event",
1649
+ event: { type: "message_stop" },
1650
+ }),
1651
+ ];
1652
+ for (const line of lines) {
1653
+ proc.stdout.write(line + "\n");
1654
+ }
1655
+ // Break-early kills and closes readline
1656
+ await vi.advanceTimersByTimeAsync(100);
1657
+
1658
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1659
+ const doneEvent = mockStream._events.find((e: any) => e.type === "done");
1660
+ expect(doneEvent).toBeDefined();
1661
+ expect(doneEvent.reason).toBe("toolUse");
1662
+ expect(doneEvent.message.stopReason).toBe("toolUse");
1663
+
1664
+ // Clean up
1665
+ proc.stdout.end();
1666
+ await vi.advanceTimersByTimeAsync(100);
1667
+ });
1668
+
1669
+ it("handles undefined output.content without crashing", async () => {
1670
+ const model = mockModels[0] as any;
1671
+ const context = {
1672
+ messages: [{ role: "user", content: "Hello" }],
1673
+ };
1674
+
1675
+ streamViaCli(model, context);
1676
+ await vi.advanceTimersByTimeAsync(0);
1677
+
1678
+ const proc = (spawn as any).mock.results[0].value;
1679
+
1680
+ // Stream a minimal sequence with no content blocks — just message_start,
1681
+ // message_delta with stop_reason, message_stop, and result.
1682
+ // This produces output.content = undefined in the event bridge.
1683
+ const lines = [
1684
+ JSON.stringify({
1685
+ type: "stream_event",
1686
+ event: {
1687
+ type: "message_start",
1688
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1689
+ },
1690
+ }),
1691
+ JSON.stringify({
1692
+ type: "stream_event",
1693
+ event: {
1694
+ type: "message_delta",
1695
+ delta: { stop_reason: "end_turn" },
1696
+ usage: { output_tokens: 0 },
1697
+ },
1698
+ }),
1699
+ JSON.stringify({
1700
+ type: "stream_event",
1701
+ event: { type: "message_stop" },
1702
+ }),
1703
+ JSON.stringify({ type: "result", subtype: "success", result: "ok" }),
1704
+ ];
1705
+ for (const line of lines) {
1706
+ proc.stdout.write(line + "\n");
1707
+ }
1708
+ proc.stdout.end();
1709
+ await vi.advanceTimersByTimeAsync(100);
1710
+
1711
+ vi.advanceTimersByTime(500);
1712
+
1713
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1714
+ const doneEvent = mockStream._events.find((e: any) => e.type === "done");
1715
+ expect(doneEvent).toBeDefined();
1716
+ // Should not crash — stopReason should be "stop" (end_turn maps to stop)
1717
+ expect(doneEvent.reason).toBe("stop");
1718
+ });
1719
+
1720
+ it("passes through length stopReason unchanged", async () => {
1721
+ const model = mockModels[0] as any;
1722
+ const context = {
1723
+ messages: [{ role: "user", content: "Write a very long essay" }],
1724
+ };
1725
+
1726
+ streamViaCli(model, context);
1727
+ await vi.advanceTimersByTimeAsync(0);
1728
+
1729
+ const proc = (spawn as any).mock.results[0].value;
1730
+
1731
+ const lines = [
1732
+ JSON.stringify({
1733
+ type: "stream_event",
1734
+ event: {
1735
+ type: "message_start",
1736
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
1737
+ },
1738
+ }),
1739
+ JSON.stringify({
1740
+ type: "stream_event",
1741
+ event: {
1742
+ type: "content_block_start",
1743
+ index: 0,
1744
+ content_block: { type: "text", text: "" },
1745
+ },
1746
+ }),
1747
+ JSON.stringify({
1748
+ type: "stream_event",
1749
+ event: {
1750
+ type: "content_block_delta",
1751
+ index: 0,
1752
+ delta: { type: "text_delta", text: "Long text..." },
1753
+ },
1754
+ }),
1755
+ JSON.stringify({
1756
+ type: "stream_event",
1757
+ event: { type: "content_block_stop", index: 0 },
1758
+ }),
1759
+ JSON.stringify({
1760
+ type: "stream_event",
1761
+ event: {
1762
+ type: "message_delta",
1763
+ delta: { stop_reason: "max_tokens" },
1764
+ usage: { output_tokens: 8192 },
1765
+ },
1766
+ }),
1767
+ JSON.stringify({
1768
+ type: "stream_event",
1769
+ event: { type: "message_stop" },
1770
+ }),
1771
+ JSON.stringify({ type: "result", subtype: "success", result: "ok" }),
1772
+ ];
1773
+ for (const line of lines) {
1774
+ proc.stdout.write(line + "\n");
1775
+ }
1776
+ proc.stdout.end();
1777
+ await vi.advanceTimersByTimeAsync(100);
1778
+
1779
+ vi.advanceTimersByTime(500);
1780
+
1781
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1782
+ const doneEvent = mockStream._events.find((e: any) => e.type === "done");
1783
+ expect(doneEvent).toBeDefined();
1784
+ expect(doneEvent.reason).toBe("length");
1785
+ expect(doneEvent.message.stopReason).toBe("length");
1786
+ });
1787
+ });
1788
+
1789
+ describe("session resume via options.sessionId", () => {
1790
+ it("passes --resume when sessionId option is provided on subsequent turn", async () => {
1791
+ const model = mockModels[0] as any;
1792
+ const context = {
1793
+ messages: [
1794
+ { role: "user", content: "Hello" },
1795
+ { role: "assistant", content: "Hi" },
1796
+ { role: "user", content: "Follow-up" },
1797
+ ],
1798
+ };
1799
+
1800
+ streamViaCli(model, context, { sessionId: "sess-abc-123" } as any);
1801
+ await vi.advanceTimersByTimeAsync(0);
1802
+
1803
+ const args = (spawn as any).mock.calls[0][1] as string[];
1804
+ expect(args).toContain("--resume");
1805
+ const idx = args.indexOf("--resume");
1806
+ expect(args[idx + 1]).toBe("sess-abc-123");
1807
+
1808
+ // Clean up
1809
+ const proc = (spawn as any).mock.results[0].value;
1810
+ proc.stdout.end();
1811
+ await vi.advanceTimersByTimeAsync(100);
1812
+ });
1813
+
1814
+ it("passes --session-id on first turn when sessionId provided", async () => {
1815
+ const model = mockModels[0] as any;
1816
+ const context = {
1817
+ messages: [{ role: "user", content: "Hello" }],
1818
+ };
1819
+
1820
+ streamViaCli(model, context, { sessionId: "sess-new" } as any);
1821
+ await vi.advanceTimersByTimeAsync(0);
1822
+
1823
+ const args = (spawn as any).mock.calls[0][1] as string[];
1824
+ expect(args).not.toContain("--resume");
1825
+ expect(args).toContain("--session-id");
1826
+ const idx = args.indexOf("--session-id");
1827
+ expect(args[idx + 1]).toBe("sess-new");
1828
+
1829
+ // Clean up
1830
+ const proc = (spawn as any).mock.results[0].value;
1831
+ proc.stdout.end();
1832
+ await vi.advanceTimersByTimeAsync(100);
1833
+ });
1834
+
1835
+ it("does not pass --resume or --session-id when no sessionId option", async () => {
1836
+ const model = mockModels[0] as any;
1837
+ const context = {
1838
+ messages: [{ role: "user", content: "Hello" }],
1839
+ };
1840
+
1841
+ streamViaCli(model, context);
1842
+ await vi.advanceTimersByTimeAsync(0);
1843
+
1844
+ const args = (spawn as any).mock.calls[0][1] as string[];
1845
+ expect(args).not.toContain("--resume");
1846
+ expect(args).not.toContain("--session-id");
1847
+
1848
+ // Clean up
1849
+ const proc = (spawn as any).mock.results[0].value;
1850
+ proc.stdout.end();
1851
+ await vi.advanceTimersByTimeAsync(100);
1852
+ });
1853
+
1854
+ it("uses buildResumePrompt when sessionId is provided (sends only new content)", async () => {
1855
+ const model = mockModels[0] as any;
1856
+ const context = {
1857
+ messages: [
1858
+ { role: "user", content: "first message" },
1859
+ { role: "assistant", content: "response" },
1860
+ { role: "user", content: "follow-up" },
1861
+ ],
1862
+ };
1863
+
1864
+ streamViaCli(model, context, { sessionId: "sess-resume" } as any);
1865
+ await vi.advanceTimersByTimeAsync(0);
1866
+
1867
+ const proc = (spawn as any).mock.results[0].value;
1868
+ const written = proc.stdin.write.mock.calls[0][0] as string;
1869
+ const parsed = JSON.parse(written.trim());
1870
+ // Should only contain the latest user message, not full history
1871
+ expect(parsed.message.content).toBe("follow-up");
1872
+
1873
+ // Clean up
1874
+ proc.stdout.end();
1875
+ await vi.advanceTimersByTimeAsync(100);
1876
+ });
1877
+
1878
+ it("does not pass system prompt when resuming", async () => {
1879
+ const model = mockModels[0] as any;
1880
+ const context = {
1881
+ messages: [
1882
+ { role: "user", content: "Hello" },
1883
+ { role: "assistant", content: "Hi" },
1884
+ { role: "user", content: "follow-up" },
1885
+ ],
1886
+ systemPrompt: "Be helpful",
1887
+ };
1888
+
1889
+ streamViaCli(model, context, { sessionId: "sess-resume" } as any);
1890
+ await vi.advanceTimersByTimeAsync(0);
1891
+
1892
+ const args = (spawn as any).mock.calls[0][1] as string[];
1893
+ expect(args).toContain("--resume");
1894
+ expect(args).not.toContain("--append-system-prompt");
1895
+
1896
+ // Clean up
1897
+ const proc = (spawn as any).mock.results[0].value;
1898
+ proc.stdout.end();
1899
+ await vi.advanceTimersByTimeAsync(100);
1900
+ });
1901
+ });
1902
+ });