macroclaw 0.0.0-dev

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.
@@ -0,0 +1,631 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { type Claude, type ClaudeDeferredResult, ClaudeParseError, ClaudeProcessError, type ClaudeResult, type ClaudeRunOptions } from "./claude";
4
+ import { Orchestrator, type OrchestratorConfig, type OrchestratorResponse } from "./orchestrator";
5
+ import { saveSettings } from "./settings";
6
+
7
+ const tmpSettingsDir = "/tmp/macroclaw-test-orchestrator-settings";
8
+ const TEST_WORKSPACE = "/tmp/macroclaw-test-workspace";
9
+
10
+ beforeEach(() => {
11
+ if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
12
+ });
13
+
14
+ afterEach(() => {
15
+ if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
16
+ });
17
+
18
+ function mockClaude(response: ClaudeResult | ((opts: ClaudeRunOptions) => Promise<ClaudeResult | ClaudeDeferredResult>)) {
19
+ const run = typeof response === "function"
20
+ ? mock(response)
21
+ : mock(async () => response);
22
+ return { run } as unknown as Claude & { run: ReturnType<typeof mock> };
23
+ }
24
+
25
+ function successResult(structuredOutput: unknown, sessionId = "test-session-id"): ClaudeResult {
26
+ return { structuredOutput, sessionId, duration: "1.0s", cost: "$0.05" };
27
+ }
28
+
29
+ function makeOrchestrator(claude: Claude, extraConfig?: Partial<OrchestratorConfig>) {
30
+ const responses: OrchestratorResponse[] = [];
31
+ const onResponse = mock(async (r: OrchestratorResponse) => { responses.push(r); });
32
+ const orch = new Orchestrator({
33
+ workspace: TEST_WORKSPACE,
34
+ settingsDir: tmpSettingsDir,
35
+ onResponse,
36
+ claude,
37
+ ...extraConfig,
38
+ });
39
+ return { orch, responses, onResponse };
40
+ }
41
+
42
+ async function waitForProcessing(ms = 50) {
43
+ await new Promise((r) => setTimeout(r, ms));
44
+ }
45
+
46
+ describe("Orchestrator", () => {
47
+ describe("prompt building", () => {
48
+ it("builds user prompt as-is", async () => {
49
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
50
+ const { orch } = makeOrchestrator(claude);
51
+
52
+ orch.handleMessage("hello");
53
+ await waitForProcessing();
54
+
55
+ const opts = claude.run.mock.calls[0][0];
56
+ expect(opts.prompt).toBe("hello");
57
+ expect(opts.systemPrompt).toContain("macroclaw");
58
+ expect(opts.timeoutMs).toBe(60_000);
59
+ });
60
+
61
+ it("prepends file references for user requests", async () => {
62
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
63
+ const { orch } = makeOrchestrator(claude);
64
+
65
+ orch.handleMessage("check this", ["/tmp/photo.jpg", "/tmp/doc.pdf"]);
66
+ await waitForProcessing();
67
+
68
+ const opts = claude.run.mock.calls[0][0];
69
+ expect(opts.prompt).toBe("[File: /tmp/photo.jpg]\n[File: /tmp/doc.pdf]\ncheck this");
70
+ });
71
+
72
+ it("sends only file references when message is empty", async () => {
73
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
74
+ const { orch } = makeOrchestrator(claude);
75
+
76
+ orch.handleMessage("", ["/tmp/photo.jpg"]);
77
+ await waitForProcessing();
78
+
79
+ expect(claude.run.mock.calls[0][0].prompt).toBe("[File: /tmp/photo.jpg]");
80
+ });
81
+
82
+ it("builds cron prompt with prefix", async () => {
83
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
84
+ const { orch } = makeOrchestrator(claude);
85
+
86
+ orch.handleCron("daily", "check updates");
87
+ await waitForProcessing();
88
+
89
+ const opts = claude.run.mock.calls[0][0];
90
+ expect(opts.prompt).toBe("[Context: cron/daily] check updates");
91
+ expect(opts.systemPrompt).toContain("macroclaw");
92
+ expect(opts.timeoutMs).toBe(300_000);
93
+ });
94
+
95
+ it("uses cron model override", async () => {
96
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
97
+ const { orch } = makeOrchestrator(claude, { model: "sonnet" });
98
+
99
+ orch.handleCron("smart", "think", "opus");
100
+ await waitForProcessing();
101
+
102
+ expect(claude.run.mock.calls[0][0].model).toBe("opus");
103
+ });
104
+
105
+ it("falls back to config model when cron has no model", async () => {
106
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
107
+ const { orch } = makeOrchestrator(claude, { model: "sonnet" });
108
+
109
+ orch.handleCron("basic", "check");
110
+ await waitForProcessing();
111
+
112
+ expect(claude.run.mock.calls[0][0].model).toBe("sonnet");
113
+ });
114
+
115
+ it("builds button click prompt", async () => {
116
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
117
+ const { orch } = makeOrchestrator(claude);
118
+
119
+ orch.handleButton("Yes");
120
+ await waitForProcessing();
121
+
122
+ const opts = claude.run.mock.calls[0][0];
123
+ expect(opts.prompt).toBe('[Context: button-click] User tapped "Yes"');
124
+ expect(opts.systemPrompt).toContain("macroclaw");
125
+ expect(opts.timeoutMs).toBe(60_000);
126
+ });
127
+ });
128
+
129
+ describe("schema validation", () => {
130
+ it("validates and returns structured output via onResponse", async () => {
131
+ const claude = mockClaude(successResult({ action: "send", message: "hello", actionReason: "ok" }));
132
+ const { orch, responses } = makeOrchestrator(claude);
133
+
134
+ orch.handleMessage("hi");
135
+ await waitForProcessing();
136
+
137
+ expect(responses).toHaveLength(1);
138
+ expect(responses[0].message).toBe("hello");
139
+ });
140
+
141
+ it("returns validation-failed response for wrong shape", async () => {
142
+ const claude = mockClaude(successResult({ action: "invalid", message: "hi", actionReason: "ok" }));
143
+ const { orch, responses } = makeOrchestrator(claude);
144
+
145
+ orch.handleMessage("hi");
146
+ await waitForProcessing();
147
+
148
+ expect(responses[0].message).toBe("hi");
149
+ });
150
+
151
+ it("sends result with prefix when structured_output is missing", async () => {
152
+ const claude = mockClaude({ structuredOutput: null, sessionId: "s1", result: "Claude said this" });
153
+ const { orch, responses } = makeOrchestrator(claude);
154
+
155
+ orch.handleMessage("hi");
156
+ await waitForProcessing();
157
+
158
+ expect(responses[0].message).toBe("[No structured output] Claude said this");
159
+ });
160
+
161
+ it("escapes HTML in fallback result to prevent Telegram parse errors", async () => {
162
+ const claude = mockClaude({ structuredOutput: null, sessionId: "s1", result: "<b>bold</b> & stuff" });
163
+ const { orch, responses } = makeOrchestrator(claude);
164
+
165
+ orch.handleMessage("hi");
166
+ await waitForProcessing();
167
+
168
+ expect(responses[0].message).toBe("[No structured output] &lt;b&gt;bold&lt;/b&gt; &amp; stuff");
169
+ });
170
+
171
+ it("returns [No output] when both structured_output and result are missing", async () => {
172
+ const claude = mockClaude({ structuredOutput: null, sessionId: "s1" });
173
+ const { orch, responses } = makeOrchestrator(claude);
174
+
175
+ orch.handleMessage("hi");
176
+ await waitForProcessing();
177
+
178
+ expect(responses[0].message).toBe("[No output]");
179
+ });
180
+
181
+ it("passes buttons through onResponse", async () => {
182
+ const output = {
183
+ action: "send",
184
+ message: "Choose",
185
+ actionReason: "ok",
186
+ buttons: ["Yes", "No", "Maybe"],
187
+ };
188
+ const claude = mockClaude(successResult(output));
189
+ const { orch, responses } = makeOrchestrator(claude);
190
+
191
+ orch.handleMessage("hi");
192
+ await waitForProcessing();
193
+
194
+ expect(responses[0].buttons).toEqual(["Yes", "No", "Maybe"]);
195
+ });
196
+
197
+ it("passes files through onResponse", async () => {
198
+ const output = { action: "send", message: "chart", actionReason: "ok", files: ["/tmp/chart.png"] };
199
+ const claude = mockClaude(successResult(output));
200
+ const { orch, responses } = makeOrchestrator(claude);
201
+
202
+ orch.handleMessage("hi");
203
+ await waitForProcessing();
204
+
205
+ expect(responses[0].files).toEqual(["/tmp/chart.png"]);
206
+ });
207
+ });
208
+
209
+ describe("error mapping", () => {
210
+ it("maps ClaudeProcessError to process-error response", async () => {
211
+ const claude = mockClaude(async () => { throw new ClaudeProcessError(1, "spawn failed"); });
212
+ const { orch, responses } = makeOrchestrator(claude);
213
+
214
+ orch.handleMessage("hi");
215
+ await waitForProcessing();
216
+
217
+ expect(responses[0].message).toContain("[Error]");
218
+ expect(responses[0].message).toContain("spawn failed");
219
+ });
220
+
221
+ it("maps ClaudeParseError to json-parse-failed response", async () => {
222
+ const claude = mockClaude(async () => { throw new ClaudeParseError("not json"); });
223
+ const { orch, responses } = makeOrchestrator(claude);
224
+
225
+ orch.handleMessage("hi");
226
+ await waitForProcessing();
227
+
228
+ expect(responses[0].message).toContain("[JSON Error]");
229
+ });
230
+ });
231
+
232
+ describe("session management", () => {
233
+ it("uses --resume for existing session", async () => {
234
+ saveSettings({ sessionId: "existing-session" }, tmpSettingsDir);
235
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
236
+ const { orch } = makeOrchestrator(claude);
237
+
238
+ orch.handleMessage("hello");
239
+ await waitForProcessing();
240
+
241
+ expect(claude.run.mock.calls[0][0].sessionFlag).toBe("--resume");
242
+ expect(claude.run.mock.calls[0][0].sessionId).toBe("existing-session");
243
+ });
244
+
245
+ it("creates new session when no settings exist", async () => {
246
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
247
+ const { orch } = makeOrchestrator(claude);
248
+
249
+ orch.handleMessage("hello");
250
+ await waitForProcessing();
251
+
252
+ expect(claude.run.mock.calls[0][0].sessionFlag).toBe("--session-id");
253
+ expect(claude.run.mock.calls[0][0].sessionId).toMatch(/^[0-9a-f]{8}-/);
254
+ });
255
+
256
+ it("creates new session when resume fails", async () => {
257
+ saveSettings({ sessionId: "old-session" }, tmpSettingsDir);
258
+ let callCount = 0;
259
+ const claude = mockClaude(async (_opts: ClaudeRunOptions): Promise<ClaudeResult> => {
260
+ callCount++;
261
+ if (callCount === 1) throw new ClaudeProcessError(1, "session not found");
262
+ return successResult({ action: "send", message: "ok", actionReason: "ok" });
263
+ });
264
+ const { orch } = makeOrchestrator(claude);
265
+
266
+ orch.handleMessage("hello");
267
+ await waitForProcessing();
268
+
269
+ expect(callCount).toBe(2);
270
+ expect(claude.run.mock.calls[0][0].sessionFlag).toBe("--resume");
271
+ expect(claude.run.mock.calls[1][0].sessionFlag).toBe("--session-id");
272
+ expect(claude.run.mock.calls[1][0].sessionId).not.toBe("old-session");
273
+ });
274
+
275
+ it("switches to --resume after first success", async () => {
276
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
277
+ const { orch } = makeOrchestrator(claude);
278
+
279
+ orch.handleMessage("first");
280
+ await waitForProcessing();
281
+ orch.handleMessage("second");
282
+ await waitForProcessing();
283
+
284
+ expect(claude.run.mock.calls[0][0].sessionFlag).toBe("--session-id");
285
+ expect(claude.run.mock.calls[1][0].sessionFlag).toBe("--resume");
286
+ });
287
+
288
+ it("handleSessionCommand sends session via onResponse", async () => {
289
+ saveSettings({ sessionId: "test-id" }, tmpSettingsDir);
290
+ const claude = mockClaude(successResult({ action: "send", message: "", actionReason: "" }));
291
+ const { orch, responses } = makeOrchestrator(claude);
292
+
293
+ orch.handleSessionCommand();
294
+ await waitForProcessing();
295
+
296
+ expect(responses).toHaveLength(1);
297
+ expect(responses[0].message).toBe("Session: <code>test-id</code>");
298
+ });
299
+
300
+ it("background-agent forks from main session without affecting it", async () => {
301
+ saveSettings({ sessionId: "main-session" }, tmpSettingsDir);
302
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }, "main-session"));
303
+ const { orch } = makeOrchestrator(claude);
304
+
305
+ orch.handleMessage("hello");
306
+ await waitForProcessing();
307
+
308
+ // Trigger a background agent spawn via a response with backgroundAgents
309
+ // The background agent is spawned by #deliverClaudeResponse
310
+ // We can't directly call handleBackgroundCommand here because it uses #spawnBackground
311
+ // which calls #processRequest (background-agent type)
312
+ // So let's verify via handleBackgroundCommand
313
+ orch.handleBackgroundCommand("do work");
314
+ await waitForProcessing();
315
+
316
+ // background-agent should use --resume and forkSession
317
+ expect(claude.run.mock.calls[1][0].sessionFlag).toBe("--resume");
318
+ expect(claude.run.mock.calls[1][0].forkSession).toBe(true);
319
+ });
320
+
321
+ it("updates session ID after forked call", async () => {
322
+ saveSettings({ sessionId: "old-session" }, tmpSettingsDir);
323
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }, "new-forked-session"));
324
+ const { orch } = makeOrchestrator(claude);
325
+
326
+ orch.handleMessage("hello");
327
+ await waitForProcessing();
328
+
329
+ // Next call should use the new session ID
330
+ orch.handleMessage("follow up");
331
+ await waitForProcessing();
332
+
333
+ expect(claude.run.mock.calls[1][0].sessionId).toBe("new-forked-session");
334
+ });
335
+ });
336
+
337
+ describe("queue-based processing", () => {
338
+ it("handleMessage queues a user request and calls onResponse", async () => {
339
+ const claude = mockClaude(successResult({ action: "send", message: "result", actionReason: "ok" }));
340
+ const { orch, responses } = makeOrchestrator(claude);
341
+
342
+ orch.handleMessage("test message");
343
+ await waitForProcessing();
344
+
345
+ expect(claude.run).toHaveBeenCalledTimes(1);
346
+ expect(responses[0].message).toBe("result");
347
+ });
348
+
349
+ it("handleButton queues a button request", async () => {
350
+ const claude = mockClaude(successResult({ action: "send", message: "button response", actionReason: "ok" }));
351
+ const { orch, responses } = makeOrchestrator(claude);
352
+
353
+ orch.handleButton("Yes");
354
+ await waitForProcessing();
355
+
356
+ expect(claude.run.mock.calls[0][0].prompt).toBe('[Context: button-click] User tapped "Yes"');
357
+ expect(responses[0].message).toBe("button response");
358
+ });
359
+
360
+ it("handleCron queues a cron request with right params", async () => {
361
+ const claude = mockClaude(successResult({ action: "send", message: "cron done", actionReason: "ok" }));
362
+ const { orch, responses } = makeOrchestrator(claude);
363
+
364
+ orch.handleCron("daily-check", "Check for updates", "haiku");
365
+ await waitForProcessing();
366
+
367
+ expect(claude.run.mock.calls[0][0].prompt).toBe("[Context: cron/daily-check] Check for updates");
368
+ expect(claude.run.mock.calls[0][0].model).toBe("haiku");
369
+ expect(responses[0].message).toBe("cron done");
370
+ });
371
+
372
+ it("processes requests serially (FIFO)", async () => {
373
+ const callOrder: number[] = [];
374
+ let firstResolve: () => void;
375
+ const firstCallDone = new Promise<void>((r) => { firstResolve = r; });
376
+
377
+ const claude = mockClaude(async (_opts: ClaudeRunOptions): Promise<ClaudeResult> => {
378
+ const callNum = (claude as any).run.mock.calls.length;
379
+ if (callNum === 1) {
380
+ await firstCallDone;
381
+ }
382
+ callOrder.push(callNum);
383
+ return successResult({ action: "send", message: `call ${callNum}`, actionReason: "ok" });
384
+ });
385
+ const { orch } = makeOrchestrator(claude);
386
+
387
+ orch.handleMessage("first");
388
+ orch.handleMessage("second");
389
+
390
+ // Let first call start but not finish
391
+ await new Promise((r) => setTimeout(r, 10));
392
+ firstResolve!();
393
+ await waitForProcessing();
394
+
395
+ // Verify they ran in order
396
+ expect((claude as any).run).toHaveBeenCalledTimes(2);
397
+ expect(callOrder).toEqual([1, 2]);
398
+ });
399
+
400
+ it("silent response: onResponse not called when action=silent", async () => {
401
+ const claude = mockClaude(successResult({ action: "silent", actionReason: "no new results" }));
402
+ const { orch, onResponse } = makeOrchestrator(claude);
403
+
404
+ orch.handleMessage("hello");
405
+ await waitForProcessing();
406
+
407
+ expect(onResponse).not.toHaveBeenCalled();
408
+ });
409
+
410
+ it("background agents spawned from Claude response: calls onResponse with started message", async () => {
411
+ let callCount = 0;
412
+ const claude = mockClaude(async (): Promise<ClaudeResult> => {
413
+ callCount++;
414
+ if (callCount === 1) {
415
+ return successResult({
416
+ action: "send",
417
+ message: "Starting research",
418
+ actionReason: "needs research",
419
+ backgroundAgents: [{ name: "research", prompt: "research this" }],
420
+ });
421
+ }
422
+ return successResult({ action: "send", message: "research result", actionReason: "done" });
423
+ });
424
+ const { orch, responses } = makeOrchestrator(claude);
425
+
426
+ orch.handleMessage("hello");
427
+ await waitForProcessing();
428
+
429
+ const messages = responses.map((r) => r.message);
430
+ expect(messages).toContain("Starting research");
431
+ expect(messages).toContain('Background agent "research" started.');
432
+
433
+ // Background agent result should be fed back
434
+ await waitForProcessing(100);
435
+ expect(callCount).toBe(3); // 1 main + 1 bg agent + 1 bg result fed back
436
+ });
437
+
438
+ it("deferred → sends 'taking longer' via onResponse, feeds result back when resolved", async () => {
439
+ saveSettings({ sessionId: "test-session" }, tmpSettingsDir);
440
+ let resolveCompletion: (r: ClaudeResult) => void;
441
+ const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
442
+ const claude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> =>
443
+ ({ deferred: true, sessionId: "test-session", completion }),
444
+ );
445
+ const { orch, responses } = makeOrchestrator(claude);
446
+
447
+ orch.handleMessage("slow task");
448
+ await waitForProcessing();
449
+
450
+ const messages = responses.map((r) => r.message);
451
+ expect(messages).toContain("This is taking longer, continuing in the background.");
452
+
453
+ resolveCompletion!(successResult({ action: "send", message: "done!", actionReason: "ok" }));
454
+ await waitForProcessing(100);
455
+
456
+ const allMessages = responses.map((r) => r.message);
457
+ expect(allMessages).toContain("done!");
458
+ });
459
+
460
+ it("session fork when background agent running on main session", async () => {
461
+ saveSettings({ sessionId: "test-session" }, tmpSettingsDir);
462
+ let resolveCompletion: (r: ClaudeResult) => void;
463
+ const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
464
+ let callCount = 0;
465
+ const claude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> => {
466
+ callCount++;
467
+ if (callCount === 1) return { deferred: true, sessionId: "test-session", completion };
468
+ return successResult({ action: "send", message: "forked response", actionReason: "ok" });
469
+ });
470
+ const { orch } = makeOrchestrator(claude);
471
+
472
+ // First message gets deferred (backgrounded on test-session)
473
+ orch.handleMessage("slow task");
474
+ await waitForProcessing();
475
+
476
+ // Second message should trigger a fork (background running on test-session = main session)
477
+ orch.handleMessage("follow up");
478
+ await waitForProcessing();
479
+
480
+ const opts = (claude as any).run.mock.calls[1][0] as ClaudeRunOptions;
481
+ expect(opts.forkSession).toBe(true);
482
+
483
+ resolveCompletion!(successResult({ action: "send", message: "bg done", actionReason: "ok" }));
484
+ await waitForProcessing(50);
485
+ });
486
+
487
+ it("background result with matching session: applied directly (no extra Claude call)", async () => {
488
+ saveSettings({ sessionId: "test-session" }, tmpSettingsDir);
489
+ // Use a deferred claude to simulate the scenario where a task is backgrounded
490
+ let resolveCompletion: (r: ClaudeResult) => void;
491
+ const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
492
+ const deferredClaude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> =>
493
+ ({ deferred: true, sessionId: "test-session", completion }),
494
+ );
495
+ const { orch: orch2, responses: responses2 } = makeOrchestrator(deferredClaude);
496
+
497
+ orch2.handleMessage("slow");
498
+ await waitForProcessing();
499
+ // Now test-session is tracked as adopted
500
+
501
+ // Resolve with a result that has sessionId matching
502
+ resolveCompletion!(successResult({ action: "send", message: "direct result", actionReason: "ok" }, "test-session"));
503
+ await waitForProcessing(100);
504
+
505
+ const messages = responses2.map((r) => r.message);
506
+ expect(messages).toContain("direct result");
507
+ // The deferred claude was only called once (for the initial slow request, not for the result)
508
+ expect(deferredClaude.run).toHaveBeenCalledTimes(1);
509
+ });
510
+ });
511
+
512
+ describe("handleBackgroundList", () => {
513
+ it("sends 'no agents' message when none running", async () => {
514
+ const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
515
+ const { orch, responses } = makeOrchestrator(claude);
516
+
517
+ orch.handleBackgroundList();
518
+ await waitForProcessing();
519
+
520
+ expect(responses[0].message).toBe("No background agents running.");
521
+ });
522
+ });
523
+
524
+ describe("handleBackgroundCommand", () => {
525
+ it("spawns background agent and sends started message", async () => {
526
+ const claude = mockClaude(() => new Promise<ClaudeResult>(() => {}));
527
+ const { orch, responses } = makeOrchestrator(claude);
528
+
529
+ orch.handleBackgroundCommand("research pricing");
530
+ await waitForProcessing();
531
+
532
+ expect(responses[0].message).toBe('Background agent "research-pricing" started.');
533
+ expect((claude as any).run).toHaveBeenCalledTimes(1);
534
+ });
535
+ });
536
+
537
+ describe("background management (spawn/adopt)", () => {
538
+ it("spawns background agent and feeds result back to queue", async () => {
539
+ let resolvePromise: (r: ClaudeResult) => void;
540
+ const claudePromise = new Promise<ClaudeResult>((r) => {
541
+ resolvePromise = r;
542
+ });
543
+ let callCount = 0;
544
+ const claude = mockClaude(async (): Promise<ClaudeResult> => {
545
+ callCount++;
546
+ if (callCount === 1) return claudePromise;
547
+ return successResult({ action: "send", message: "bg result processed", actionReason: "ok" });
548
+ });
549
+ const { orch, responses } = makeOrchestrator(claude);
550
+
551
+ orch.handleBackgroundCommand("do something");
552
+ await waitForProcessing();
553
+
554
+ expect(responses[0].message).toContain('started.');
555
+
556
+ resolvePromise!(successResult({ action: "send", message: "done!", actionReason: "completed" }));
557
+ await claudePromise;
558
+ await waitForProcessing(100);
559
+
560
+ // The bg result gets fed back to the queue and processed
561
+ expect(callCount).toBe(2); // 1 bg agent + 1 bg result fed back
562
+ });
563
+
564
+ it("feeds error back to queue on spawn failure", async () => {
565
+ let rejectPromise: (e: Error) => void;
566
+ const claudePromise = new Promise<ClaudeResult>((_, r) => {
567
+ rejectPromise = r;
568
+ });
569
+ let callCount = 0;
570
+ const claude = mockClaude(async (): Promise<ClaudeResult> => {
571
+ callCount++;
572
+ if (callCount === 1) return claudePromise;
573
+ return successResult({ action: "send", message: "error processed", actionReason: "ok" });
574
+ });
575
+ const { orch, responses } = makeOrchestrator(claude);
576
+
577
+ orch.handleBackgroundCommand("failing task");
578
+ await waitForProcessing();
579
+
580
+ rejectPromise!(new Error("spawn failed"));
581
+ try { await claudePromise; } catch {}
582
+ await waitForProcessing(100);
583
+
584
+ // Error should be fed back and processed
585
+ expect(callCount).toBe(2);
586
+ expect(responses[responses.length - 1].message).toBe("error processed");
587
+ });
588
+
589
+ it("adopt feeds result back when deferred resolves", async () => {
590
+ saveSettings({ sessionId: "adopted-session" }, tmpSettingsDir);
591
+ let resolveCompletion: (r: ClaudeResult) => void;
592
+ const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
593
+ const claude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> =>
594
+ ({ deferred: true, sessionId: "adopted-session", completion }),
595
+ );
596
+ const { orch, responses } = makeOrchestrator(claude);
597
+
598
+ orch.handleMessage("slow");
599
+ await waitForProcessing();
600
+
601
+ expect(responses[0].message).toContain("taking longer");
602
+
603
+ resolveCompletion!(successResult({ action: "send", message: "completed!", actionReason: "ok" }));
604
+ await waitForProcessing(100);
605
+
606
+ const messages = responses.map((r) => r.message);
607
+ expect(messages).toContain("completed!");
608
+ });
609
+
610
+ });
611
+
612
+ describe("onResponse error handling", () => {
613
+ it("logs error and does not throw when onResponse callback fails", async () => {
614
+ const claude = mockClaude(successResult({ action: "send", message: "hello", actionReason: "ok" }));
615
+ const failingOnResponse = mock(async (_r: OrchestratorResponse) => { throw new Error("send failed"); });
616
+ const orch = new Orchestrator({
617
+ workspace: TEST_WORKSPACE,
618
+ settingsDir: tmpSettingsDir,
619
+ onResponse: failingOnResponse,
620
+ claude,
621
+ });
622
+
623
+ // handleBackgroundList and handleBackgroundCommand use #callOnResponse
624
+ orch.handleBackgroundList();
625
+ await waitForProcessing();
626
+
627
+ // Should not throw — error is caught internally
628
+ expect(failingOnResponse).toHaveBeenCalled();
629
+ });
630
+ });
631
+ });