macroclaw 0.31.0 → 0.33.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.
@@ -1,8 +1,10 @@
1
1
  import { afterEach, describe, expect, it, mock } from "bun:test";
2
2
  import { z } from "zod/v4";
3
- import { Claude, QueryParseError, QueryProcessError, QueryValidationError } from "./claude";
3
+ import { Claude, ClaudeProcess, QueryProcessError, QueryValidationError, type RawProcess } from "./claude";
4
4
 
5
- function envelope(opts: { structuredOutput?: unknown; result?: string; sessionId?: string; durationMs?: number; costUsd?: number }): string {
5
+ const encoder = new TextEncoder();
6
+
7
+ function resultEnvelope(opts: { structuredOutput?: unknown; result?: string; sessionId?: string; durationMs?: number; costUsd?: number }): string {
6
8
  return JSON.stringify({
7
9
  type: "result",
8
10
  subtype: "success",
@@ -14,37 +16,80 @@ function envelope(opts: { structuredOutput?: unknown; result?: string; sessionId
14
16
  });
15
17
  }
16
18
 
17
- const originalSpawn = Bun.spawn;
19
+ function systemEvent(): string {
20
+ return JSON.stringify({ type: "system", subtype: "init", session_id: "sid", tools: [] });
21
+ }
18
22
 
19
- afterEach(() => {
20
- Bun.spawn = originalSpawn;
21
- });
23
+ function assistantEvent(text = "hello"): string {
24
+ return JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text }] } });
25
+ }
22
26
 
23
- function mockSpawn(opts: { stdout?: string; stderr?: string; exitCode?: number }) {
24
- const proc = {
25
- stdout: new Response(opts.stdout ?? "").body,
26
- stderr: new Response(opts.stderr ?? "").body,
27
- exited: Promise.resolve(opts.exitCode ?? 0),
28
- kill: mock(() => {}),
27
+ /** All mock procs created during a test cleaned up in afterEach */
28
+ const activeMocks: Array<{ closeStdout: () => void; resolveExited: (code: number) => void }> = [];
29
+
30
+ /** Creates a controllable mock process for ClaudeProcess tests */
31
+ function createMockProc(): {
32
+ proc: RawProcess;
33
+ emitLine: (line: string) => void;
34
+ closeStdout: () => void;
35
+ resolveExited: (code: number) => void;
36
+ stdin: { write: ReturnType<typeof mock>; flush: ReturnType<typeof mock>; end: ReturnType<typeof mock> };
37
+ } {
38
+ let stdoutController: ReadableStreamDefaultController<Uint8Array>;
39
+ const stdout = new ReadableStream<Uint8Array>({
40
+ start(c) { stdoutController = c; },
41
+ });
42
+
43
+ let stderrController: ReadableStreamDefaultController<Uint8Array>;
44
+ const stderr = new ReadableStream<Uint8Array>({
45
+ start(c) { stderrController = c; },
46
+ });
47
+
48
+ let resolveExited: (code: number) => void;
49
+ const exited = new Promise<number>((resolve) => { resolveExited = resolve; });
50
+
51
+ const stdin = {
52
+ write: mock(() => {}),
53
+ flush: mock(() => {}),
54
+ end: mock(() => {}),
29
55
  };
30
- Bun.spawn = mock((() => proc) as any);
31
- return proc;
32
- }
33
56
 
34
- function mockHangingSpawn(stdout: string) {
35
- let resolveExited!: (code: number) => void;
36
- const proc = {
37
- stdout: new Response(stdout).body,
38
- stderr: new Response("").body,
39
- exited: new Promise<number>((resolve) => {
40
- resolveExited = resolve;
41
- }),
57
+ const proc: RawProcess = {
58
+ stdin,
59
+ stdout,
60
+ stderr,
61
+ exited,
42
62
  kill: mock(() => {}),
43
63
  };
44
- Bun.spawn = mock((() => proc) as any);
45
- return { proc, resolveExited };
64
+
65
+ const closeStdout = () => {
66
+ try { stdoutController.close(); } catch { /* already closed */ }
67
+ try { stderrController.close(); } catch { /* already closed */ }
68
+ };
69
+
70
+ const handle = { closeStdout, resolveExited: resolveExited! };
71
+ activeMocks.push(handle);
72
+
73
+ return {
74
+ proc,
75
+ emitLine: (line: string) => stdoutController.enqueue(encoder.encode(`${line}\n`)),
76
+ closeStdout,
77
+ resolveExited: resolveExited!,
78
+ stdin,
79
+ };
46
80
  }
47
81
 
82
+ const originalSpawn = Bun.spawn;
83
+
84
+ afterEach(() => {
85
+ Bun.spawn = originalSpawn;
86
+ // Close all streams and resolve exited promises to free readers
87
+ for (const m of activeMocks.splice(0)) {
88
+ m.closeStdout();
89
+ m.resolveExited(0);
90
+ }
91
+ });
92
+
48
93
  const TEST_WORKSPACE = "/tmp/claude2-test";
49
94
 
50
95
  function makeClaude(config?: { model?: string; systemPrompt?: string }) {
@@ -61,255 +106,390 @@ function spawnArgs(): string[] {
61
106
  return (Bun.spawn as any).mock.calls[0][0] as string[];
62
107
  }
63
108
 
64
- describe("Claude", () => {
65
- describe("newSession", () => {
66
- it("uses --session-id with a generated UUID", async () => {
67
- mockSpawn({ stdout: envelope({ result: "hi" }), exitCode: 0 });
68
- const claude = makeClaude();
69
- const query = claude.newSession("hello", textResult);
109
+ function spawnOpts(): Record<string, unknown> {
110
+ return (Bun.spawn as any).mock.calls[0][1] as Record<string, unknown>;
111
+ }
70
112
 
71
- expect(query.sessionId).toMatch(/^[0-9a-f-]{36}$/);
72
- const args = spawnArgs();
73
- expect(args).toContain("--session-id");
74
- expect(args).toContain(query.sessionId);
75
- expect(args).not.toContain("--resume");
76
- expect(args).not.toContain("--fork-session");
113
+ /** Helper to mock Bun.spawn and return a controllable mock process */
114
+ function mockSpawn() {
115
+ const mockProc = createMockProc();
116
+ (Bun as any).spawn = mock(() => mockProc.proc);
117
+ return mockProc;
118
+ }
77
119
 
78
- await query.result;
79
- });
120
+ describe("ClaudeProcess", () => {
121
+ describe("send", () => {
122
+ it("writes NDJSON to stdin and resolves on result event", async () => {
123
+ const { proc, emitLine, stdin } = createMockProc();
124
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
80
125
 
81
- it("returns text value for text resultType", async () => {
82
- mockSpawn({ stdout: envelope({ result: "hello world" }), exitCode: 0 });
83
- const claude = makeClaude();
84
- const { value } = await claude.newSession("hi", textResult).result;
126
+ const promise = cp.send("hello");
127
+
128
+ emitLine(systemEvent());
129
+ emitLine(assistantEvent());
130
+ emitLine(resultEnvelope({ result: "hello world" }));
131
+
132
+ const { value } = await promise;
85
133
  expect(value).toBe("hello world");
134
+ expect(stdin.write).toHaveBeenCalledTimes(1);
135
+ const written = stdin.write.mock.calls[0][0] as string;
136
+ const parsed = JSON.parse(written);
137
+ expect(parsed.type).toBe("user");
138
+ expect(parsed.message.content).toBe("hello");
139
+ expect(stdin.flush).toHaveBeenCalledTimes(1);
86
140
  });
87
141
 
88
142
  it("returns parsed value for object resultType", async () => {
89
- mockSpawn({ stdout: envelope({ structuredOutput: { action: "send", message: "hi" } }), exitCode: 0 });
143
+ const schema = z.object({ count: z.number() });
144
+ const { proc, emitLine } = createMockProc();
145
+ const cp = new ClaudeProcess(proc, "test-sid", objectResult(schema));
146
+
147
+ const promise = cp.send("hi");
148
+ emitLine(resultEnvelope({ structuredOutput: { count: 5 } }));
149
+
150
+ const { value } = await promise;
151
+ expect(value).toEqual({ count: 5 });
152
+ });
153
+
154
+ it("skips non-result events", async () => {
155
+ const { proc, emitLine } = createMockProc();
156
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
157
+
158
+ const promise = cp.send("hi");
159
+ emitLine(systemEvent());
160
+ emitLine(assistantEvent());
161
+ emitLine(JSON.stringify({ type: "rate_limit_event" }));
162
+ emitLine(resultEnvelope({ result: "done" }));
163
+
164
+ const { value } = await promise;
165
+ expect(value).toBe("done");
166
+ });
167
+
168
+ it("skips non-JSON lines", async () => {
169
+ const { proc, emitLine } = createMockProc();
170
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
171
+
172
+ const promise = cp.send("hi");
173
+ emitLine("not json at all");
174
+ emitLine(resultEnvelope({ result: "ok" }));
175
+
176
+ const { value } = await promise;
177
+ expect(value).toBe("ok");
178
+ });
179
+
180
+ it("returns duration and cost from result envelope", async () => {
181
+ const { proc, emitLine } = createMockProc();
182
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
183
+
184
+ const promise = cp.send("hi");
185
+ emitLine(resultEnvelope({ result: "ok", durationMs: 2500, costUsd: 0.123 }));
186
+
187
+ const { duration, cost } = await promise;
188
+ expect(duration).toBe("2.5s");
189
+ expect(cost).toBe("$0.1230");
190
+ });
191
+
192
+ it("returns sessionId from result envelope", async () => {
193
+ const { proc, emitLine } = createMockProc();
194
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
195
+
196
+ const promise = cp.send("hi");
197
+ emitLine(resultEnvelope({ result: "ok", sessionId: "server-returned-sid" }));
198
+
199
+ const { sessionId } = await promise;
200
+ expect(sessionId).toBe("server-returned-sid");
201
+ });
202
+
203
+ it("transitions state: idle -> busy -> idle", async () => {
204
+ const { proc, emitLine } = createMockProc();
205
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
206
+
207
+ expect(cp.state).toBe("idle");
208
+ const promise = cp.send("hi");
209
+ // After send starts, state is busy
210
+ expect(cp.state).toBe("busy");
211
+
212
+ emitLine(resultEnvelope({ result: "ok" }));
213
+ await promise;
214
+ expect(cp.state).toBe("idle");
215
+ });
216
+
217
+ it("throws when called while busy", async () => {
218
+ const { proc } = createMockProc();
219
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
220
+
221
+ const pending = cp.send("first"); // starts, goes busy
222
+ pending.catch(() => {}); // Ignore — testing the second send
223
+ expect(() => cp.send("second")).toThrow("Cannot send: process is busy");
224
+ });
225
+
226
+ it("throws when called while dead", async () => {
227
+ const { proc, resolveExited } = createMockProc();
228
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
229
+
230
+ resolveExited(0);
231
+ await cp.kill();
232
+ expect(() => cp.send("hi")).toThrow("Cannot send: process is dead");
233
+ });
234
+
235
+ it("supports multiple sequential sends", async () => {
236
+ const { proc, emitLine } = createMockProc();
237
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
238
+
239
+ const p1 = cp.send("first");
240
+ emitLine(resultEnvelope({ result: "response 1" }));
241
+ const r1 = await p1;
242
+ expect(r1.value).toBe("response 1");
243
+
244
+ const p2 = cp.send("second");
245
+ emitLine(resultEnvelope({ result: "response 2" }));
246
+ const r2 = await p2;
247
+ expect(r2.value).toBe("response 2");
248
+ });
249
+
250
+ it("rejects with QueryProcessError when process exits mid-send", async () => {
251
+ const { proc, closeStdout, resolveExited } = createMockProc();
252
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
253
+
254
+ const promise = cp.send("hi");
255
+ closeStdout();
256
+ resolveExited(1);
257
+
258
+ try {
259
+ await promise;
260
+ expect.unreachable("should have thrown");
261
+ } catch (err) {
262
+ expect(err).toBeInstanceOf(QueryProcessError);
263
+ expect((err as QueryProcessError).exitCode).toBe(1);
264
+ }
265
+ expect(cp.state).toBe("dead");
266
+ });
267
+
268
+ it("rejects with QueryProcessError when stdin write fails", async () => {
269
+ const { proc, stdin } = createMockProc();
270
+ stdin.write.mockImplementation(() => { throw new Error("broken pipe"); });
271
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
272
+
273
+ try {
274
+ await cp.send("hi");
275
+ expect.unreachable("should have thrown");
276
+ } catch (err) {
277
+ expect(err).toBeInstanceOf(QueryProcessError);
278
+ expect((err as QueryProcessError).stderr).toContain("broken pipe");
279
+ }
280
+ expect(cp.state).toBe("dead");
281
+ });
282
+
283
+ it("rejects with QueryValidationError when schema.parse throws", async () => {
284
+ const schema = z.object({ count: z.number() }).strict();
285
+ const { proc, emitLine } = createMockProc();
286
+ const cp = new ClaudeProcess(proc, "test-sid", objectResult(schema));
287
+
288
+ const promise = cp.send("hi");
289
+ emitLine(resultEnvelope({ structuredOutput: { bad: true } }));
290
+
291
+ try {
292
+ await promise;
293
+ expect.unreachable("should have thrown");
294
+ } catch (err) {
295
+ expect(err).toBeInstanceOf(QueryValidationError);
296
+ expect((err as QueryValidationError).raw).toEqual({ bad: true });
297
+ }
298
+ });
299
+ });
300
+
301
+ describe("kill", () => {
302
+ it("kills the process and transitions to dead", async () => {
303
+ const { proc, resolveExited } = createMockProc();
304
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
305
+
306
+ const killPromise = cp.kill();
307
+ resolveExited(0);
308
+ await killPromise;
309
+
310
+ expect(cp.state).toBe("dead");
311
+ expect(proc.kill).toHaveBeenCalled();
312
+ });
313
+
314
+ it("is idempotent when already dead", async () => {
315
+ const { proc, resolveExited } = createMockProc();
316
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
317
+
318
+ const p1 = cp.kill();
319
+ resolveExited(0);
320
+ await p1;
321
+
322
+ // Second kill should be a no-op
323
+ await cp.kill();
324
+ expect(proc.kill).toHaveBeenCalledTimes(1);
325
+ });
326
+ });
327
+
328
+ describe("unexpected exit", () => {
329
+ it("sets state to dead when process exits while idle", async () => {
330
+ const { proc, resolveExited } = createMockProc();
331
+ const cp = new ClaudeProcess(proc, "test-sid", textResult);
332
+
333
+ expect(cp.state).toBe("idle");
334
+ resolveExited(1);
335
+ // Give microtask a chance to run
336
+ await new Promise((r) => setTimeout(r, 10));
337
+ expect(cp.state).toBe("dead");
338
+ });
339
+ });
340
+ });
341
+
342
+ describe("Claude factory", () => {
343
+ describe("newSession", () => {
344
+ it("spawns with --session-id and stream-json flags", () => {
345
+ mockSpawn();
90
346
  const claude = makeClaude();
91
- const schema = objectResult();
92
- const { value } = await claude.newSession("hi", schema).result;
93
- expect(value).toEqual({ action: "send", message: "hi" });
347
+ const process = claude.newSession(textResult);
348
+
349
+ expect(process.sessionId).toMatch(/^[0-9a-f-]{36}$/);
350
+ const args = spawnArgs();
351
+ expect(args).toContain("--session-id");
352
+ expect(args).toContain(process.sessionId);
353
+ expect(args).toContain("--input-format");
354
+ expect(args).toContain("stream-json");
355
+ expect(args).toContain("--output-format");
356
+ expect(args).toContain("--verbose");
357
+ expect(args).not.toContain("--resume");
358
+ expect(args).not.toContain("--fork-session");
94
359
  });
95
360
  });
96
361
 
97
362
  describe("resumeSession", () => {
98
- it("uses --resume with the provided sessionId", async () => {
99
- mockSpawn({ stdout: envelope({ result: "resumed" }), exitCode: 0 });
363
+ it("spawns with --resume and the provided sessionId", () => {
364
+ mockSpawn();
100
365
  const claude = makeClaude();
101
- const query = claude.resumeSession("existing-sid", "continue", textResult);
366
+ const process = claude.resumeSession("existing-sid", textResult);
102
367
 
103
- expect(query.sessionId).toBe("existing-sid");
368
+ expect(process.sessionId).toBe("existing-sid");
104
369
  const args = spawnArgs();
105
370
  expect(args).toContain("--resume");
106
371
  expect(args).toContain("existing-sid");
107
372
  expect(args).not.toContain("--session-id");
108
373
  expect(args).not.toContain("--fork-session");
109
-
110
- await query.result;
111
374
  });
112
375
  });
113
376
 
114
377
  describe("forkSession", () => {
115
- it("generates a new session ID and passes parent via --resume", async () => {
116
- mockSpawn({ stdout: envelope({ result: "forked" }), exitCode: 0 });
378
+ it("spawns with --resume parent, --fork-session, and new --session-id", () => {
379
+ mockSpawn();
117
380
  const claude = makeClaude();
118
- const query = claude.forkSession("parent-sid", "bg task", textResult);
381
+ const process = claude.forkSession("parent-sid", textResult);
119
382
 
120
- expect(query.sessionId).toMatch(/^[0-9a-f-]{36}$/);
121
- expect(query.sessionId).not.toBe("parent-sid");
383
+ expect(process.sessionId).toMatch(/^[0-9a-f-]{36}$/);
384
+ expect(process.sessionId).not.toBe("parent-sid");
122
385
  const args = spawnArgs();
123
386
  expect(args).toContain("--resume");
124
387
  expect(args).toContain("parent-sid");
125
388
  expect(args).toContain("--fork-session");
126
389
  expect(args).toContain("--session-id");
127
- expect(args).toContain(query.sessionId);
128
-
129
- await query.result;
390
+ expect(args).toContain(process.sessionId);
130
391
  });
131
392
  });
132
393
 
133
394
  describe("options", () => {
134
- it("uses constructor model as default", async () => {
135
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
395
+ it("uses constructor model as default", () => {
396
+ mockSpawn();
136
397
  const claude = makeClaude({ model: "sonnet" });
137
- await claude.newSession("hi", textResult).result;
398
+ claude.newSession(textResult);
138
399
  expect(spawnArgs()).toContain("sonnet");
139
400
  });
140
401
 
141
- it("per-call model overrides constructor model", async () => {
142
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
402
+ it("per-call model overrides constructor model", () => {
403
+ mockSpawn();
143
404
  const claude = makeClaude({ model: "sonnet" });
144
- await claude.newSession("hi", textResult, { model: "haiku" }).result;
405
+ claude.newSession(textResult, { model: "haiku" });
145
406
  const args = spawnArgs();
146
407
  expect(args).toContain("haiku");
147
408
  expect(args).not.toContain("sonnet");
148
409
  });
149
410
 
150
- it("uses constructor systemPrompt as default", async () => {
151
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
411
+ it("uses constructor systemPrompt as default", () => {
412
+ mockSpawn();
152
413
  const claude = makeClaude({ systemPrompt: "Be helpful." });
153
- await claude.newSession("hi", textResult).result;
414
+ claude.newSession(textResult);
154
415
  const args = spawnArgs();
155
416
  expect(args).toContain("--append-system-prompt");
156
417
  expect(args).toContain("Be helpful.");
157
418
  });
158
419
 
159
- it("per-call systemPrompt overrides constructor systemPrompt", async () => {
160
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
420
+ it("per-call systemPrompt overrides constructor systemPrompt", () => {
421
+ mockSpawn();
161
422
  const claude = makeClaude({ systemPrompt: "Be helpful." });
162
- await claude.newSession("hi", textResult, { systemPrompt: "Be brief." }).result;
423
+ claude.newSession(textResult, { systemPrompt: "Be brief." });
163
424
  const args = spawnArgs();
164
425
  expect(args).toContain("Be brief.");
165
426
  expect(args).not.toContain("Be helpful.");
166
427
  });
167
428
 
168
- it("omits --model when none specified", async () => {
169
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
429
+ it("omits --model when none specified", () => {
430
+ mockSpawn();
170
431
  const claude = makeClaude();
171
- await claude.newSession("hi", textResult).result;
432
+ claude.newSession(textResult);
172
433
  expect(spawnArgs()).not.toContain("--model");
173
434
  });
174
435
 
175
- it("omits --append-system-prompt when none specified", async () => {
176
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
436
+ it("omits --append-system-prompt when none specified", () => {
437
+ mockSpawn();
177
438
  const claude = makeClaude();
178
- await claude.newSession("hi", textResult).result;
439
+ claude.newSession(textResult);
179
440
  expect(spawnArgs()).not.toContain("--append-system-prompt");
180
441
  });
181
442
  });
182
443
 
183
444
  describe("resultType", () => {
184
- it("omits --json-schema for text resultType", async () => {
185
- mockSpawn({ stdout: envelope({ result: "plain" }), exitCode: 0 });
445
+ it("omits --json-schema for text resultType", () => {
446
+ mockSpawn();
186
447
  const claude = makeClaude();
187
- await claude.newSession("hi", textResult).result;
448
+ claude.newSession(textResult);
188
449
  expect(spawnArgs()).not.toContain("--json-schema");
189
450
  });
190
451
 
191
- it("passes --json-schema for object resultType", async () => {
192
- mockSpawn({ stdout: envelope({ structuredOutput: { ok: true } }), exitCode: 0 });
193
- const claude = makeClaude();
194
- const schema = objectResult();
195
- await claude.newSession("hi", schema).result;
196
- const args = spawnArgs();
197
- expect(args).toContain("--json-schema");
198
- });
199
-
200
- it("validates structured output through schema.parse", async () => {
201
- mockSpawn({ stdout: envelope({ structuredOutput: { count: 5 } }), exitCode: 0 });
452
+ it("passes --json-schema for object resultType", () => {
453
+ mockSpawn();
202
454
  const claude = makeClaude();
203
- const schema = objectResult(z.object({ count: z.number() }));
204
- const { value } = await claude.newSession("hi", schema).result;
205
- expect(value).toEqual({ count: 5 });
206
- });
207
- });
208
-
209
- describe("metadata", () => {
210
- it("returns duration and cost", async () => {
211
- mockSpawn({ stdout: envelope({ result: "ok", durationMs: 2500, costUsd: 0.123 }), exitCode: 0 });
212
- const claude = makeClaude();
213
- const { duration, cost } = await claude.newSession("hi", textResult).result;
214
- expect(duration).toBe("2.5s");
215
- expect(cost).toBe("$0.1230");
216
- });
217
-
218
- it("returns sessionId from server envelope", async () => {
219
- mockSpawn({ stdout: envelope({ result: "ok", sessionId: "server-returned-sid" }), exitCode: 0 });
220
- const claude = makeClaude();
221
- const { sessionId } = await claude.newSession("hi", textResult).result;
222
- expect(sessionId).toBe("server-returned-sid");
223
- });
224
-
225
- it("sets startedAt on the running query", () => {
226
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
227
- const claude = makeClaude();
228
- const before = new Date();
229
- const query = claude.newSession("hi", textResult);
230
- const after = new Date();
231
- expect(query.startedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
232
- expect(query.startedAt.getTime()).toBeLessThanOrEqual(after.getTime());
233
- });
234
- });
235
-
236
- describe("errors", () => {
237
- it("rejects with QueryProcessError on non-zero exit", async () => {
238
- mockSpawn({ stderr: "boom", exitCode: 1 });
239
- const claude = makeClaude();
240
- try {
241
- await claude.newSession("hi", textResult).result;
242
- expect.unreachable("should have thrown");
243
- } catch (err) {
244
- expect(err).toBeInstanceOf(QueryProcessError);
245
- expect((err as QueryProcessError).exitCode).toBe(1);
246
- expect((err as QueryProcessError).stderr).toBe("boom");
247
- }
248
- });
249
-
250
- it("rejects with QueryParseError on invalid JSON", async () => {
251
- mockSpawn({ stdout: "not json", exitCode: 0 });
252
- const claude = makeClaude();
253
- try {
254
- await claude.newSession("hi", textResult).result;
255
- expect.unreachable("should have thrown");
256
- } catch (err) {
257
- expect(err).toBeInstanceOf(QueryParseError);
258
- expect((err as QueryParseError).raw).toBe("not json");
259
- }
260
- });
261
-
262
- it("rejects with QueryValidationError when schema.parse throws", async () => {
263
- mockSpawn({ stdout: envelope({ structuredOutput: { bad: true } }), exitCode: 0 });
264
- const claude = makeClaude();
265
- const schema = objectResult(z.object({ count: z.number() }).strict());
266
- try {
267
- await claude.newSession("hi", schema).result;
268
- expect.unreachable("should have thrown");
269
- } catch (err) {
270
- expect(err).toBeInstanceOf(QueryValidationError);
271
- expect((err as QueryValidationError).raw).toEqual({ bad: true });
272
- }
273
- });
274
- });
275
-
276
- describe("kill", () => {
277
- it("kills the process and waits for exit", async () => {
278
- const { proc, resolveExited } = mockHangingSpawn(envelope({ result: "done" }));
279
- const claude = makeClaude();
280
- const query = claude.newSession("hi", textResult);
281
-
282
- const killPromise = query.kill();
283
- resolveExited(0);
284
- await killPromise;
285
- expect(proc.kill).toHaveBeenCalled();
455
+ claude.newSession(objectResult());
456
+ expect(spawnArgs()).toContain("--json-schema");
286
457
  });
287
458
  });
288
459
 
289
460
  describe("environment", () => {
290
- it("strips CLAUDECODE env var", async () => {
291
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
461
+ it("strips CLAUDECODE env var", () => {
462
+ mockSpawn();
292
463
  const claude = makeClaude();
293
- await claude.newSession("hi", textResult).result;
294
- const spawnOpts = (Bun.spawn as any).mock.calls[0][1];
295
- expect(spawnOpts.env).not.toHaveProperty("CLAUDECODE");
464
+ claude.newSession(textResult);
465
+ const opts = spawnOpts();
466
+ expect(opts.env).not.toHaveProperty("CLAUDECODE");
296
467
  });
297
468
 
298
- it("spawns in the configured workspace directory", async () => {
299
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
469
+ it("spawns in the configured workspace directory", () => {
470
+ mockSpawn();
300
471
  const claude = makeClaude();
301
- await claude.newSession("hi", textResult).result;
302
- const spawnOpts = (Bun.spawn as any).mock.calls[0][1];
303
- expect(spawnOpts.cwd).toBe(TEST_WORKSPACE);
472
+ claude.newSession(textResult);
473
+ expect(spawnOpts().cwd).toBe(TEST_WORKSPACE);
304
474
  });
305
475
 
306
- it("includes disallowedTools in CLI args", async () => {
307
- mockSpawn({ stdout: envelope({ result: "ok" }), exitCode: 0 });
476
+ it("includes disallowedTools in CLI args", () => {
477
+ mockSpawn();
308
478
  const claude = makeClaude();
309
- await claude.newSession("hi", textResult).result;
479
+ claude.newSession(textResult);
310
480
  const args = spawnArgs();
311
481
  expect(args).toContain("--disallowedTools");
312
482
  expect(args).toContain("CronList,CronDelete,CronCreate,AskUserQuestion");
313
483
  });
484
+
485
+ it("spawns with stdin: pipe, stdout: pipe, stderr: pipe", () => {
486
+ mockSpawn();
487
+ const claude = makeClaude();
488
+ claude.newSession(textResult);
489
+ const opts = spawnOpts();
490
+ expect(opts.stdin).toBe("pipe");
491
+ expect(opts.stdout).toBe("pipe");
492
+ expect(opts.stderr).toBe("pipe");
493
+ });
314
494
  });
315
495
  });