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