macroclaw 0.26.0 → 0.28.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.
@@ -7,13 +7,14 @@ import { saveSessions } from "./sessions";
7
7
  const tmpSettingsDir = "/tmp/macroclaw-test-orchestrator-settings";
8
8
  const TEST_WORKSPACE = "/tmp/macroclaw-test-workspace";
9
9
 
10
- beforeEach(() => {
11
- if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
12
- });
10
+ function cleanup() {
11
+ try {
12
+ if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
13
+ } catch { /* async completion handlers may race with cleanup */ }
14
+ }
13
15
 
14
- afterEach(() => {
15
- if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
16
- });
16
+ beforeEach(cleanup);
17
+ afterEach(cleanup);
17
18
 
18
19
  interface CallInfo {
19
20
  method: "newSession" | "resumeSession" | "forkSession";
@@ -38,6 +39,17 @@ function resolvedQuery<T>(value: T, sessionId = "test-session-id"): RunningQuery
38
39
  };
39
40
  }
40
41
 
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; });
46
+ return {
47
+ query: { sessionId, startedAt: new Date(), result, kill: mock(async () => {}) },
48
+ resolve,
49
+ reject,
50
+ };
51
+ }
52
+
41
53
  function mockClaude(handler: MockHandler | unknown) {
42
54
  const calls: CallInfo[] = [];
43
55
  const handlerFn: MockHandler = typeof handler === "function"
@@ -91,7 +103,8 @@ describe("Orchestrator", () => {
91
103
  orch.handleMessage("hello");
92
104
  await waitForProcessing();
93
105
 
94
- expect(claude.calls[0].prompt).toBe("hello");
106
+ expect(claude.calls[0].prompt).toContain('type="user-message"');
107
+ expect(claude.calls[0].prompt).toContain("<text>hello</text>");
95
108
  });
96
109
 
97
110
  it("prepends file references for user requests", async () => {
@@ -101,7 +114,9 @@ describe("Orchestrator", () => {
101
114
  orch.handleMessage("check this", ["/tmp/photo.jpg", "/tmp/doc.pdf"]);
102
115
  await waitForProcessing();
103
116
 
104
- expect(claude.calls[0].prompt).toBe("[File: /tmp/photo.jpg]\n[File: /tmp/doc.pdf]\ncheck this");
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>");
105
120
  });
106
121
 
107
122
  it("sends only file references when message is empty", async () => {
@@ -111,37 +126,8 @@ describe("Orchestrator", () => {
111
126
  orch.handleMessage("", ["/tmp/photo.jpg"]);
112
127
  await waitForProcessing();
113
128
 
114
- expect(claude.calls[0].prompt).toBe("[File: /tmp/photo.jpg]");
115
- });
116
-
117
- it("builds cron prompt with prefix", async () => {
118
- const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
119
- const { orch } = makeOrchestrator(claude);
120
-
121
- orch.handleCron("daily", "check updates");
122
- await waitForProcessing();
123
-
124
- expect(claude.calls[0].prompt).toBe("[Context: cron/daily] check updates");
125
- });
126
-
127
- it("uses cron model override", async () => {
128
- const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
129
- const { orch } = makeOrchestrator(claude, { model: "sonnet" });
130
-
131
- orch.handleCron("smart", "think", "opus");
132
- await waitForProcessing();
133
-
134
- expect(claude.calls[0].model).toBe("opus");
135
- });
136
-
137
- it("falls back to config model when cron has no model", async () => {
138
- const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
139
- const { orch } = makeOrchestrator(claude, { model: "sonnet" });
140
-
141
- orch.handleCron("basic", "check");
142
- await waitForProcessing();
143
-
144
- expect(claude.calls[0].model).toBe("sonnet");
129
+ expect(claude.calls[0].prompt).toContain('<file path="/tmp/photo.jpg" />');
130
+ expect(claude.calls[0].prompt).not.toContain("<text>");
145
131
  });
146
132
 
147
133
  it("builds button click prompt", async () => {
@@ -151,7 +137,7 @@ describe("Orchestrator", () => {
151
137
  orch.handleButton("Yes");
152
138
  await waitForProcessing();
153
139
 
154
- expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
140
+ expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
155
141
  });
156
142
  });
157
143
 
@@ -226,6 +212,24 @@ describe("Orchestrator", () => {
226
212
 
227
213
  expect(responses[0].message).toContain("[JSON Error]");
228
214
  });
215
+
216
+ it("reports error when resume fails", async () => {
217
+ 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
+ }));
224
+ const { orch, responses } = makeOrchestrator(claude);
225
+
226
+ orch.handleMessage("hello");
227
+ await waitForProcessing();
228
+
229
+ expect(claude.calls[0].method).toBe("resumeSession");
230
+ expect(responses[0].message).toContain("[Error]");
231
+ expect(responses[0].message).toContain("session not found");
232
+ });
229
233
  });
230
234
 
231
235
  describe("session management", () => {
@@ -251,31 +255,6 @@ describe("Orchestrator", () => {
251
255
  expect(claude.calls[0].method).toBe("newSession");
252
256
  });
253
257
 
254
- it("creates new session when resume fails", async () => {
255
- saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
256
- let callCount = 0;
257
- const claude = mockClaude((_info: CallInfo): RunningQuery<unknown> => {
258
- callCount++;
259
- if (callCount === 1) {
260
- return {
261
- sessionId: "old-session",
262
- startedAt: new Date(),
263
- result: Promise.reject(new QueryProcessError(1, "session not found")),
264
- kill: mock(async () => {}),
265
- };
266
- }
267
- return resolvedQuery({ action: "send", message: "ok", actionReason: "ok" });
268
- });
269
- const { orch } = makeOrchestrator(claude);
270
-
271
- orch.handleMessage("hello");
272
- await waitForProcessing();
273
-
274
- expect(callCount).toBe(2);
275
- expect(claude.calls[0].method).toBe("resumeSession");
276
- expect(claude.calls[1].method).toBe("newSession");
277
- });
278
-
279
258
  it("switches to resumeSession after first success", async () => {
280
259
  const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
281
260
  const { orch } = makeOrchestrator(claude);
@@ -289,18 +268,6 @@ describe("Orchestrator", () => {
289
268
  expect(claude.calls[1].method).toBe("resumeSession");
290
269
  });
291
270
 
292
- it("handleSessionCommand sends session via onResponse", async () => {
293
- saveSessions({ mainSessionId: "test-id" }, tmpSettingsDir);
294
- const claude = mockClaude({ action: "send", message: "", actionReason: "" });
295
- const { orch, responses } = makeOrchestrator(claude);
296
-
297
- orch.handleSessionCommand();
298
- await waitForProcessing();
299
-
300
- expect(responses).toHaveLength(1);
301
- expect(responses[0].message).toBe("Session: <code>test-id</code>");
302
- });
303
-
304
271
  it("background-agent forks from main session", async () => {
305
272
  saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
306
273
  const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
@@ -312,7 +279,6 @@ describe("Orchestrator", () => {
312
279
  orch.handleBackgroundCommand("do work");
313
280
  await waitForProcessing();
314
281
 
315
- // bg agent uses forkSession
316
282
  expect(claude.calls[1].method).toBe("forkSession");
317
283
  });
318
284
 
@@ -324,7 +290,6 @@ describe("Orchestrator", () => {
324
290
  orch.handleMessage("hello");
325
291
  await waitForProcessing();
326
292
 
327
- // Next call should use the new session ID
328
293
  orch.handleMessage("follow up");
329
294
  await waitForProcessing();
330
295
 
@@ -332,75 +297,165 @@ describe("Orchestrator", () => {
332
297
  });
333
298
  });
334
299
 
335
- describe("queue-based processing", () => {
336
- it("handleMessage queues a user request and calls onResponse", async () => {
337
- const claude = mockClaude({ action: "send", message: "result", actionReason: "ok" });
300
+ describe("non-blocking handler", () => {
301
+ it("handler returns immediately, result delivered by completion handler", async () => {
302
+ const { query, resolve } = pendingQuery("main-sid");
303
+ const claude = mockClaude(() => query);
338
304
  const { orch, responses } = makeOrchestrator(claude);
339
305
 
340
- orch.handleMessage("test message");
306
+ orch.handleMessage("hello");
341
307
  await waitForProcessing();
342
308
 
343
- expect(claude.calls).toHaveLength(1);
344
- expect(responses[0].message).toBe("result");
309
+ // Handler returned but no response yet (query still pending)
310
+ expect(responses).toHaveLength(0);
311
+
312
+ // Query completes — completion handler delivers
313
+ resolve(queryResult({ action: "send", message: "done!", actionReason: "ok" }));
314
+ await waitForProcessing();
315
+
316
+ expect(responses[0].message).toBe("done!");
345
317
  });
346
318
 
347
- it("handleButton queues a button request", async () => {
348
- const claude = mockClaude({ action: "send", message: "button response", actionReason: "ok" });
349
- const { orch, responses } = makeOrchestrator(claude);
319
+ 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");
350
322
 
351
- orch.handleButton("Yes");
323
+ let callCount = 0;
324
+ const claude = mockClaude((): RunningQuery<unknown> => {
325
+ callCount++;
326
+ if (callCount === 1) return q1;
327
+ return q2;
328
+ });
329
+ // waitThreshold=0 so second message demotes immediately
330
+ const { orch, responses } = makeOrchestrator(claude, { waitThreshold: 0 });
331
+
332
+ orch.handleMessage("first");
352
333
  await waitForProcessing();
353
334
 
354
- expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
355
- expect(responses[0].message).toBe("button response");
335
+ // First query is running, handler returned
336
+ expect(callCount).toBe(1);
337
+
338
+ orch.handleMessage("second");
339
+ await waitForProcessing();
340
+
341
+ // Second message caused a fork+demote, second query started
342
+ expect(callCount).toBe(2);
343
+ const secondCall = claude.calls[1];
344
+ expect(secondCall.method).toBe("forkSession");
345
+ expect(secondCall.prompt).toContain("<backgrounded-event");
346
+ expect(secondCall.prompt).toContain("<text>second</text>");
347
+
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"));
351
+ await waitForProcessing(100);
352
+
353
+ const messages = responses.map((r) => r.message);
354
+ expect(messages).toContain("second done");
355
+ // First result goes through Claude as background context (not direct)
356
356
  });
357
357
 
358
- it("handleCron queues a cron request with right params", async () => {
359
- const claude = mockClaude({ action: "send", message: "cron done", actionReason: "ok" });
358
+ it("waits for main to finish when within threshold, then processes next message", async () => {
359
+ const { query: q1, resolve: resolve1 } = pendingQuery("main-sid");
360
+
361
+ let callCount = 0;
362
+ const claude = mockClaude((): RunningQuery<unknown> => {
363
+ callCount++;
364
+ if (callCount === 1) return q1;
365
+ return resolvedQuery({ action: "send", message: "follow-up result", actionReason: "ok" });
366
+ });
360
367
  const { orch, responses } = makeOrchestrator(claude);
361
368
 
362
- orch.handleCron("daily-check", "Check for updates", "haiku");
369
+ // First message handler returns immediately
370
+ orch.handleMessage("slow task");
363
371
  await waitForProcessing();
372
+ expect(callCount).toBe(1);
364
373
 
365
- expect(claude.calls[0].prompt).toBe("[Context: cron/daily-check] Check for updates");
366
- expect(claude.calls[0].model).toBe("haiku");
367
- expect(responses[0].message).toBe("cron done");
374
+ // Second message main is running, within threshold, handler blocks
375
+ orch.handleMessage("follow up");
376
+ await waitForProcessing(10);
377
+
378
+ // Still blocked, only 1 call
379
+ expect(callCount).toBe(1);
380
+
381
+ // First query finishes — completion handler delivers, then handler unblocks
382
+ resolve1(queryResult({ action: "send", message: "slow done", actionReason: "ok" }));
383
+ await waitForProcessing(100);
384
+
385
+ expect(callCount).toBe(2);
386
+ const messages = responses.map((r) => r.message);
387
+ expect(messages).toContain("slow done");
388
+ expect(messages).toContain("follow-up result");
368
389
  });
369
390
 
370
- it("processes requests serially (FIFO)", async () => {
371
- const callOrder: number[] = [];
372
- let firstResolve: () => void;
373
- const firstCallDone = new Promise<void>((r) => { firstResolve = r; });
391
+ it("demotes after wait timeout when main does not finish in time", async () => {
392
+ const { query: q1 } = pendingQuery("main-sid");
374
393
 
375
- let callNum = 0;
394
+ let callCount = 0;
376
395
  const claude = mockClaude((): RunningQuery<unknown> => {
377
- callNum++;
378
- const n = callNum;
379
- if (n === 1) {
380
- return {
381
- sessionId: "sid",
382
- startedAt: new Date(),
383
- result: firstCallDone.then(() => {
384
- callOrder.push(n);
385
- return queryResult({ action: "send", message: `call ${n}`, actionReason: "ok" });
386
- }),
387
- kill: mock(async () => {}),
388
- };
389
- }
390
- callOrder.push(n);
391
- return resolvedQuery({ action: "send", message: `call ${n}`, actionReason: "ok" });
396
+ callCount++;
397
+ if (callCount === 1) return q1;
398
+ return resolvedQuery({ action: "send", message: "forked result", actionReason: "ok" }, "new-main");
392
399
  });
393
- const { orch } = makeOrchestrator(claude);
400
+ // Short threshold so we don't wait long
401
+ const { orch, responses } = makeOrchestrator(claude, { waitThreshold: 10 });
394
402
 
395
- orch.handleMessage("first");
396
- orch.handleMessage("second");
403
+ orch.handleMessage("slow task");
404
+ await waitForProcessing();
405
+
406
+ orch.handleMessage("follow up");
407
+ await waitForProcessing(200);
408
+
409
+ expect(callCount).toBe(2);
410
+ const userCall = claude.calls[1];
411
+ expect(userCall.prompt).toContain("<backgrounded-event");
412
+ expect(userCall.prompt).toContain("follow up");
413
+ expect(responses.map((r) => r.message)).toContain("forked result");
414
+ });
415
+
416
+ it("delivers result with error when session not in runningSessions", async () => {
417
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
418
+ const { query, resolve } = pendingQuery("main-session");
419
+ const claude = mockClaude(() => query);
420
+ const { orch, responses } = makeOrchestrator(claude);
421
+
422
+ orch.handleMessage("hello");
423
+ await waitForProcessing();
424
+
425
+ // Kill the session (removes from runningSessions)
426
+ await orch.handleKill("main-session");
427
+ await waitForProcessing();
428
+
429
+ // Query completes after kill — should still deliver (with error log)
430
+ resolve(queryResult({ action: "send", message: "late result", actionReason: "ok" }));
431
+ await waitForProcessing();
432
+
433
+ const messages = responses.map((r) => r.message);
434
+ expect(messages).toContain("late result");
435
+ });
436
+ });
437
+
438
+ describe("queue-based processing", () => {
439
+ it("handleMessage queues a user request and calls onResponse", async () => {
440
+ const claude = mockClaude({ action: "send", message: "result", actionReason: "ok" });
441
+ const { orch, responses } = makeOrchestrator(claude);
442
+
443
+ orch.handleMessage("test message");
444
+ await waitForProcessing();
445
+
446
+ expect(claude.calls).toHaveLength(1);
447
+ expect(responses[0].message).toBe("result");
448
+ });
449
+
450
+ it("handleButton queues a button request", async () => {
451
+ const claude = mockClaude({ action: "send", message: "button response", actionReason: "ok" });
452
+ const { orch, responses } = makeOrchestrator(claude);
397
453
 
398
- await new Promise((r) => setTimeout(r, 10));
399
- firstResolve!();
454
+ orch.handleButton("Yes");
400
455
  await waitForProcessing();
401
456
 
402
- expect(claude.calls).toHaveLength(2);
403
- expect(callOrder).toEqual([1, 2]);
457
+ expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
458
+ expect(responses[0].message).toBe("button response");
404
459
  });
405
460
 
406
461
  it("silent response: onResponse not called when action=silent", async () => {
@@ -436,130 +491,65 @@ describe("Orchestrator", () => {
436
491
  expect(messages).toContain("Starting research");
437
492
  expect(messages).toContain('Background agent "research" started.');
438
493
 
439
- // Background agent result should be fed back
440
494
  await waitForProcessing(100);
441
495
  expect(callCount).toBe(3); // 1 main + 1 bg agent + 1 bg result fed back
442
496
  });
497
+ });
443
498
 
444
- it("deferred → sends 'taking longer' via onResponse, feeds result back when resolved", async () => {
445
- saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
446
- let resolveCompletion: (r: QueryResult<unknown>) => void;
447
- const completion = new Promise<QueryResult<unknown>>((r) => { resolveCompletion = r; });
448
-
449
- // Mock setTimeout to fire immediately only for the timeout race, not for waitForProcessing
450
- const origSetTimeout = globalThis.setTimeout;
451
- const claude = mockClaude((): RunningQuery<unknown> => {
452
- // Mock setTimeout right before the race happens (synchronously after this returns)
453
- globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
454
- return {
455
- sessionId: "test-session",
456
- startedAt: new Date(),
457
- result: completion,
458
- kill: mock(async () => {}),
459
- };
460
- });
461
- const { orch, responses } = makeOrchestrator(claude);
499
+ describe("cron routing", () => {
500
+ it("cron always forks as background, never goes through queue", async () => {
501
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
502
+ const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
503
+ const { orch } = makeOrchestrator(claude);
462
504
 
463
- orch.handleMessage("slow task");
464
- // Restore immediately so waitForProcessing works
465
- await new Promise((r) => origSetTimeout(r, 10));
466
- globalThis.setTimeout = origSetTimeout;
505
+ orch.handleCron("daily-check", "Check for updates", "haiku");
467
506
  await waitForProcessing();
468
507
 
469
- const messages = responses.map((r) => r.message);
470
- expect(messages).toContain("This is taking longer, continuing in the background.");
471
-
472
- resolveCompletion!(queryResult({ action: "send", message: "done!", actionReason: "ok" }));
473
- await waitForProcessing(100);
474
-
475
- const allMessages = responses.map((r) => r.message);
476
- expect(allMessages).toContain("done!");
508
+ 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>");
511
+ expect(claude.calls[0].model).toBe("haiku");
477
512
  });
478
513
 
479
- it("session fork when background agent running on main session", async () => {
480
- saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
481
- let resolveCompletion: (r: QueryResult<unknown>) => void;
482
- const completion = new Promise<QueryResult<unknown>>((r) => { resolveCompletion = r; });
483
-
484
- const origSetTimeout = globalThis.setTimeout;
485
-
486
- let callCount = 0;
487
- const claude = mockClaude((): RunningQuery<unknown> => {
488
- callCount++;
489
- if (callCount === 1) {
490
- globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
491
- return {
492
- sessionId: "test-session",
493
- startedAt: new Date(),
494
- result: completion,
495
- kill: mock(async () => {}),
496
- };
497
- }
498
- return resolvedQuery({ action: "send", message: "forked response", actionReason: "ok" });
499
- });
500
- const { orch } = makeOrchestrator(claude);
501
-
502
- // First message gets deferred (backgrounded on test-session)
503
- orch.handleMessage("slow task");
504
- await new Promise((r) => origSetTimeout(r, 10));
505
- globalThis.setTimeout = origSetTimeout;
506
- await waitForProcessing();
514
+ it("cron uses config model when none specified", async () => {
515
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
516
+ const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
517
+ const { orch } = makeOrchestrator(claude, { model: "sonnet" });
507
518
 
508
- // Second message should trigger a fork (background running on test-session = main session)
509
- orch.handleMessage("follow up");
519
+ orch.handleCron("basic", "check");
510
520
  await waitForProcessing();
511
521
 
512
- expect(claude.calls[1].method).toBe("forkSession");
513
-
514
- resolveCompletion!(queryResult({ action: "send", message: "bg done", actionReason: "ok" }));
515
- await waitForProcessing(50);
522
+ expect(claude.calls[0].model).toBe("sonnet");
516
523
  });
517
524
 
518
- it("background result with matching session: applied directly (no extra Claude call)", async () => {
519
- saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
520
- let resolveCompletion: (r: QueryResult<unknown>) => void;
521
- const completion = new Promise<QueryResult<unknown>>((r) => { resolveCompletion = r; });
522
-
523
- const origSetTimeout = globalThis.setTimeout;
524
-
525
+ it("cron result feeds back into main session", async () => {
526
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
527
+ let callCount = 0;
525
528
  const claude = mockClaude((): RunningQuery<unknown> => {
526
- globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
527
- return {
528
- sessionId: "test-session",
529
- startedAt: new Date(),
530
- result: completion,
531
- kill: mock(async () => {}),
532
- };
529
+ callCount++;
530
+ return resolvedQuery({ action: "send", message: `call ${callCount}`, actionReason: "ok" });
533
531
  });
534
- const { orch, responses } = makeOrchestrator(claude);
535
-
536
- orch.handleMessage("slow");
537
- await new Promise((r) => origSetTimeout(r, 10));
538
- globalThis.setTimeout = origSetTimeout;
539
- await waitForProcessing();
532
+ const { orch } = makeOrchestrator(claude);
540
533
 
541
- resolveCompletion!(queryResult({ action: "send", message: "direct result", actionReason: "ok" }, "test-session"));
542
- await waitForProcessing(100);
534
+ orch.handleCron("check", "any updates?");
535
+ await waitForProcessing(150);
543
536
 
544
- const messages = responses.map((r) => r.message);
545
- expect(messages).toContain("direct result");
546
- // Only called once (for the initial slow request, not for the result)
547
- expect(claude.calls).toHaveLength(1);
537
+ expect(callCount).toBe(2); // 1 cron bg + 1 bg result fed back
548
538
  });
549
539
  });
550
540
 
551
- describe("handleBackgroundList", () => {
552
- it("sends 'no agents' message when none running", async () => {
541
+ describe("handleSessions", () => {
542
+ it("sends 'no sessions' message when none running", async () => {
553
543
  const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
554
544
  const { orch, responses } = makeOrchestrator(claude);
555
545
 
556
- orch.handleBackgroundList();
546
+ orch.handleSessions();
557
547
  await waitForProcessing();
558
548
 
559
- expect(responses[0].message).toBe("No background agents running.");
549
+ expect(responses[0].message).toBe("No running sessions.");
560
550
  });
561
551
 
562
- it("includes detail buttons and dismiss when agents are running", async () => {
552
+ it("includes detail buttons and dismiss when sessions are running", async () => {
563
553
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
564
554
  const claude = mockClaude((): RunningQuery<unknown> => ({
565
555
  sessionId: `bg-${Date.now()}`,
@@ -572,19 +562,35 @@ describe("Orchestrator", () => {
572
562
  orch.handleBackgroundCommand("long-task");
573
563
  await waitForProcessing();
574
564
 
575
- orch.handleBackgroundList();
565
+ orch.handleSessions();
576
566
  await waitForProcessing();
577
567
 
578
568
  const listResponse = responses[responses.length - 1];
579
569
  expect(listResponse.message).toContain("long-task");
580
570
  expect(listResponse.buttons).toBeDefined();
581
- expect(listResponse.buttons!.length).toBe(2); // 1 detail + dismiss
571
+ expect(listResponse.buttons!.length).toBe(2);
582
572
  const detailBtn = listResponse.buttons![0];
583
573
  expect(typeof detailBtn).toBe("object");
584
574
  expect((detailBtn as any).data).toMatch(/^detail:/);
585
575
  expect((detailBtn as any).text).toContain("long-task");
586
576
  expect(listResponse.buttons![1]).toEqual({ text: "Dismiss", data: "_dismiss" });
587
577
  });
578
+
579
+ it("marks main session in listing", async () => {
580
+ const { query } = pendingQuery("main-sid");
581
+ const claude = mockClaude(() => query);
582
+ const { orch, responses } = makeOrchestrator(claude);
583
+
584
+ // Start a main query (non-blocking, stays in runningSessions)
585
+ orch.handleMessage("task");
586
+ await waitForProcessing();
587
+
588
+ orch.handleSessions();
589
+ await waitForProcessing();
590
+
591
+ const listResponse = responses[responses.length - 1];
592
+ expect(listResponse.message).toContain("[main]");
593
+ });
588
594
  });
589
595
 
590
596
  describe("handlePeek", () => {
@@ -595,7 +601,7 @@ describe("Orchestrator", () => {
595
601
  await orch.handlePeek("nonexistent-session");
596
602
  await waitForProcessing();
597
603
 
598
- expect(responses[0].message).toBe("Agent not found or already finished.");
604
+ expect(responses[0].message).toBe("Session not found or already finished.");
599
605
  });
600
606
 
601
607
  it("peeks at running agent and returns status", async () => {
@@ -604,7 +610,6 @@ describe("Orchestrator", () => {
604
610
  const claude = mockClaude((): RunningQuery<unknown> => {
605
611
  callCount++;
606
612
  if (callCount === 1) {
607
- // bg agent — never finishes
608
613
  return {
609
614
  sessionId: "bg-sid",
610
615
  startedAt: new Date(),
@@ -612,7 +617,6 @@ describe("Orchestrator", () => {
612
617
  kill: mock(async () => {}),
613
618
  };
614
619
  }
615
- // peek fork call
616
620
  return resolvedQuery("Working on it, 50% done.", "peek-session");
617
621
  });
618
622
  const { orch, responses } = makeOrchestrator(claude);
@@ -620,12 +624,11 @@ describe("Orchestrator", () => {
620
624
  orch.handleBackgroundCommand("research");
621
625
  await waitForProcessing();
622
626
 
623
- // Get the internal session ID from the peek button
624
- orch.handleBackgroundList();
627
+ orch.handleSessions();
625
628
  await waitForProcessing();
626
629
  const listResponse = responses[responses.length - 1];
627
630
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
628
- const sessionId = detailBtn.data.slice(7); // strip "detail:"
631
+ const sessionId = detailBtn.data.slice(7);
629
632
 
630
633
  await orch.handlePeek(sessionId);
631
634
  await waitForProcessing();
@@ -660,11 +663,11 @@ describe("Orchestrator", () => {
660
663
  orch.handleBackgroundCommand("failing-peek");
661
664
  await waitForProcessing();
662
665
 
663
- orch.handleBackgroundList();
666
+ orch.handleSessions();
664
667
  await waitForProcessing();
665
668
  const listResponse = responses[responses.length - 1];
666
669
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
667
- const sessionId = detailBtn.data.slice(7); // strip "detail:"
670
+ const sessionId = detailBtn.data.slice(7);
668
671
 
669
672
  await orch.handlePeek(sessionId);
670
673
  await waitForProcessing();
@@ -682,10 +685,10 @@ describe("Orchestrator", () => {
682
685
  orch.handleDetail("nonexistent-session");
683
686
  await waitForProcessing();
684
687
 
685
- expect(responses[0].message).toBe("Agent not found or already finished.");
688
+ expect(responses[0].message).toBe("Session not found or already finished.");
686
689
  });
687
690
 
688
- it("shows agent details with peek/kill/dismiss buttons", async () => {
691
+ it("shows session details with peek/kill/dismiss buttons", async () => {
689
692
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
690
693
  const claude = mockClaude((): RunningQuery<unknown> => ({
691
694
  sessionId: "bg-sid",
@@ -698,7 +701,7 @@ describe("Orchestrator", () => {
698
701
  orch.handleBackgroundCommand("research pricing");
699
702
  await waitForProcessing();
700
703
 
701
- orch.handleBackgroundList();
704
+ orch.handleSessions();
702
705
  await waitForProcessing();
703
706
  const listResponse = responses[responses.length - 1];
704
707
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -732,7 +735,7 @@ describe("Orchestrator", () => {
732
735
  orch.handleBackgroundCommand(longPrompt);
733
736
  await waitForProcessing();
734
737
 
735
- orch.handleBackgroundList();
738
+ orch.handleSessions();
736
739
  await waitForProcessing();
737
740
  const listResponse = responses[responses.length - 1];
738
741
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -742,7 +745,6 @@ describe("Orchestrator", () => {
742
745
  await waitForProcessing();
743
746
 
744
747
  const detailResponse = responses[responses.length - 1];
745
- // 300 chars + ellipsis
746
748
  expect(detailResponse.message).toContain("a".repeat(300));
747
749
  expect(detailResponse.message).toContain("…");
748
750
  expect(detailResponse.message).not.toContain("a".repeat(301));
@@ -761,7 +763,7 @@ describe("Orchestrator", () => {
761
763
  orch.handleBackgroundCommand("research");
762
764
  await waitForProcessing();
763
765
 
764
- orch.handleBackgroundList();
766
+ orch.handleSessions();
765
767
  await waitForProcessing();
766
768
  const listResponse = responses[responses.length - 1];
767
769
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -783,10 +785,10 @@ describe("Orchestrator", () => {
783
785
  await orch.handleKill("nonexistent-session");
784
786
  await waitForProcessing();
785
787
 
786
- expect(responses[0].message).toBe("Agent not found or already finished.");
788
+ expect(responses[0].message).toBe("Session not found or already finished.");
787
789
  });
788
790
 
789
- it("kills running agent and sends confirmation", async () => {
791
+ it("kills running session and sends confirmation", async () => {
790
792
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
791
793
  const killMock = mock(async () => {});
792
794
  const claude = mockClaude((): RunningQuery<unknown> => ({
@@ -800,7 +802,7 @@ describe("Orchestrator", () => {
800
802
  orch.handleBackgroundCommand("research pricing");
801
803
  await waitForProcessing();
802
804
 
803
- orch.handleBackgroundList();
805
+ orch.handleSessions();
804
806
  await waitForProcessing();
805
807
  const listResponse = responses[responses.length - 1];
806
808
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -814,10 +816,9 @@ describe("Orchestrator", () => {
814
816
  expect(killResponse.message).toContain("Killed");
815
817
  expect(killResponse.message).toContain("research-pricing");
816
818
 
817
- // Agent should be removed from list
818
- orch.handleBackgroundList();
819
+ orch.handleSessions();
819
820
  await waitForProcessing();
820
- expect(responses[responses.length - 1].message).toBe("No background agents running.");
821
+ expect(responses[responses.length - 1].message).toBe("No running sessions.");
821
822
  });
822
823
 
823
824
  it("does not feed error back to queue after kill", async () => {
@@ -835,7 +836,7 @@ describe("Orchestrator", () => {
835
836
  orch.handleBackgroundCommand("task");
836
837
  await waitForProcessing();
837
838
 
838
- orch.handleBackgroundList();
839
+ orch.handleSessions();
839
840
  await waitForProcessing();
840
841
  const listResponse = responses[responses.length - 1];
841
842
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -845,12 +846,9 @@ describe("Orchestrator", () => {
845
846
  await waitForProcessing();
846
847
  const countAfterKill = responses.length;
847
848
 
848
- // Simulate the bg process rejecting after kill
849
849
  rejectBg!(new Error("process killed"));
850
850
  await waitForProcessing(100);
851
851
 
852
- // No additional error responses should have been added
853
- // Only the "Killed" message, no "[Error]" from the bg handler
854
852
  const newResponses = responses.slice(countAfterKill);
855
853
  expect(newResponses.every((r) => !r.message.includes("[Error]"))).toBe(true);
856
854
  });
@@ -875,23 +873,15 @@ describe("Orchestrator", () => {
875
873
  });
876
874
  });
877
875
 
878
- describe("background management (spawn/adopt)", () => {
876
+ describe("background management", () => {
879
877
  it("spawns background agent and feeds result back to queue", async () => {
880
878
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
881
- let resolvePromise: (r: QueryResult<unknown>) => void;
882
- const bgResult = new Promise<QueryResult<unknown>>((r) => { resolvePromise = r; });
879
+ const { query: bgQuery, resolve: resolveBg } = pendingQuery("bg-sid");
883
880
 
884
881
  let callCount = 0;
885
882
  const claude = mockClaude((): RunningQuery<unknown> => {
886
883
  callCount++;
887
- if (callCount === 1) {
888
- return {
889
- sessionId: "bg-sid",
890
- startedAt: new Date(),
891
- result: bgResult,
892
- kill: mock(async () => {}),
893
- };
894
- }
884
+ if (callCount === 1) return bgQuery;
895
885
  return resolvedQuery({ action: "send", message: "bg result processed", actionReason: "ok" });
896
886
  });
897
887
  const { orch, responses } = makeOrchestrator(claude);
@@ -901,29 +891,20 @@ describe("Orchestrator", () => {
901
891
 
902
892
  expect(responses[0].message).toContain('started.');
903
893
 
904
- resolvePromise!(queryResult({ action: "send", message: "done!", actionReason: "completed" }));
894
+ resolveBg(queryResult({ action: "send", message: "done!", actionReason: "completed" }));
905
895
  await waitForProcessing(100);
906
896
 
907
- // The bg result gets fed back to the queue and processed
908
- expect(callCount).toBe(2); // 1 bg agent + 1 bg result fed back
897
+ expect(callCount).toBe(2);
909
898
  });
910
899
 
911
900
  it("feeds error back to queue on spawn failure", async () => {
912
901
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
913
- let rejectPromise: (e: Error) => void;
914
- const bgResult = new Promise<QueryResult<unknown>>((_, r) => { rejectPromise = r; });
902
+ const { query: bgQuery, reject: rejectBg } = pendingQuery("bg-sid");
915
903
 
916
904
  let callCount = 0;
917
905
  const claude = mockClaude((): RunningQuery<unknown> => {
918
906
  callCount++;
919
- if (callCount === 1) {
920
- return {
921
- sessionId: "bg-sid",
922
- startedAt: new Date(),
923
- result: bgResult,
924
- kill: mock(async () => {}),
925
- };
926
- }
907
+ if (callCount === 1) return bgQuery;
927
908
  return resolvedQuery({ action: "send", message: "error processed", actionReason: "ok" });
928
909
  });
929
910
  const { orch, responses } = makeOrchestrator(claude);
@@ -931,75 +912,12 @@ describe("Orchestrator", () => {
931
912
  orch.handleBackgroundCommand("failing task");
932
913
  await waitForProcessing();
933
914
 
934
- rejectPromise!(new Error("spawn failed"));
915
+ rejectBg(new Error("spawn failed"));
935
916
  await waitForProcessing(100);
936
917
 
937
- // Error should be fed back and processed
938
918
  expect(callCount).toBe(2);
939
919
  expect(responses[responses.length - 1].message).toBe("error processed");
940
920
  });
941
-
942
- it("adopt feeds error back when deferred rejects", async () => {
943
- saveSessions({ mainSessionId: "adopted-session" }, tmpSettingsDir);
944
- let rejectCompletion: (err: Error) => void;
945
- const completion = new Promise<QueryResult<unknown>>((_, r) => { rejectCompletion = r; });
946
-
947
- const origSetTimeout = globalThis.setTimeout;
948
-
949
- const claude = mockClaude((): RunningQuery<unknown> => {
950
- globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
951
- return {
952
- sessionId: "adopted-session",
953
- startedAt: new Date(),
954
- result: completion,
955
- kill: mock(async () => {}),
956
- };
957
- });
958
- const { orch, responses } = makeOrchestrator(claude);
959
-
960
- orch.handleMessage("slow");
961
- await new Promise((r) => origSetTimeout(r, 10));
962
- globalThis.setTimeout = origSetTimeout;
963
- await waitForProcessing();
964
-
965
- rejectCompletion!(new Error("process crashed"));
966
- await waitForProcessing(100);
967
-
968
- const messages = responses.map((r) => r.message);
969
- expect(messages.some((m) => m.includes("[Error]"))).toBe(true);
970
- });
971
-
972
- it("adopt feeds result back when deferred resolves", async () => {
973
- saveSessions({ mainSessionId: "adopted-session" }, tmpSettingsDir);
974
- let resolveCompletion: (r: QueryResult<unknown>) => void;
975
- const completion = new Promise<QueryResult<unknown>>((r) => { resolveCompletion = r; });
976
-
977
- const origSetTimeout = globalThis.setTimeout;
978
-
979
- const claude = mockClaude((): RunningQuery<unknown> => {
980
- globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
981
- return {
982
- sessionId: "adopted-session",
983
- startedAt: new Date(),
984
- result: completion,
985
- kill: mock(async () => {}),
986
- };
987
- });
988
- const { orch, responses } = makeOrchestrator(claude);
989
-
990
- orch.handleMessage("slow");
991
- await new Promise((r) => origSetTimeout(r, 10));
992
- globalThis.setTimeout = origSetTimeout;
993
- await waitForProcessing();
994
-
995
- expect(responses[0].message).toContain("taking longer");
996
-
997
- resolveCompletion!(queryResult({ action: "send", message: "completed!", actionReason: "ok" }));
998
- await waitForProcessing(100);
999
-
1000
- const messages = responses.map((r) => r.message);
1001
- expect(messages).toContain("completed!");
1002
- });
1003
921
  });
1004
922
 
1005
923
  describe("onResponse error handling", () => {
@@ -1013,7 +931,7 @@ describe("Orchestrator", () => {
1013
931
  claude,
1014
932
  });
1015
933
 
1016
- orch.handleBackgroundList();
934
+ orch.handleSessions();
1017
935
  await waitForProcessing();
1018
936
 
1019
937
  expect(failingOnResponse).toHaveBeenCalled();