@runfusion/fusion 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +6533 -2558
- package/dist/client/assets/AgentDetailView-C6BG7O7i.js +18 -0
- package/dist/client/assets/AgentDetailView-CUtWvXBn.css +1 -0
- package/dist/client/assets/ChatView-DeXUYwSY.js +1 -0
- package/dist/client/assets/{DevServerView-r6V3FqkY.js → DevServerView-Dariyxt_.js} +1 -1
- package/dist/client/assets/{DirectoryPicker-CTZE95Fk.js → DirectoryPicker-SchiK-Aq.js} +1 -1
- package/dist/client/assets/{DocumentsView-DSEf1Lmg.js → DocumentsView-C6v-tBhG.js} +1 -1
- package/dist/client/assets/InsightsView-AWo5o_81.css +1 -0
- package/dist/client/assets/InsightsView-Cqim12az.js +11 -0
- package/dist/client/assets/{MemoryView-DicXjec9.js → MemoryView-CakLoJtY.js} +2 -2
- package/dist/client/assets/NodesView-BxGm3poT.js +14 -0
- package/dist/client/assets/{NodesView-sJgPLTzz.css → NodesView-fXqDk9ur.css} +1 -1
- package/dist/client/assets/PiExtensionsManager-lJbmskyZ.js +6 -0
- package/dist/client/assets/PluginManager-BZjNNf9m.js +1 -0
- package/dist/client/assets/ResearchView-Bzsr9V0y.js +1 -0
- package/dist/client/assets/{RoadmapsView-DfEF3mql.js → RoadmapsView-CeKks_OI.js} +2 -2
- package/dist/client/assets/SettingsModal-BWe0KrGY.css +1 -0
- package/dist/client/assets/SettingsModal-D-9CLguN.js +31 -0
- package/dist/client/assets/{SettingsModal-YcScdFiG.js → SettingsModal-YdeVPhRJ.js} +1 -1
- package/dist/client/assets/{SetupWizardModal-DRF5fOoR.css → SetupWizardModal-CGYGKurR.css} +1 -1
- package/dist/client/assets/SetupWizardModal-DAC04LlA.js +1 -0
- package/dist/client/assets/{SkillsView-Dkq2CQla.js → SkillsView-CClC_5RN.js} +1 -1
- package/dist/client/assets/index-CrHLf3pB.js +1222 -0
- package/dist/client/assets/index-Df1bHDY4.css +1 -0
- package/dist/client/assets/star-DxVRh9VT.js +6 -0
- package/dist/client/assets/{users-Cp5TSxVm.js → users-3SD3oNMQ.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/version.json +1 -1
- package/dist/droid-cli/index.ts +3 -5
- package/dist/droid-cli/package.json +1 -1
- package/dist/droid-cli/src/__tests__/event-bridge.test.ts +6 -1315
- package/dist/droid-cli/src/__tests__/provider.test.ts +6 -1927
- package/dist/droid-cli/src/control-handler.ts +1 -82
- package/dist/droid-cli/src/event-bridge.ts +1 -397
- package/dist/droid-cli/src/mcp-config.ts +1 -144
- package/dist/droid-cli/src/process-manager.ts +1 -358
- package/dist/droid-cli/src/prompt-builder.ts +1 -629
- package/dist/droid-cli/src/provider.ts +1 -447
- package/dist/droid-cli/src/stream-parser.ts +1 -37
- package/dist/droid-cli/src/thinking-config.ts +1 -83
- package/dist/droid-cli/src/tool-mapping.ts +1 -147
- package/dist/droid-cli/src/types.ts +1 -87
- package/dist/extension.js +4674 -1748
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
- package/package.json +5 -4
- package/skill/fusion/references/engine-tools.md +5 -1
- package/skill/fusion/references/extension-tools.md +3 -1
- package/dist/client/assets/ChatView-3Sqm6teN.js +0 -1
- package/dist/client/assets/InsightsView-4KiUKzbz.css +0 -1
- package/dist/client/assets/InsightsView-F5PZsX5u.js +0 -11
- package/dist/client/assets/NodesView-DddCS7zB.js +0 -14
- package/dist/client/assets/PiExtensionsManager-Ch7si-v8.js +0 -11
- package/dist/client/assets/PluginManager-LcTh_fHP.js +0 -1
- package/dist/client/assets/ResearchView-D0TY1VcX.js +0 -1
- package/dist/client/assets/SettingsModal-SOADcCNJ.js +0 -31
- package/dist/client/assets/SettingsModal-oOnIed5O.css +0 -1
- package/dist/client/assets/SetupWizardModal-EDYuf9Yc.js +0 -1
- package/dist/client/assets/index-4hC8zoTD.css +0 -1
- package/dist/client/assets/index-DNzA4aZ7.js +0 -1229
|
@@ -1,1930 +1,9 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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", { timeout: 90_000 }, () => {
|
|
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
|
});
|