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,12 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import { existsSync, rmSync } from "node:fs";
3
- import { type Claude, QueryParseError, QueryProcessError, type QueryResult, type RunningQuery } from "./claude";
3
+ import { type Claude, type ClaudeProcess, type ProcessState, QueryParseError, QueryProcessError, type QueryResult } from "./claude";
4
4
  import { Orchestrator, type OrchestratorConfig, type OrchestratorResponse } from "./orchestrator";
5
5
  import { saveSessions } from "./sessions";
6
6
 
7
7
  const tmpSettingsDir = "/tmp/macroclaw-test-orchestrator-settings";
8
8
  const TEST_WORKSPACE = "/tmp/macroclaw-test-workspace";
9
9
 
10
+ const activeOrchestrators: Orchestrator[] = [];
11
+
10
12
  function cleanup() {
11
13
  try {
12
14
  if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
@@ -14,66 +16,98 @@ function cleanup() {
14
16
  }
15
17
 
16
18
  beforeEach(cleanup);
17
- afterEach(cleanup);
19
+ afterEach(async () => {
20
+ for (const orch of activeOrchestrators.splice(0)) {
21
+ await orch.dispose();
22
+ }
23
+ cleanup();
24
+ });
18
25
 
19
26
  interface CallInfo {
20
27
  method: "newSession" | "resumeSession" | "forkSession";
21
- prompt: string;
22
28
  sessionId?: string;
23
29
  model?: string;
24
30
  systemPrompt?: string;
25
31
  }
26
32
 
27
- type MockHandler = (info: CallInfo) => RunningQuery<unknown>;
28
-
29
33
  function queryResult<T>(value: T, sessionId = "test-session-id"): QueryResult<T> {
30
34
  return { value, sessionId, duration: "1.0s", cost: "$0.05" };
31
35
  }
32
36
 
33
- function resolvedQuery<T>(value: T, sessionId = "test-session-id"): RunningQuery<T> {
37
+ /** Creates a mock process that auto-resolves send() with a fixed value */
38
+ function autoProcess(value: unknown, sessionId = "test-sid"): ClaudeProcess<unknown> {
34
39
  return {
35
40
  sessionId,
36
41
  startedAt: new Date(),
37
- result: Promise.resolve(queryResult(value, sessionId)),
42
+ get state(): ProcessState { return "idle"; },
43
+ send: mock(async (_prompt: string) => queryResult(value, sessionId)),
38
44
  kill: mock(async () => {}),
39
- };
45
+ } as unknown as ClaudeProcess<unknown>;
40
46
  }
41
47
 
42
- function pendingQuery(sessionId = "pending-sid"): { query: RunningQuery<unknown>; resolve: (v: QueryResult<unknown>) => void; reject: (e: Error) => void } {
43
- let resolve!: (v: QueryResult<unknown>) => void;
44
- let reject!: (e: Error) => void;
45
- const result = new Promise<QueryResult<unknown>>((res, rej) => { resolve = res; reject = rej; });
48
+ /** Creates a mock process with controllable send() */
49
+ function pendingProcess(sessionId = "pending-sid") {
50
+ type SendEntry = { resolve: (v: QueryResult<unknown>) => void; reject: (e: Error) => void };
51
+ const sendQueue: SendEntry[] = [];
52
+ let state: ProcessState = "idle";
53
+
54
+ const proc = {
55
+ sessionId,
56
+ startedAt: new Date(),
57
+ get state(): ProcessState { return state; },
58
+ send: mock(async (_prompt: string): Promise<QueryResult<unknown>> => {
59
+ state = "busy";
60
+ return new Promise<QueryResult<unknown>>((resolve, reject) => {
61
+ sendQueue.push({ resolve, reject });
62
+ });
63
+ }),
64
+ kill: mock(async () => { state = "dead"; }),
65
+ } as unknown as ClaudeProcess<unknown>;
66
+
46
67
  return {
47
- query: { sessionId, startedAt: new Date(), result, kill: mock(async () => {}) },
48
- resolve,
49
- reject,
68
+ process: proc,
69
+ resolve: (value: unknown) => {
70
+ state = "idle";
71
+ const entry = sendQueue.shift();
72
+ if (entry) entry.resolve(queryResult(value, sessionId));
73
+ },
74
+ reject: (err: Error) => {
75
+ state = "dead";
76
+ const entry = sendQueue.shift();
77
+ if (entry) entry.reject(err);
78
+ },
50
79
  };
51
80
  }
52
81
 
82
+ type MockHandler = (info: CallInfo) => ClaudeProcess<unknown>;
83
+
53
84
  function mockClaude(handler: MockHandler | unknown) {
54
85
  const calls: CallInfo[] = [];
86
+ const processes: ClaudeProcess<unknown>[] = [];
55
87
  const handlerFn: MockHandler = typeof handler === "function"
56
88
  ? handler as MockHandler
57
- : () => resolvedQuery(handler);
89
+ : () => autoProcess(handler);
90
+
91
+ function handleCall(info: CallInfo): ClaudeProcess<unknown> {
92
+ calls.push(info);
93
+ const proc = handlerFn(info);
94
+ processes.push(proc);
95
+ return proc;
96
+ }
58
97
 
59
98
  const claude = {
60
- newSession: mock((prompt: string, _resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
61
- const info: CallInfo = { method: "newSession", prompt, model: options?.model, systemPrompt: options?.systemPrompt };
62
- calls.push(info);
63
- return handlerFn(info);
99
+ newSession: mock((_resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
100
+ return handleCall({ method: "newSession", model: options?.model, systemPrompt: options?.systemPrompt });
64
101
  }),
65
- resumeSession: mock((sessionId: string, prompt: string, _resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
66
- const info: CallInfo = { method: "resumeSession", sessionId, prompt, model: options?.model, systemPrompt: options?.systemPrompt };
67
- calls.push(info);
68
- return handlerFn(info);
102
+ resumeSession: mock((sessionId: string, _resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
103
+ return handleCall({ method: "resumeSession", sessionId, model: options?.model, systemPrompt: options?.systemPrompt });
69
104
  }),
70
- forkSession: mock((sessionId: string, prompt: string, _resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
71
- const info: CallInfo = { method: "forkSession", sessionId, prompt, model: options?.model, systemPrompt: options?.systemPrompt };
72
- calls.push(info);
73
- return handlerFn(info);
105
+ forkSession: mock((sessionId: string, _resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
106
+ return handleCall({ method: "forkSession", sessionId, model: options?.model, systemPrompt: options?.systemPrompt });
74
107
  }),
75
108
  calls,
76
- } as unknown as Claude & { calls: CallInfo[]; newSession: ReturnType<typeof mock>; resumeSession: ReturnType<typeof mock>; forkSession: ReturnType<typeof mock> };
109
+ processes,
110
+ } as unknown as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[]; newSession: ReturnType<typeof mock>; resumeSession: ReturnType<typeof mock>; forkSession: ReturnType<typeof mock> };
77
111
  return claude;
78
112
  }
79
113
 
@@ -85,8 +119,10 @@ function makeOrchestrator(claude: Claude, extraConfig?: Partial<OrchestratorConf
85
119
  settingsDir: tmpSettingsDir,
86
120
  onResponse,
87
121
  claude,
122
+ healthCheckInterval: 0,
88
123
  ...extraConfig,
89
124
  });
125
+ activeOrchestrators.push(orch);
90
126
  return { orch, responses, onResponse };
91
127
  }
92
128
 
@@ -94,6 +130,13 @@ async function waitForProcessing(ms = 50) {
94
130
  await new Promise((r) => setTimeout(r, ms));
95
131
  }
96
132
 
133
+ /** Get all prompts sent to all processes */
134
+ function sentPrompts(claude: { processes: ClaudeProcess<unknown>[] }): string[] {
135
+ return claude.processes.flatMap((p) =>
136
+ (p.send as ReturnType<typeof mock>).mock.calls.map((c: unknown[]) => c[0] as string),
137
+ );
138
+ }
139
+
97
140
  describe("Orchestrator", () => {
98
141
  describe("prompt building", () => {
99
142
  it("builds user prompt as-is", async () => {
@@ -103,8 +146,8 @@ describe("Orchestrator", () => {
103
146
  orch.handleMessage("hello");
104
147
  await waitForProcessing();
105
148
 
106
- expect(claude.calls[0].prompt).toContain('type="user-message"');
107
- expect(claude.calls[0].prompt).toContain("<text>hello</text>");
149
+ expect(sentPrompts(claude)[0]).toContain('type="user-message"');
150
+ expect(sentPrompts(claude)[0]).toContain("<text>hello</text>");
108
151
  });
109
152
 
110
153
  it("prepends file references for user requests", async () => {
@@ -114,9 +157,9 @@ describe("Orchestrator", () => {
114
157
  orch.handleMessage("check this", ["/tmp/photo.jpg", "/tmp/doc.pdf"]);
115
158
  await waitForProcessing();
116
159
 
117
- expect(claude.calls[0].prompt).toContain('<file path="/tmp/photo.jpg" />');
118
- expect(claude.calls[0].prompt).toContain('<file path="/tmp/doc.pdf" />');
119
- expect(claude.calls[0].prompt).toContain("<text>check this</text>");
160
+ expect(sentPrompts(claude)[0]).toContain('<file path="/tmp/photo.jpg" />');
161
+ expect(sentPrompts(claude)[0]).toContain('<file path="/tmp/doc.pdf" />');
162
+ expect(sentPrompts(claude)[0]).toContain("<text>check this</text>");
120
163
  });
121
164
 
122
165
  it("sends only file references when message is empty", async () => {
@@ -126,8 +169,8 @@ describe("Orchestrator", () => {
126
169
  orch.handleMessage("", ["/tmp/photo.jpg"]);
127
170
  await waitForProcessing();
128
171
 
129
- expect(claude.calls[0].prompt).toContain('<file path="/tmp/photo.jpg" />');
130
- expect(claude.calls[0].prompt).not.toContain("<text>");
172
+ expect(sentPrompts(claude)[0]).toContain('<file path="/tmp/photo.jpg" />');
173
+ expect(sentPrompts(claude)[0]).not.toContain("<text>");
131
174
  });
132
175
 
133
176
  it("builds button click prompt", async () => {
@@ -137,7 +180,7 @@ describe("Orchestrator", () => {
137
180
  orch.handleButton("Yes");
138
181
  await waitForProcessing();
139
182
 
140
- expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
183
+ expect(sentPrompts(claude)[0]).toContain('<button>Yes</button>');
141
184
  });
142
185
  });
143
186
 
@@ -183,12 +226,17 @@ describe("Orchestrator", () => {
183
226
 
184
227
  describe("error mapping", () => {
185
228
  it("maps QueryProcessError to process-error response", async () => {
186
- const claude = mockClaude((): RunningQuery<unknown> => ({
187
- sessionId: "err-sid",
188
- startedAt: new Date(),
189
- result: Promise.reject(new QueryProcessError(1, "spawn failed")),
190
- kill: mock(async () => {}),
191
- }));
229
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
230
+ const { process, reject } = pendingProcess("err-sid");
231
+ // Auto-reject when send is called
232
+ const origSend = process.send;
233
+ (process as any).send = mock(async (prompt: string) => {
234
+ const p = (origSend as Function).call(process, prompt);
235
+ reject(new QueryProcessError(1, "spawn failed"));
236
+ return p;
237
+ });
238
+ return process;
239
+ });
192
240
  const { orch, responses } = makeOrchestrator(claude);
193
241
 
194
242
  orch.handleMessage("hi");
@@ -199,12 +247,11 @@ describe("Orchestrator", () => {
199
247
  });
200
248
 
201
249
  it("maps QueryParseError to json-parse-failed response", async () => {
202
- const claude = mockClaude((): RunningQuery<unknown> => ({
203
- sessionId: "err-sid",
204
- startedAt: new Date(),
205
- result: Promise.reject(new QueryParseError("not json")),
206
- kill: mock(async () => {}),
207
- }));
250
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
251
+ const proc = autoProcess(null, "err-sid");
252
+ (proc as any).send = mock(async () => { throw new QueryParseError("not json"); });
253
+ return proc;
254
+ });
208
255
  const { orch, responses } = makeOrchestrator(claude);
209
256
 
210
257
  orch.handleMessage("hi");
@@ -215,12 +262,11 @@ describe("Orchestrator", () => {
215
262
 
216
263
  it("reports error when resume fails", async () => {
217
264
  saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
218
- const claude = mockClaude((): RunningQuery<unknown> => ({
219
- sessionId: "old-session",
220
- startedAt: new Date(),
221
- result: Promise.reject(new QueryProcessError(1, "session not found")),
222
- kill: mock(async () => {}),
223
- }));
265
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
266
+ const proc = autoProcess(null, "old-session");
267
+ (proc as any).send = mock(async () => { throw new QueryProcessError(1, "session not found"); });
268
+ return proc;
269
+ });
224
270
  const { orch, responses } = makeOrchestrator(claude);
225
271
 
226
272
  orch.handleMessage("hello");
@@ -255,7 +301,7 @@ describe("Orchestrator", () => {
255
301
  expect(claude.calls[0].method).toBe("newSession");
256
302
  });
257
303
 
258
- it("switches to resumeSession after first success", async () => {
304
+ it("reuses main process for second message (no new factory call)", async () => {
259
305
  const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
260
306
  const { orch } = makeOrchestrator(claude);
261
307
 
@@ -264,8 +310,11 @@ describe("Orchestrator", () => {
264
310
  orch.handleMessage("second");
265
311
  await waitForProcessing();
266
312
 
267
- expect(claude.calls[0].method).toBe("newSession");
268
- expect(claude.calls[1].method).toBe("resumeSession");
313
+ // Only one factory call — process is reused
314
+ expect(claude.calls).toHaveLength(1);
315
+ // But the process was sent to twice
316
+ const mainProc = claude.processes[0];
317
+ expect((mainProc.send as ReturnType<typeof mock>).mock.calls).toHaveLength(2);
269
318
  });
270
319
 
271
320
  it("background-agent forks from main session", async () => {
@@ -279,52 +328,39 @@ describe("Orchestrator", () => {
279
328
  orch.handleBackgroundCommand("do work");
280
329
  await waitForProcessing();
281
330
 
331
+ // First call: resumeSession for main, second: forkSession for background
282
332
  expect(claude.calls[1].method).toBe("forkSession");
283
333
  });
284
-
285
- it("updates session ID after forked call", async () => {
286
- saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
287
- const claude = mockClaude(() => resolvedQuery({ action: "send", message: "ok", actionReason: "ok" }, "new-forked-session"));
288
- const { orch } = makeOrchestrator(claude);
289
-
290
- orch.handleMessage("hello");
291
- await waitForProcessing();
292
-
293
- orch.handleMessage("follow up");
294
- await waitForProcessing();
295
-
296
- expect(claude.calls[1].sessionId).toBe("new-forked-session");
297
- });
298
334
  });
299
335
 
300
336
  describe("non-blocking handler", () => {
301
337
  it("handler returns immediately, result delivered by completion handler", async () => {
302
- const { query, resolve } = pendingQuery("main-sid");
303
- const claude = mockClaude(() => query);
338
+ const { process: mainProc, resolve } = pendingProcess("main-sid");
339
+ const claude = mockClaude(() => mainProc);
304
340
  const { orch, responses } = makeOrchestrator(claude);
305
341
 
306
342
  orch.handleMessage("hello");
307
343
  await waitForProcessing();
308
344
 
309
- // Handler returned but no response yet (query still pending)
345
+ // Handler returned but no response yet (send still pending)
310
346
  expect(responses).toHaveLength(0);
311
347
 
312
348
  // Query completes — completion handler delivers
313
- resolve(queryResult({ action: "send", message: "done!", actionReason: "ok" }));
349
+ resolve({ action: "send", message: "done!", actionReason: "ok" });
314
350
  await waitForProcessing();
315
351
 
316
352
  expect(responses[0].message).toBe("done!");
317
353
  });
318
354
 
319
355
  it("second message processes while first is still running", async () => {
320
- const { query: q1, resolve: resolve1 } = pendingQuery("q1-sid");
321
- const { query: q2, resolve: resolve2 } = pendingQuery("q2-sid");
356
+ const { process: p1, resolve: resolve1 } = pendingProcess("q1-sid");
357
+ const p2 = autoProcess({ action: "send", message: "second done", actionReason: "ok" }, "q2-sid");
322
358
 
323
359
  let callCount = 0;
324
- const claude = mockClaude((): RunningQuery<unknown> => {
360
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
325
361
  callCount++;
326
- if (callCount === 1) return q1;
327
- return q2;
362
+ if (callCount === 1) return p1;
363
+ return p2;
328
364
  });
329
365
  // waitThreshold=0 so second message demotes immediately
330
366
  const { orch, responses } = makeOrchestrator(claude, { waitThreshold: 0 });
@@ -332,37 +368,34 @@ describe("Orchestrator", () => {
332
368
  orch.handleMessage("first");
333
369
  await waitForProcessing();
334
370
 
335
- // First query is running, handler returned
371
+ // First process is busy, handler returned
336
372
  expect(callCount).toBe(1);
337
373
 
338
374
  orch.handleMessage("second");
339
375
  await waitForProcessing();
340
376
 
341
- // Second message caused a fork+demote, second query started
377
+ // Second message caused a fork+demote, second process started
342
378
  expect(callCount).toBe(2);
343
379
  const secondCall = claude.calls[1];
344
380
  expect(secondCall.method).toBe("forkSession");
345
- expect(secondCall.prompt).toContain("<backgrounded-event");
346
- expect(secondCall.prompt).toContain("<text>second</text>");
381
+ expect(sentPrompts(claude)[1]).toContain("<backgrounded-event");
382
+ expect(sentPrompts(claude)[1]).toContain("<text>second</text>");
347
383
 
348
- // Resolve both
349
- resolve2(queryResult({ action: "send", message: "second done", actionReason: "ok" }, "q2-sid"));
350
- resolve1(queryResult({ action: "send", message: "first done", actionReason: "ok" }, "q1-sid"));
384
+ // Resolve first (backgrounded) — feeds back as background result
385
+ resolve1({ action: "send", message: "first done", actionReason: "ok" });
351
386
  await waitForProcessing(100);
352
387
 
353
388
  const messages = responses.map((r) => r.message);
354
389
  expect(messages).toContain("second done");
355
- // First result goes through Claude as background context (not direct)
356
390
  });
357
391
 
358
392
  it("waits for main to finish when within threshold, then processes next message", async () => {
359
- const { query: q1, resolve: resolve1 } = pendingQuery("main-sid");
393
+ const { process: p1, resolve: resolve1 } = pendingProcess("main-sid");
360
394
 
361
395
  let callCount = 0;
362
- const claude = mockClaude((): RunningQuery<unknown> => {
396
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
363
397
  callCount++;
364
- if (callCount === 1) return q1;
365
- return resolvedQuery({ action: "send", message: "follow-up result", actionReason: "ok" });
398
+ return p1; // Same process reused
366
399
  });
367
400
  const { orch, responses } = makeOrchestrator(claude);
368
401
 
@@ -375,27 +408,28 @@ describe("Orchestrator", () => {
375
408
  orch.handleMessage("follow up");
376
409
  await waitForProcessing(10);
377
410
 
378
- // Still blocked, only 1 call
411
+ // Still blocked, only 1 process
379
412
  expect(callCount).toBe(1);
380
413
 
381
- // First query finishes — completion handler delivers, then handler unblocks
382
- resolve1(queryResult({ action: "send", message: "slow done", actionReason: "ok" }));
414
+ // First send finishes — completion handler delivers, then handler unblocks
415
+ resolve1({ action: "send", message: "slow done", actionReason: "ok" });
383
416
  await waitForProcessing(100);
384
417
 
385
- expect(callCount).toBe(2);
418
+ // Process was reused for second message (same process, second send)
419
+ const mainProc = claude.processes[0];
420
+ expect((mainProc.send as ReturnType<typeof mock>).mock.calls).toHaveLength(2);
386
421
  const messages = responses.map((r) => r.message);
387
422
  expect(messages).toContain("slow done");
388
- expect(messages).toContain("follow-up result");
389
423
  });
390
424
 
391
425
  it("demotes after wait timeout when main does not finish in time", async () => {
392
- const { query: q1 } = pendingQuery("main-sid");
426
+ const { process: p1 } = pendingProcess("main-sid");
393
427
 
394
428
  let callCount = 0;
395
- const claude = mockClaude((): RunningQuery<unknown> => {
429
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
396
430
  callCount++;
397
- if (callCount === 1) return q1;
398
- return resolvedQuery({ action: "send", message: "forked result", actionReason: "ok" }, "new-main");
431
+ if (callCount === 1) return p1;
432
+ return autoProcess({ action: "send", message: "forked result", actionReason: "ok" }, "new-main");
399
433
  });
400
434
  // Short threshold so we don't wait long
401
435
  const { orch, responses } = makeOrchestrator(claude, { waitThreshold: 10 });
@@ -407,16 +441,15 @@ describe("Orchestrator", () => {
407
441
  await waitForProcessing(200);
408
442
 
409
443
  expect(callCount).toBe(2);
410
- const userCall = claude.calls[1];
411
- expect(userCall.prompt).toContain("<backgrounded-event");
412
- expect(userCall.prompt).toContain("follow up");
444
+ expect(sentPrompts(claude)[1]).toContain("<backgrounded-event");
445
+ expect(sentPrompts(claude)[1]).toContain("follow up");
413
446
  expect(responses.map((r) => r.message)).toContain("forked result");
414
447
  });
415
448
 
416
449
  it("delivers result with error when session not in runningSessions", async () => {
417
450
  saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
418
- const { query, resolve } = pendingQuery("main-session");
419
- const claude = mockClaude(() => query);
451
+ const { process: mainProc, resolve } = pendingProcess("main-session");
452
+ const claude = mockClaude(() => mainProc);
420
453
  const { orch, responses } = makeOrchestrator(claude);
421
454
 
422
455
  orch.handleMessage("hello");
@@ -427,7 +460,7 @@ describe("Orchestrator", () => {
427
460
  await waitForProcessing();
428
461
 
429
462
  // Query completes after kill — should still deliver (with error log)
430
- resolve(queryResult({ action: "send", message: "late result", actionReason: "ok" }));
463
+ resolve({ action: "send", message: "late result", actionReason: "ok" });
431
464
  await waitForProcessing();
432
465
 
433
466
  const messages = responses.map((r) => r.message);
@@ -454,7 +487,7 @@ describe("Orchestrator", () => {
454
487
  orch.handleButton("Yes");
455
488
  await waitForProcessing();
456
489
 
457
- expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
490
+ expect(sentPrompts(claude)[0]).toContain('<button>Yes</button>');
458
491
  expect(responses[0].message).toBe("button response");
459
492
  });
460
493
 
@@ -470,17 +503,24 @@ describe("Orchestrator", () => {
470
503
 
471
504
  it("background agents spawned from Claude response: calls onResponse with started message", async () => {
472
505
  let callCount = 0;
473
- const claude = mockClaude((): RunningQuery<unknown> => {
506
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
474
507
  callCount++;
475
- if (callCount === 1) {
476
- return resolvedQuery({
477
- action: "send",
478
- message: "Starting research",
479
- actionReason: "needs research",
480
- backgroundAgents: [{ name: "research", prompt: "research this" }],
481
- });
508
+ if (callCount <= 2) {
509
+ // First: main process, second: background agent fork
510
+ return autoProcess(
511
+ callCount === 1
512
+ ? {
513
+ action: "send",
514
+ message: "Starting research",
515
+ actionReason: "needs research",
516
+ backgroundAgents: [{ name: "research", prompt: "research this" }],
517
+ }
518
+ : { action: "send", message: "research result", actionReason: "done" },
519
+ `sid-${callCount}`,
520
+ );
482
521
  }
483
- return resolvedQuery({ action: "send", message: "research result", actionReason: "done" });
522
+ // Third: main session gets background-agent-result
523
+ return autoProcess({ action: "send", message: "relayed", actionReason: "ok" }, `sid-${callCount}`);
484
524
  });
485
525
  const { orch, responses } = makeOrchestrator(claude);
486
526
 
@@ -492,7 +532,6 @@ describe("Orchestrator", () => {
492
532
  expect(messages).toContain('Background agent "research" started.');
493
533
 
494
534
  await waitForProcessing(100);
495
- expect(callCount).toBe(3); // 1 main + 1 bg agent + 1 bg result fed back
496
535
  });
497
536
  });
498
537
 
@@ -506,8 +545,8 @@ describe("Orchestrator", () => {
506
545
  await waitForProcessing();
507
546
 
508
547
  expect(claude.calls[0].method).toBe("forkSession");
509
- expect(claude.calls[0].prompt).toContain('<schedule name="daily-check" />');
510
- expect(claude.calls[0].prompt).toContain("<text>Check for updates</text>");
548
+ expect(sentPrompts(claude)[0]).toContain('<schedule name="daily-check" />');
549
+ expect(sentPrompts(claude)[0]).toContain("<text>Check for updates</text>");
511
550
  expect(claude.calls[0].model).toBe("haiku");
512
551
  });
513
552
 
@@ -525,16 +564,16 @@ describe("Orchestrator", () => {
525
564
  it("cron result feeds back into main session", async () => {
526
565
  saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
527
566
  let callCount = 0;
528
- const claude = mockClaude((): RunningQuery<unknown> => {
567
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
529
568
  callCount++;
530
- return resolvedQuery({ action: "send", message: `call ${callCount}`, actionReason: "ok" });
569
+ return autoProcess({ action: "send", message: `call ${callCount}`, actionReason: "ok" }, `sid-${callCount}`);
531
570
  });
532
571
  const { orch } = makeOrchestrator(claude);
533
572
 
534
573
  orch.handleCron("check", "any updates?");
535
574
  await waitForProcessing(150);
536
575
 
537
- expect(callCount).toBe(2); // 1 cron bg + 1 bg result fed back
576
+ expect(callCount).toBeGreaterThanOrEqual(2); // 1 cron bg + 1 bg result fed back
538
577
  });
539
578
  });
540
579
 
@@ -551,12 +590,10 @@ describe("Orchestrator", () => {
551
590
 
552
591
  it("includes detail buttons and dismiss when sessions are running", async () => {
553
592
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
554
- const claude = mockClaude((): RunningQuery<unknown> => ({
555
- sessionId: `bg-${Date.now()}`,
556
- startedAt: new Date(),
557
- result: new Promise(() => {}),
558
- kill: mock(async () => {}),
559
- }));
593
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
594
+ const { process } = pendingProcess(`bg-${Date.now()}`);
595
+ return process;
596
+ });
560
597
  const { orch, responses } = makeOrchestrator(claude);
561
598
 
562
599
  orch.handleBackgroundCommand("long-task");
@@ -571,14 +608,14 @@ describe("Orchestrator", () => {
571
608
  expect(listResponse.buttons!.length).toBe(2);
572
609
  const detailBtn = listResponse.buttons![0];
573
610
  expect(typeof detailBtn).toBe("object");
574
- expect((detailBtn as any).data).toMatch(/^detail:/);
575
- expect((detailBtn as any).text).toContain("long-task");
611
+ expect((detailBtn as { data: string }).data).toMatch(/^detail:/);
612
+ expect((detailBtn as { text: string }).text).toContain("long-task");
576
613
  expect(listResponse.buttons![1]).toEqual({ text: "Dismiss", data: "_dismiss" });
577
614
  });
578
615
 
579
616
  it("marks main session in listing", async () => {
580
- const { query } = pendingQuery("main-sid");
581
- const claude = mockClaude(() => query);
617
+ const { process } = pendingProcess("main-sid");
618
+ const claude = mockClaude(() => process);
582
619
  const { orch, responses } = makeOrchestrator(claude);
583
620
 
584
621
  // Start a main query (non-blocking, stays in runningSessions)
@@ -607,17 +644,13 @@ describe("Orchestrator", () => {
607
644
  it("peeks at running agent and returns status", async () => {
608
645
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
609
646
  let callCount = 0;
610
- const claude = mockClaude((): RunningQuery<unknown> => {
647
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
611
648
  callCount++;
612
649
  if (callCount === 1) {
613
- return {
614
- sessionId: "bg-sid",
615
- startedAt: new Date(),
616
- result: new Promise(() => {}),
617
- kill: mock(async () => {}),
618
- };
650
+ const { process } = pendingProcess("bg-sid");
651
+ return process;
619
652
  }
620
- return resolvedQuery("Working on it, 50% done.", "peek-session");
653
+ return autoProcess("Working on it, 50% done.", "peek-session");
621
654
  });
622
655
  const { orch, responses } = makeOrchestrator(claude);
623
656
 
@@ -641,22 +674,15 @@ describe("Orchestrator", () => {
641
674
  it("handles Claude error during peek gracefully", async () => {
642
675
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
643
676
  let callCount = 0;
644
- const claude = mockClaude((): RunningQuery<unknown> => {
677
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
645
678
  callCount++;
646
679
  if (callCount === 1) {
647
- return {
648
- sessionId: "bg-sid",
649
- startedAt: new Date(),
650
- result: new Promise(() => {}),
651
- kill: mock(async () => {}),
652
- };
680
+ const { process } = pendingProcess("bg-sid");
681
+ return process;
653
682
  }
654
- return {
655
- sessionId: "peek-err",
656
- startedAt: new Date(),
657
- result: Promise.reject(new Error("connection lost")),
658
- kill: mock(async () => {}),
659
- };
683
+ const proc = autoProcess(null, "peek-err");
684
+ (proc as any).send = mock(async () => { throw new Error("connection lost"); });
685
+ return proc;
660
686
  });
661
687
  const { orch, responses } = makeOrchestrator(claude);
662
688
 
@@ -690,12 +716,10 @@ describe("Orchestrator", () => {
690
716
 
691
717
  it("shows session details with peek/kill/dismiss buttons", async () => {
692
718
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
693
- const claude = mockClaude((): RunningQuery<unknown> => ({
694
- sessionId: "bg-sid",
695
- startedAt: new Date(),
696
- result: new Promise(() => {}),
697
- kill: mock(async () => {}),
698
- }));
719
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
720
+ const { process } = pendingProcess("bg-sid");
721
+ return process;
722
+ });
699
723
  const { orch, responses } = makeOrchestrator(claude);
700
724
 
701
725
  orch.handleBackgroundCommand("research pricing");
@@ -724,12 +748,10 @@ describe("Orchestrator", () => {
724
748
  it("truncates prompt at 300 chars", async () => {
725
749
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
726
750
  const longPrompt = "a".repeat(500);
727
- const claude = mockClaude((): RunningQuery<unknown> => ({
728
- sessionId: "bg-sid",
729
- startedAt: new Date(),
730
- result: new Promise(() => {}),
731
- kill: mock(async () => {}),
732
- }));
751
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
752
+ const { process } = pendingProcess("bg-sid");
753
+ return process;
754
+ });
733
755
  const { orch, responses } = makeOrchestrator(claude);
734
756
 
735
757
  orch.handleBackgroundCommand(longPrompt);
@@ -752,12 +774,10 @@ describe("Orchestrator", () => {
752
774
 
753
775
  it("shows model when specified", async () => {
754
776
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
755
- const claude = mockClaude((): RunningQuery<unknown> => ({
756
- sessionId: "bg-sid",
757
- startedAt: new Date(),
758
- result: new Promise(() => {}),
759
- kill: mock(async () => {}),
760
- }));
777
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
778
+ const { process } = pendingProcess("bg-sid");
779
+ return process;
780
+ });
761
781
  const { orch, responses } = makeOrchestrator(claude, { model: "opus" });
762
782
 
763
783
  orch.handleBackgroundCommand("research");
@@ -777,16 +797,12 @@ describe("Orchestrator", () => {
777
797
  });
778
798
 
779
799
  it("shows clean prompt text for main sessions, not XML wrapper", async () => {
780
- const claude = mockClaude((): RunningQuery<unknown> => ({
781
- sessionId: "main-sid",
782
- startedAt: new Date(),
783
- result: new Promise(() => {}),
784
- kill: mock(async () => {}),
785
- }));
800
+ const { process } = pendingProcess("main-sid");
801
+ const claude = mockClaude(() => process);
786
802
  const { orch, responses } = makeOrchestrator(claude);
787
803
 
788
804
  orch.handleMessage("I want to visit my parents at their house");
789
- await waitForProcessing(); // Let the queue process and start the main query
805
+ await waitForProcessing();
790
806
 
791
807
  orch.handleSessions();
792
808
  await waitForProcessing();
@@ -817,13 +833,8 @@ describe("Orchestrator", () => {
817
833
 
818
834
  it("kills running session and sends confirmation", async () => {
819
835
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
820
- const killMock = mock(async () => {});
821
- const claude = mockClaude((): RunningQuery<unknown> => ({
822
- sessionId: "bg-sid",
823
- startedAt: new Date(),
824
- result: new Promise(() => {}),
825
- kill: killMock,
826
- }));
836
+ const { process: bgProc } = pendingProcess("bg-sid");
837
+ const claude = mockClaude((): ClaudeProcess<unknown> => bgProc);
827
838
  const { orch, responses } = makeOrchestrator(claude);
828
839
 
829
840
  orch.handleBackgroundCommand("research pricing");
@@ -838,7 +849,7 @@ describe("Orchestrator", () => {
838
849
  await orch.handleKill(sessionId);
839
850
  await waitForProcessing();
840
851
 
841
- expect(killMock).toHaveBeenCalled();
852
+ expect(bgProc.kill).toHaveBeenCalled();
842
853
  const killResponse = responses[responses.length - 1];
843
854
  expect(killResponse.message).toContain("Killed");
844
855
  expect(killResponse.message).toContain("research-pricing");
@@ -850,14 +861,8 @@ describe("Orchestrator", () => {
850
861
 
851
862
  it("does not feed error back to queue after kill", async () => {
852
863
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
853
- let rejectBg: (err: Error) => void;
854
- const bgResult = new Promise<never>((_, r) => { rejectBg = r; });
855
- const claude = mockClaude((): RunningQuery<unknown> => ({
856
- sessionId: "bg-sid",
857
- startedAt: new Date(),
858
- result: bgResult,
859
- kill: mock(async () => {}),
860
- }));
864
+ const { process: bgProc, reject: rejectBg } = pendingProcess("bg-sid");
865
+ const claude = mockClaude((): ClaudeProcess<unknown> => bgProc);
861
866
  const { orch, responses } = makeOrchestrator(claude);
862
867
 
863
868
  orch.handleBackgroundCommand("task");
@@ -873,7 +878,7 @@ describe("Orchestrator", () => {
873
878
  await waitForProcessing();
874
879
  const countAfterKill = responses.length;
875
880
 
876
- rejectBg!(new Error("process killed"));
881
+ rejectBg(new Error("process killed"));
877
882
  await waitForProcessing(100);
878
883
 
879
884
  const newResponses = responses.slice(countAfterKill);
@@ -884,12 +889,10 @@ describe("Orchestrator", () => {
884
889
  describe("handleBackgroundCommand", () => {
885
890
  it("spawns background agent and sends started message", async () => {
886
891
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
887
- const claude = mockClaude((): RunningQuery<unknown> => ({
888
- sessionId: "bg-sid",
889
- startedAt: new Date(),
890
- result: new Promise(() => {}),
891
- kill: mock(async () => {}),
892
- }));
892
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
893
+ const { process } = pendingProcess("bg-sid");
894
+ return process;
895
+ });
893
896
  const { orch, responses } = makeOrchestrator(claude);
894
897
 
895
898
  orch.handleBackgroundCommand("research pricing");
@@ -903,13 +906,14 @@ describe("Orchestrator", () => {
903
906
  describe("background management", () => {
904
907
  it("spawns background agent and feeds result back to queue", async () => {
905
908
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
906
- const { query: bgQuery, resolve: resolveBg } = pendingQuery("bg-sid");
909
+ const { process: bgProc, resolve: resolveBg } = pendingProcess("bg-sid");
907
910
 
908
911
  let callCount = 0;
909
- const claude = mockClaude((): RunningQuery<unknown> => {
912
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
910
913
  callCount++;
911
- if (callCount === 1) return bgQuery;
912
- return resolvedQuery({ action: "send", message: "bg result processed", actionReason: "ok" });
914
+ if (callCount === 1) return bgProc;
915
+ // Main session processes the background-agent-result
916
+ return autoProcess({ action: "send", message: "bg result processed", actionReason: "ok" }, `sid-${callCount}`);
913
917
  });
914
918
  const { orch, responses } = makeOrchestrator(claude);
915
919
 
@@ -918,21 +922,21 @@ describe("Orchestrator", () => {
918
922
 
919
923
  expect(responses[0].message).toContain('started.');
920
924
 
921
- resolveBg(queryResult({ action: "send", message: "done!", actionReason: "completed" }));
925
+ resolveBg({ action: "send", message: "done!", actionReason: "completed" });
922
926
  await waitForProcessing(100);
923
927
 
924
- expect(callCount).toBe(2);
928
+ expect(callCount).toBeGreaterThanOrEqual(2);
925
929
  });
926
930
 
927
931
  it("feeds error back to queue on spawn failure", async () => {
928
932
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
929
- const { query: bgQuery, reject: rejectBg } = pendingQuery("bg-sid");
933
+ const { process: bgProc, reject: rejectBg } = pendingProcess("bg-sid");
930
934
 
931
935
  let callCount = 0;
932
- const claude = mockClaude((): RunningQuery<unknown> => {
936
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
933
937
  callCount++;
934
- if (callCount === 1) return bgQuery;
935
- return resolvedQuery({ action: "send", message: "error processed", actionReason: "ok" });
938
+ if (callCount === 1) return bgProc;
939
+ return autoProcess({ action: "send", message: "error processed", actionReason: "ok" }, `sid-${callCount}`);
936
940
  });
937
941
  const { orch, responses } = makeOrchestrator(claude);
938
942
 
@@ -942,7 +946,7 @@ describe("Orchestrator", () => {
942
946
  rejectBg(new Error("spawn failed"));
943
947
  await waitForProcessing(100);
944
948
 
945
- expect(callCount).toBe(2);
949
+ expect(callCount).toBeGreaterThanOrEqual(2);
946
950
  expect(responses[responses.length - 1].message).toBe("error processed");
947
951
  });
948
952
  });
@@ -950,20 +954,21 @@ describe("Orchestrator", () => {
950
954
  describe("health checks", () => {
951
955
  it("runs health check after interval and reports finished agent", async () => {
952
956
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
953
- const { query: bgQuery } = pendingQuery("bg-sid");
957
+ const { process: bgProc } = pendingProcess("bg-sid");
954
958
 
955
959
  let callCount = 0;
956
- const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
960
+ const claude = mockClaude((_info: CallInfo): ClaudeProcess<unknown> => {
957
961
  callCount++;
958
- if (callCount === 1) return bgQuery; // background agent spawn
959
- if (info.prompt.includes("health-check")) {
960
- return resolvedQuery({
962
+ if (callCount === 1) return bgProc; // background agent spawn
963
+ if (sentPrompts({ processes: (claude as any).processes }).some((p: string) => p?.includes("health-check")) || callCount === 2) {
964
+ // Health check fork
965
+ return autoProcess({
961
966
  finished: true,
962
967
  output: { action: "send", message: "task complete", actionReason: "done" },
963
968
  }, "hc-sid");
964
969
  }
965
970
  // Main session processes the background-agent-result
966
- return resolvedQuery({ action: "send", message: "relayed", actionReason: "ok" });
971
+ return autoProcess({ action: "send", message: "relayed", actionReason: "ok" }, `sid-${callCount}`);
967
972
  });
968
973
 
969
974
  const { orch } = makeOrchestrator(claude, {
@@ -974,27 +979,26 @@ describe("Orchestrator", () => {
974
979
  orch.handleBackgroundCommand("long task");
975
980
  await waitForProcessing(200);
976
981
 
977
- // Health check should have fired, detected finished, killed original, and pushed result
982
+ // Health check should have fired
978
983
  expect(callCount).toBeGreaterThanOrEqual(2);
979
- const hcCall = claude.calls.find((c: CallInfo) => c.prompt.includes("health-check"));
980
- expect(hcCall).toBeDefined();
981
- expect(hcCall!.model).toBe("haiku");
984
+ expect(claude.calls.some((c: CallInfo) => c.model === "haiku")).toBe(true);
982
985
  });
983
986
 
984
987
  it("reports progress and schedules next check when not finished", async () => {
985
988
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
986
- const { query: bgQuery } = pendingQuery("bg-sid");
989
+ const { process: bgProc } = pendingProcess("bg-sid");
987
990
 
988
991
  let hcCount = 0;
989
- const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
990
- if (info.prompt.includes("health-check")) {
992
+ const claude = mockClaude((info: CallInfo): ClaudeProcess<unknown> => {
993
+ if (info.model === "haiku") {
991
994
  hcCount++;
992
- return resolvedQuery({ finished: false, progress: "still working" }, "hc-sid");
995
+ return autoProcess({ finished: false, progress: "still working" }, `hc-sid-${hcCount}`);
993
996
  }
994
- if (info.prompt.includes("background-agent-result")) {
995
- return resolvedQuery({ action: "silent", message: "ok", actionReason: "progress" });
997
+ if (hcCount > 0) {
998
+ // Main session processes progress
999
+ return autoProcess({ action: "silent", message: "ok", actionReason: "progress" }, `main-sid-${hcCount}`);
996
1000
  }
997
- return bgQuery; // background agent spawn
1001
+ return bgProc; // background agent spawn
998
1002
  });
999
1003
 
1000
1004
  const { orch } = makeOrchestrator(claude, {
@@ -1007,18 +1011,22 @@ describe("Orchestrator", () => {
1007
1011
  await waitForProcessing(250);
1008
1012
 
1009
1013
  expect(hcCount).toBeGreaterThanOrEqual(2);
1014
+
1015
+ // Kill session to stop the health check loop
1016
+ await orch.handleKill("bg-sid");
1010
1017
  });
1011
1018
 
1012
1019
  it("kills unresponsive agent on health check timeout", async () => {
1013
1020
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1014
- const { query: bgQuery } = pendingQuery("bg-sid");
1021
+ const { process: bgProc } = pendingProcess("bg-sid");
1015
1022
 
1016
- const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
1017
- if (info.prompt.includes("health-check")) {
1018
- // Never resolves — simulates unresponsive agent
1019
- return { sessionId: "hc-sid", startedAt: new Date(), result: new Promise(() => {}), kill: mock(async () => {}) };
1023
+ const claude = mockClaude((info: CallInfo): ClaudeProcess<unknown> => {
1024
+ if (info.model === "haiku") {
1025
+ // Never resolves — simulates unresponsive health check
1026
+ const { process } = pendingProcess("hc-sid");
1027
+ return process;
1020
1028
  }
1021
- return bgQuery;
1029
+ return bgProc;
1022
1030
  });
1023
1031
 
1024
1032
  const { orch, responses } = makeOrchestrator(claude, {
@@ -1035,9 +1043,9 @@ describe("Orchestrator", () => {
1035
1043
 
1036
1044
  it("does not run health checks when interval is 0", async () => {
1037
1045
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1038
- const { query: bgQuery } = pendingQuery("bg-sid");
1046
+ const { process: bgProc } = pendingProcess("bg-sid");
1039
1047
 
1040
- const claude = mockClaude((): RunningQuery<unknown> => bgQuery);
1048
+ const claude = mockClaude((): ClaudeProcess<unknown> => bgProc);
1041
1049
  const { orch } = makeOrchestrator(claude, { healthCheckInterval: 0 });
1042
1050
 
1043
1051
  orch.handleBackgroundCommand("some task");
@@ -1049,12 +1057,12 @@ describe("Orchestrator", () => {
1049
1057
 
1050
1058
  it("clears health check timer when session is killed", async () => {
1051
1059
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1052
- const { query: bgQuery } = pendingQuery("bg-sid");
1060
+ const { process: bgProc } = pendingProcess("bg-sid");
1053
1061
 
1054
1062
  let hcCount = 0;
1055
- const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
1056
- if (info.prompt.includes("health-check")) hcCount++;
1057
- return bgQuery;
1063
+ const claude = mockClaude((info: CallInfo): ClaudeProcess<unknown> => {
1064
+ if (info.model === "haiku") hcCount++;
1065
+ return bgProc;
1058
1066
  });
1059
1067
 
1060
1068
  const { orch } = makeOrchestrator(claude, { healthCheckInterval: 100 });
@@ -1071,15 +1079,15 @@ describe("Orchestrator", () => {
1071
1079
 
1072
1080
  it("stops health check if session completes organically before timer", async () => {
1073
1081
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1074
- const { query: bgQuery, resolve: resolveBg } = pendingQuery("bg-sid");
1082
+ const { process: bgProc, resolve: resolveBg } = pendingProcess("bg-sid");
1075
1083
 
1076
1084
  let hcCount = 0;
1077
1085
  let callCount = 0;
1078
- const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
1086
+ const claude = mockClaude((info: CallInfo): ClaudeProcess<unknown> => {
1079
1087
  callCount++;
1080
- if (info.prompt.includes("health-check")) { hcCount++; return pendingQuery().query; }
1081
- if (callCount === 1) return bgQuery;
1082
- return resolvedQuery({ action: "send", message: "processed", actionReason: "ok" });
1088
+ if (info.model === "haiku") { hcCount++; const { process } = pendingProcess(`hc-sid-${hcCount}`); return process; }
1089
+ if (callCount === 1) return bgProc;
1090
+ return autoProcess({ action: "send", message: "processed", actionReason: "ok" }, `sid-${callCount}`);
1083
1091
  });
1084
1092
 
1085
1093
  const { orch } = makeOrchestrator(claude, { healthCheckInterval: 200 });
@@ -1088,13 +1096,57 @@ describe("Orchestrator", () => {
1088
1096
  await waitForProcessing();
1089
1097
 
1090
1098
  // Complete before health check fires
1091
- resolveBg(queryResult({ action: "send", message: "done", actionReason: "done" }));
1099
+ resolveBg({ action: "send", message: "done", actionReason: "done" });
1092
1100
  await waitForProcessing(350);
1093
1101
 
1094
1102
  expect(hcCount).toBe(0);
1095
1103
  });
1096
1104
  });
1097
1105
 
1106
+ describe("handleClear", () => {
1107
+ it("kills main process and sends confirmation", async () => {
1108
+ const { process: mainProc } = pendingProcess("main-sid");
1109
+ const claude = mockClaude(() => mainProc);
1110
+ const { orch, responses } = makeOrchestrator(claude);
1111
+
1112
+ orch.handleMessage("hello");
1113
+ await waitForProcessing();
1114
+
1115
+ await orch.handleClear();
1116
+ await waitForProcessing();
1117
+
1118
+ expect(mainProc.kill).toHaveBeenCalled();
1119
+ expect(responses.some((r) => r.message === "Session cleared.")).toBe(true);
1120
+ });
1121
+
1122
+ it("creates new session (not resume) for next message after clear", async () => {
1123
+ const { process: p1, resolve: r1 } = pendingProcess("first-sid");
1124
+ let callCount = 0;
1125
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
1126
+ callCount++;
1127
+ if (callCount === 1) return p1;
1128
+ return autoProcess({ action: "send", message: "after clear", actionReason: "ok" }, "second-sid");
1129
+ });
1130
+ const { orch, responses } = makeOrchestrator(claude);
1131
+
1132
+ orch.handleMessage("hello");
1133
+ await waitForProcessing();
1134
+ r1({ action: "send", message: "first", actionReason: "ok" });
1135
+ await waitForProcessing();
1136
+
1137
+ await orch.handleClear();
1138
+ await waitForProcessing();
1139
+
1140
+ orch.handleMessage("hi again");
1141
+ await waitForProcessing();
1142
+
1143
+ expect(callCount).toBe(2);
1144
+ // After clear, should use newSession (not resumeSession)
1145
+ expect(claude.calls[1].method).toBe("newSession");
1146
+ expect(responses.some((r) => r.message === "after clear")).toBe(true);
1147
+ });
1148
+ });
1149
+
1098
1150
  describe("onResponse error handling", () => {
1099
1151
  it("logs error and does not throw when onResponse callback fails", async () => {
1100
1152
  const claude = mockClaude({ action: "send", message: "hello", actionReason: "ok" });