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.
- package/package.json +1 -1
- package/src/app.test.ts +159 -103
- package/src/app.ts +13 -0
- package/src/claude.integration-test.ts +65 -27
- package/src/claude.test.ts +369 -189
- package/src/claude.ts +171 -71
- package/src/orchestrator.test.ts +301 -249
- package/src/orchestrator.ts +198 -166
- package/src/prompts.test.ts +102 -162
- package/src/prompts.ts +62 -53
package/src/claude.test.ts
CHANGED
|
@@ -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,
|
|
3
|
+
import { Claude, ClaudeProcess, QueryProcessError, QueryValidationError, type RawProcess } from "./claude";
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
19
|
+
function systemEvent(): string {
|
|
20
|
+
return JSON.stringify({ type: "system", subtype: "init", session_id: "sid", tools: [] });
|
|
21
|
+
}
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
23
|
+
function assistantEvent(text = "hello"): string {
|
|
24
|
+
return JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text }] } });
|
|
25
|
+
}
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
expect(
|
|
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("
|
|
99
|
-
mockSpawn(
|
|
363
|
+
it("spawns with --resume and the provided sessionId", () => {
|
|
364
|
+
mockSpawn();
|
|
100
365
|
const claude = makeClaude();
|
|
101
|
-
const
|
|
366
|
+
const process = claude.resumeSession("existing-sid", textResult);
|
|
102
367
|
|
|
103
|
-
expect(
|
|
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("
|
|
116
|
-
mockSpawn(
|
|
378
|
+
it("spawns with --resume parent, --fork-session, and new --session-id", () => {
|
|
379
|
+
mockSpawn();
|
|
117
380
|
const claude = makeClaude();
|
|
118
|
-
const
|
|
381
|
+
const process = claude.forkSession("parent-sid", textResult);
|
|
119
382
|
|
|
120
|
-
expect(
|
|
121
|
-
expect(
|
|
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(
|
|
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",
|
|
135
|
-
mockSpawn(
|
|
395
|
+
it("uses constructor model as default", () => {
|
|
396
|
+
mockSpawn();
|
|
136
397
|
const claude = makeClaude({ model: "sonnet" });
|
|
137
|
-
|
|
398
|
+
claude.newSession(textResult);
|
|
138
399
|
expect(spawnArgs()).toContain("sonnet");
|
|
139
400
|
});
|
|
140
401
|
|
|
141
|
-
it("per-call model overrides constructor model",
|
|
142
|
-
mockSpawn(
|
|
402
|
+
it("per-call model overrides constructor model", () => {
|
|
403
|
+
mockSpawn();
|
|
143
404
|
const claude = makeClaude({ model: "sonnet" });
|
|
144
|
-
|
|
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",
|
|
151
|
-
mockSpawn(
|
|
411
|
+
it("uses constructor systemPrompt as default", () => {
|
|
412
|
+
mockSpawn();
|
|
152
413
|
const claude = makeClaude({ systemPrompt: "Be helpful." });
|
|
153
|
-
|
|
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",
|
|
160
|
-
mockSpawn(
|
|
420
|
+
it("per-call systemPrompt overrides constructor systemPrompt", () => {
|
|
421
|
+
mockSpawn();
|
|
161
422
|
const claude = makeClaude({ systemPrompt: "Be helpful." });
|
|
162
|
-
|
|
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",
|
|
169
|
-
mockSpawn(
|
|
429
|
+
it("omits --model when none specified", () => {
|
|
430
|
+
mockSpawn();
|
|
170
431
|
const claude = makeClaude();
|
|
171
|
-
|
|
432
|
+
claude.newSession(textResult);
|
|
172
433
|
expect(spawnArgs()).not.toContain("--model");
|
|
173
434
|
});
|
|
174
435
|
|
|
175
|
-
it("omits --append-system-prompt when none specified",
|
|
176
|
-
mockSpawn(
|
|
436
|
+
it("omits --append-system-prompt when none specified", () => {
|
|
437
|
+
mockSpawn();
|
|
177
438
|
const claude = makeClaude();
|
|
178
|
-
|
|
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",
|
|
185
|
-
mockSpawn(
|
|
445
|
+
it("omits --json-schema for text resultType", () => {
|
|
446
|
+
mockSpawn();
|
|
186
447
|
const claude = makeClaude();
|
|
187
|
-
|
|
448
|
+
claude.newSession(textResult);
|
|
188
449
|
expect(spawnArgs()).not.toContain("--json-schema");
|
|
189
450
|
});
|
|
190
451
|
|
|
191
|
-
it("passes --json-schema for object resultType",
|
|
192
|
-
mockSpawn(
|
|
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
|
-
|
|
204
|
-
|
|
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",
|
|
291
|
-
mockSpawn(
|
|
461
|
+
it("strips CLAUDECODE env var", () => {
|
|
462
|
+
mockSpawn();
|
|
292
463
|
const claude = makeClaude();
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
expect(
|
|
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",
|
|
299
|
-
mockSpawn(
|
|
469
|
+
it("spawns in the configured workspace directory", () => {
|
|
470
|
+
mockSpawn();
|
|
300
471
|
const claude = makeClaude();
|
|
301
|
-
|
|
302
|
-
|
|
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",
|
|
307
|
-
mockSpawn(
|
|
476
|
+
it("includes disallowedTools in CLI args", () => {
|
|
477
|
+
mockSpawn();
|
|
308
478
|
const claude = makeClaude();
|
|
309
|
-
|
|
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
|
});
|