macroclaw 0.26.0 → 0.27.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"
@@ -114,36 +126,6 @@ describe("Orchestrator", () => {
114
126
  expect(claude.calls[0].prompt).toBe("[File: /tmp/photo.jpg]");
115
127
  });
116
128
 
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");
145
- });
146
-
147
129
  it("builds button click prompt", async () => {
148
130
  const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
149
131
  const { orch } = makeOrchestrator(claude);
@@ -226,6 +208,24 @@ describe("Orchestrator", () => {
226
208
 
227
209
  expect(responses[0].message).toContain("[JSON Error]");
228
210
  });
211
+
212
+ it("reports error when resume fails", async () => {
213
+ saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
214
+ const claude = mockClaude((): RunningQuery<unknown> => ({
215
+ sessionId: "old-session",
216
+ startedAt: new Date(),
217
+ result: Promise.reject(new QueryProcessError(1, "session not found")),
218
+ kill: mock(async () => {}),
219
+ }));
220
+ const { orch, responses } = makeOrchestrator(claude);
221
+
222
+ orch.handleMessage("hello");
223
+ await waitForProcessing();
224
+
225
+ expect(claude.calls[0].method).toBe("resumeSession");
226
+ expect(responses[0].message).toContain("[Error]");
227
+ expect(responses[0].message).toContain("session not found");
228
+ });
229
229
  });
230
230
 
231
231
  describe("session management", () => {
@@ -251,31 +251,6 @@ describe("Orchestrator", () => {
251
251
  expect(claude.calls[0].method).toBe("newSession");
252
252
  });
253
253
 
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
254
  it("switches to resumeSession after first success", async () => {
280
255
  const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
281
256
  const { orch } = makeOrchestrator(claude);
@@ -289,18 +264,6 @@ describe("Orchestrator", () => {
289
264
  expect(claude.calls[1].method).toBe("resumeSession");
290
265
  });
291
266
 
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
267
  it("background-agent forks from main session", async () => {
305
268
  saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
306
269
  const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
@@ -312,7 +275,6 @@ describe("Orchestrator", () => {
312
275
  orch.handleBackgroundCommand("do work");
313
276
  await waitForProcessing();
314
277
 
315
- // bg agent uses forkSession
316
278
  expect(claude.calls[1].method).toBe("forkSession");
317
279
  });
318
280
 
@@ -324,7 +286,6 @@ describe("Orchestrator", () => {
324
286
  orch.handleMessage("hello");
325
287
  await waitForProcessing();
326
288
 
327
- // Next call should use the new session ID
328
289
  orch.handleMessage("follow up");
329
290
  await waitForProcessing();
330
291
 
@@ -332,75 +293,166 @@ describe("Orchestrator", () => {
332
293
  });
333
294
  });
334
295
 
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" });
296
+ describe("non-blocking handler", () => {
297
+ it("handler returns immediately, result delivered by completion handler", async () => {
298
+ const { query, resolve } = pendingQuery("main-sid");
299
+ const claude = mockClaude(() => query);
338
300
  const { orch, responses } = makeOrchestrator(claude);
339
301
 
340
- orch.handleMessage("test message");
302
+ orch.handleMessage("hello");
341
303
  await waitForProcessing();
342
304
 
343
- expect(claude.calls).toHaveLength(1);
344
- expect(responses[0].message).toBe("result");
305
+ // Handler returned but no response yet (query still pending)
306
+ expect(responses).toHaveLength(0);
307
+
308
+ // Query completes — completion handler delivers
309
+ resolve(queryResult({ action: "send", message: "done!", actionReason: "ok" }));
310
+ await waitForProcessing();
311
+
312
+ expect(responses[0].message).toBe("done!");
345
313
  });
346
314
 
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);
315
+ it("second message processes while first is still running", async () => {
316
+ const { query: q1, resolve: resolve1 } = pendingQuery("q1-sid");
317
+ const { query: q2, resolve: resolve2 } = pendingQuery("q2-sid");
350
318
 
351
- orch.handleButton("Yes");
319
+ let callCount = 0;
320
+ const claude = mockClaude((): RunningQuery<unknown> => {
321
+ callCount++;
322
+ if (callCount === 1) return q1;
323
+ return q2;
324
+ });
325
+ // waitThreshold=0 so second message demotes immediately
326
+ const { orch, responses } = makeOrchestrator(claude, { waitThreshold: 0 });
327
+
328
+ orch.handleMessage("first");
352
329
  await waitForProcessing();
353
330
 
354
- expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
355
- expect(responses[0].message).toBe("button response");
331
+ // First query is running, handler returned
332
+ expect(callCount).toBe(1);
333
+
334
+ orch.handleMessage("second");
335
+ await waitForProcessing();
336
+
337
+ // Second message caused a fork+demote, second query started
338
+ expect(callCount).toBe(2);
339
+ const secondCall = claude.calls[1];
340
+ expect(secondCall.method).toBe("forkSession");
341
+ expect(secondCall.prompt).toContain("[Context: previous task");
342
+ expect(secondCall.prompt).toContain("moved to background]");
343
+ expect(secondCall.prompt).toContain("second");
344
+
345
+ // Resolve both
346
+ resolve2(queryResult({ action: "send", message: "second done", actionReason: "ok" }, "q2-sid"));
347
+ resolve1(queryResult({ action: "send", message: "first done", actionReason: "ok" }, "q1-sid"));
348
+ await waitForProcessing(100);
349
+
350
+ const messages = responses.map((r) => r.message);
351
+ expect(messages).toContain("second done");
352
+ // First result goes through Claude as background context (not direct)
356
353
  });
357
354
 
358
- it("handleCron queues a cron request with right params", async () => {
359
- const claude = mockClaude({ action: "send", message: "cron done", actionReason: "ok" });
355
+ it("waits for main to finish when within threshold, then processes next message", async () => {
356
+ const { query: q1, resolve: resolve1 } = pendingQuery("main-sid");
357
+
358
+ let callCount = 0;
359
+ const claude = mockClaude((): RunningQuery<unknown> => {
360
+ callCount++;
361
+ if (callCount === 1) return q1;
362
+ return resolvedQuery({ action: "send", message: "follow-up result", actionReason: "ok" });
363
+ });
360
364
  const { orch, responses } = makeOrchestrator(claude);
361
365
 
362
- orch.handleCron("daily-check", "Check for updates", "haiku");
366
+ // First message handler returns immediately
367
+ orch.handleMessage("slow task");
363
368
  await waitForProcessing();
369
+ expect(callCount).toBe(1);
364
370
 
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");
371
+ // Second message main is running, within threshold, handler blocks
372
+ orch.handleMessage("follow up");
373
+ await waitForProcessing(10);
374
+
375
+ // Still blocked, only 1 call
376
+ expect(callCount).toBe(1);
377
+
378
+ // First query finishes — completion handler delivers, then handler unblocks
379
+ resolve1(queryResult({ action: "send", message: "slow done", actionReason: "ok" }));
380
+ await waitForProcessing(100);
381
+
382
+ expect(callCount).toBe(2);
383
+ const messages = responses.map((r) => r.message);
384
+ expect(messages).toContain("slow done");
385
+ expect(messages).toContain("follow-up result");
368
386
  });
369
387
 
370
- it("processes requests serially (FIFO)", async () => {
371
- const callOrder: number[] = [];
372
- let firstResolve: () => void;
373
- const firstCallDone = new Promise<void>((r) => { firstResolve = r; });
388
+ it("demotes after wait timeout when main does not finish in time", async () => {
389
+ const { query: q1 } = pendingQuery("main-sid");
374
390
 
375
- let callNum = 0;
391
+ let callCount = 0;
376
392
  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" });
393
+ callCount++;
394
+ if (callCount === 1) return q1;
395
+ return resolvedQuery({ action: "send", message: "forked result", actionReason: "ok" }, "new-main");
392
396
  });
393
- const { orch } = makeOrchestrator(claude);
397
+ // Short threshold so we don't wait long
398
+ const { orch, responses } = makeOrchestrator(claude, { waitThreshold: 10 });
394
399
 
395
- orch.handleMessage("first");
396
- orch.handleMessage("second");
400
+ orch.handleMessage("slow task");
401
+ await waitForProcessing();
402
+
403
+ orch.handleMessage("follow up");
404
+ await waitForProcessing(200);
405
+
406
+ expect(callCount).toBe(2);
407
+ const userCall = claude.calls[1];
408
+ expect(userCall.prompt).toContain("[Context: previous task");
409
+ expect(userCall.prompt).toContain("follow up");
410
+ expect(responses.map((r) => r.message)).toContain("forked result");
411
+ });
412
+
413
+ it("delivers result with error when session not in runningSessions", async () => {
414
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
415
+ const { query, resolve } = pendingQuery("main-session");
416
+ const claude = mockClaude(() => query);
417
+ const { orch, responses } = makeOrchestrator(claude);
418
+
419
+ orch.handleMessage("hello");
420
+ await waitForProcessing();
421
+
422
+ // Kill the session (removes from runningSessions)
423
+ await orch.handleKill("main-session");
424
+ await waitForProcessing();
425
+
426
+ // Query completes after kill — should still deliver (with error log)
427
+ resolve(queryResult({ action: "send", message: "late result", actionReason: "ok" }));
428
+ await waitForProcessing();
429
+
430
+ const messages = responses.map((r) => r.message);
431
+ expect(messages).toContain("late result");
432
+ });
433
+ });
434
+
435
+ describe("queue-based processing", () => {
436
+ it("handleMessage queues a user request and calls onResponse", async () => {
437
+ const claude = mockClaude({ action: "send", message: "result", actionReason: "ok" });
438
+ const { orch, responses } = makeOrchestrator(claude);
439
+
440
+ orch.handleMessage("test message");
441
+ await waitForProcessing();
442
+
443
+ expect(claude.calls).toHaveLength(1);
444
+ expect(responses[0].message).toBe("result");
445
+ });
446
+
447
+ it("handleButton queues a button request", async () => {
448
+ const claude = mockClaude({ action: "send", message: "button response", actionReason: "ok" });
449
+ const { orch, responses } = makeOrchestrator(claude);
397
450
 
398
- await new Promise((r) => setTimeout(r, 10));
399
- firstResolve!();
451
+ orch.handleButton("Yes");
400
452
  await waitForProcessing();
401
453
 
402
- expect(claude.calls).toHaveLength(2);
403
- expect(callOrder).toEqual([1, 2]);
454
+ expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
455
+ expect(responses[0].message).toBe("button response");
404
456
  });
405
457
 
406
458
  it("silent response: onResponse not called when action=silent", async () => {
@@ -436,130 +488,65 @@ describe("Orchestrator", () => {
436
488
  expect(messages).toContain("Starting research");
437
489
  expect(messages).toContain('Background agent "research" started.');
438
490
 
439
- // Background agent result should be fed back
440
491
  await waitForProcessing(100);
441
492
  expect(callCount).toBe(3); // 1 main + 1 bg agent + 1 bg result fed back
442
493
  });
494
+ });
443
495
 
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);
496
+ describe("cron routing", () => {
497
+ it("cron always forks as background, never goes through queue", async () => {
498
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
499
+ const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
500
+ const { orch } = makeOrchestrator(claude);
462
501
 
463
- orch.handleMessage("slow task");
464
- // Restore immediately so waitForProcessing works
465
- await new Promise((r) => origSetTimeout(r, 10));
466
- globalThis.setTimeout = origSetTimeout;
502
+ orch.handleCron("daily-check", "Check for updates", "haiku");
467
503
  await waitForProcessing();
468
504
 
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!");
505
+ expect(claude.calls[0].method).toBe("forkSession");
506
+ expect(claude.calls[0].prompt).toContain("[Context: background-agent/cron-daily-check]");
507
+ expect(claude.calls[0].prompt).toContain("[Context: cron/daily-check] Check for updates");
508
+ expect(claude.calls[0].model).toBe("haiku");
477
509
  });
478
510
 
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();
511
+ it("cron uses config model when none specified", async () => {
512
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
513
+ const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
514
+ const { orch } = makeOrchestrator(claude, { model: "sonnet" });
507
515
 
508
- // Second message should trigger a fork (background running on test-session = main session)
509
- orch.handleMessage("follow up");
516
+ orch.handleCron("basic", "check");
510
517
  await waitForProcessing();
511
518
 
512
- expect(claude.calls[1].method).toBe("forkSession");
513
-
514
- resolveCompletion!(queryResult({ action: "send", message: "bg done", actionReason: "ok" }));
515
- await waitForProcessing(50);
519
+ expect(claude.calls[0].model).toBe("sonnet");
516
520
  });
517
521
 
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
-
522
+ it("cron result feeds back into main session", async () => {
523
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
524
+ let callCount = 0;
525
525
  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
- };
526
+ callCount++;
527
+ return resolvedQuery({ action: "send", message: `call ${callCount}`, actionReason: "ok" });
533
528
  });
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();
529
+ const { orch } = makeOrchestrator(claude);
540
530
 
541
- resolveCompletion!(queryResult({ action: "send", message: "direct result", actionReason: "ok" }, "test-session"));
542
- await waitForProcessing(100);
531
+ orch.handleCron("check", "any updates?");
532
+ await waitForProcessing(150);
543
533
 
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);
534
+ expect(callCount).toBe(2); // 1 cron bg + 1 bg result fed back
548
535
  });
549
536
  });
550
537
 
551
- describe("handleBackgroundList", () => {
552
- it("sends 'no agents' message when none running", async () => {
538
+ describe("handleSessions", () => {
539
+ it("sends 'no sessions' message when none running", async () => {
553
540
  const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
554
541
  const { orch, responses } = makeOrchestrator(claude);
555
542
 
556
- orch.handleBackgroundList();
543
+ orch.handleSessions();
557
544
  await waitForProcessing();
558
545
 
559
- expect(responses[0].message).toBe("No background agents running.");
546
+ expect(responses[0].message).toBe("No running sessions.");
560
547
  });
561
548
 
562
- it("includes detail buttons and dismiss when agents are running", async () => {
549
+ it("includes detail buttons and dismiss when sessions are running", async () => {
563
550
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
564
551
  const claude = mockClaude((): RunningQuery<unknown> => ({
565
552
  sessionId: `bg-${Date.now()}`,
@@ -572,19 +559,35 @@ describe("Orchestrator", () => {
572
559
  orch.handleBackgroundCommand("long-task");
573
560
  await waitForProcessing();
574
561
 
575
- orch.handleBackgroundList();
562
+ orch.handleSessions();
576
563
  await waitForProcessing();
577
564
 
578
565
  const listResponse = responses[responses.length - 1];
579
566
  expect(listResponse.message).toContain("long-task");
580
567
  expect(listResponse.buttons).toBeDefined();
581
- expect(listResponse.buttons!.length).toBe(2); // 1 detail + dismiss
568
+ expect(listResponse.buttons!.length).toBe(2);
582
569
  const detailBtn = listResponse.buttons![0];
583
570
  expect(typeof detailBtn).toBe("object");
584
571
  expect((detailBtn as any).data).toMatch(/^detail:/);
585
572
  expect((detailBtn as any).text).toContain("long-task");
586
573
  expect(listResponse.buttons![1]).toEqual({ text: "Dismiss", data: "_dismiss" });
587
574
  });
575
+
576
+ it("marks main session in listing", async () => {
577
+ const { query } = pendingQuery("main-sid");
578
+ const claude = mockClaude(() => query);
579
+ const { orch, responses } = makeOrchestrator(claude);
580
+
581
+ // Start a main query (non-blocking, stays in runningSessions)
582
+ orch.handleMessage("task");
583
+ await waitForProcessing();
584
+
585
+ orch.handleSessions();
586
+ await waitForProcessing();
587
+
588
+ const listResponse = responses[responses.length - 1];
589
+ expect(listResponse.message).toContain("[main]");
590
+ });
588
591
  });
589
592
 
590
593
  describe("handlePeek", () => {
@@ -595,7 +598,7 @@ describe("Orchestrator", () => {
595
598
  await orch.handlePeek("nonexistent-session");
596
599
  await waitForProcessing();
597
600
 
598
- expect(responses[0].message).toBe("Agent not found or already finished.");
601
+ expect(responses[0].message).toBe("Session not found or already finished.");
599
602
  });
600
603
 
601
604
  it("peeks at running agent and returns status", async () => {
@@ -604,7 +607,6 @@ describe("Orchestrator", () => {
604
607
  const claude = mockClaude((): RunningQuery<unknown> => {
605
608
  callCount++;
606
609
  if (callCount === 1) {
607
- // bg agent — never finishes
608
610
  return {
609
611
  sessionId: "bg-sid",
610
612
  startedAt: new Date(),
@@ -612,7 +614,6 @@ describe("Orchestrator", () => {
612
614
  kill: mock(async () => {}),
613
615
  };
614
616
  }
615
- // peek fork call
616
617
  return resolvedQuery("Working on it, 50% done.", "peek-session");
617
618
  });
618
619
  const { orch, responses } = makeOrchestrator(claude);
@@ -620,12 +621,11 @@ describe("Orchestrator", () => {
620
621
  orch.handleBackgroundCommand("research");
621
622
  await waitForProcessing();
622
623
 
623
- // Get the internal session ID from the peek button
624
- orch.handleBackgroundList();
624
+ orch.handleSessions();
625
625
  await waitForProcessing();
626
626
  const listResponse = responses[responses.length - 1];
627
627
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
628
- const sessionId = detailBtn.data.slice(7); // strip "detail:"
628
+ const sessionId = detailBtn.data.slice(7);
629
629
 
630
630
  await orch.handlePeek(sessionId);
631
631
  await waitForProcessing();
@@ -660,11 +660,11 @@ describe("Orchestrator", () => {
660
660
  orch.handleBackgroundCommand("failing-peek");
661
661
  await waitForProcessing();
662
662
 
663
- orch.handleBackgroundList();
663
+ orch.handleSessions();
664
664
  await waitForProcessing();
665
665
  const listResponse = responses[responses.length - 1];
666
666
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
667
- const sessionId = detailBtn.data.slice(7); // strip "detail:"
667
+ const sessionId = detailBtn.data.slice(7);
668
668
 
669
669
  await orch.handlePeek(sessionId);
670
670
  await waitForProcessing();
@@ -682,10 +682,10 @@ describe("Orchestrator", () => {
682
682
  orch.handleDetail("nonexistent-session");
683
683
  await waitForProcessing();
684
684
 
685
- expect(responses[0].message).toBe("Agent not found or already finished.");
685
+ expect(responses[0].message).toBe("Session not found or already finished.");
686
686
  });
687
687
 
688
- it("shows agent details with peek/kill/dismiss buttons", async () => {
688
+ it("shows session details with peek/kill/dismiss buttons", async () => {
689
689
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
690
690
  const claude = mockClaude((): RunningQuery<unknown> => ({
691
691
  sessionId: "bg-sid",
@@ -698,7 +698,7 @@ describe("Orchestrator", () => {
698
698
  orch.handleBackgroundCommand("research pricing");
699
699
  await waitForProcessing();
700
700
 
701
- orch.handleBackgroundList();
701
+ orch.handleSessions();
702
702
  await waitForProcessing();
703
703
  const listResponse = responses[responses.length - 1];
704
704
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -732,7 +732,7 @@ describe("Orchestrator", () => {
732
732
  orch.handleBackgroundCommand(longPrompt);
733
733
  await waitForProcessing();
734
734
 
735
- orch.handleBackgroundList();
735
+ orch.handleSessions();
736
736
  await waitForProcessing();
737
737
  const listResponse = responses[responses.length - 1];
738
738
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -742,7 +742,6 @@ describe("Orchestrator", () => {
742
742
  await waitForProcessing();
743
743
 
744
744
  const detailResponse = responses[responses.length - 1];
745
- // 300 chars + ellipsis
746
745
  expect(detailResponse.message).toContain("a".repeat(300));
747
746
  expect(detailResponse.message).toContain("…");
748
747
  expect(detailResponse.message).not.toContain("a".repeat(301));
@@ -761,7 +760,7 @@ describe("Orchestrator", () => {
761
760
  orch.handleBackgroundCommand("research");
762
761
  await waitForProcessing();
763
762
 
764
- orch.handleBackgroundList();
763
+ orch.handleSessions();
765
764
  await waitForProcessing();
766
765
  const listResponse = responses[responses.length - 1];
767
766
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -783,10 +782,10 @@ describe("Orchestrator", () => {
783
782
  await orch.handleKill("nonexistent-session");
784
783
  await waitForProcessing();
785
784
 
786
- expect(responses[0].message).toBe("Agent not found or already finished.");
785
+ expect(responses[0].message).toBe("Session not found or already finished.");
787
786
  });
788
787
 
789
- it("kills running agent and sends confirmation", async () => {
788
+ it("kills running session and sends confirmation", async () => {
790
789
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
791
790
  const killMock = mock(async () => {});
792
791
  const claude = mockClaude((): RunningQuery<unknown> => ({
@@ -800,7 +799,7 @@ describe("Orchestrator", () => {
800
799
  orch.handleBackgroundCommand("research pricing");
801
800
  await waitForProcessing();
802
801
 
803
- orch.handleBackgroundList();
802
+ orch.handleSessions();
804
803
  await waitForProcessing();
805
804
  const listResponse = responses[responses.length - 1];
806
805
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -814,10 +813,9 @@ describe("Orchestrator", () => {
814
813
  expect(killResponse.message).toContain("Killed");
815
814
  expect(killResponse.message).toContain("research-pricing");
816
815
 
817
- // Agent should be removed from list
818
- orch.handleBackgroundList();
816
+ orch.handleSessions();
819
817
  await waitForProcessing();
820
- expect(responses[responses.length - 1].message).toBe("No background agents running.");
818
+ expect(responses[responses.length - 1].message).toBe("No running sessions.");
821
819
  });
822
820
 
823
821
  it("does not feed error back to queue after kill", async () => {
@@ -835,7 +833,7 @@ describe("Orchestrator", () => {
835
833
  orch.handleBackgroundCommand("task");
836
834
  await waitForProcessing();
837
835
 
838
- orch.handleBackgroundList();
836
+ orch.handleSessions();
839
837
  await waitForProcessing();
840
838
  const listResponse = responses[responses.length - 1];
841
839
  const detailBtn = listResponse.buttons![0] as { text: string; data: string };
@@ -845,12 +843,9 @@ describe("Orchestrator", () => {
845
843
  await waitForProcessing();
846
844
  const countAfterKill = responses.length;
847
845
 
848
- // Simulate the bg process rejecting after kill
849
846
  rejectBg!(new Error("process killed"));
850
847
  await waitForProcessing(100);
851
848
 
852
- // No additional error responses should have been added
853
- // Only the "Killed" message, no "[Error]" from the bg handler
854
849
  const newResponses = responses.slice(countAfterKill);
855
850
  expect(newResponses.every((r) => !r.message.includes("[Error]"))).toBe(true);
856
851
  });
@@ -875,23 +870,15 @@ describe("Orchestrator", () => {
875
870
  });
876
871
  });
877
872
 
878
- describe("background management (spawn/adopt)", () => {
873
+ describe("background management", () => {
879
874
  it("spawns background agent and feeds result back to queue", async () => {
880
875
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
881
- let resolvePromise: (r: QueryResult<unknown>) => void;
882
- const bgResult = new Promise<QueryResult<unknown>>((r) => { resolvePromise = r; });
876
+ const { query: bgQuery, resolve: resolveBg } = pendingQuery("bg-sid");
883
877
 
884
878
  let callCount = 0;
885
879
  const claude = mockClaude((): RunningQuery<unknown> => {
886
880
  callCount++;
887
- if (callCount === 1) {
888
- return {
889
- sessionId: "bg-sid",
890
- startedAt: new Date(),
891
- result: bgResult,
892
- kill: mock(async () => {}),
893
- };
894
- }
881
+ if (callCount === 1) return bgQuery;
895
882
  return resolvedQuery({ action: "send", message: "bg result processed", actionReason: "ok" });
896
883
  });
897
884
  const { orch, responses } = makeOrchestrator(claude);
@@ -901,29 +888,20 @@ describe("Orchestrator", () => {
901
888
 
902
889
  expect(responses[0].message).toContain('started.');
903
890
 
904
- resolvePromise!(queryResult({ action: "send", message: "done!", actionReason: "completed" }));
891
+ resolveBg(queryResult({ action: "send", message: "done!", actionReason: "completed" }));
905
892
  await waitForProcessing(100);
906
893
 
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
894
+ expect(callCount).toBe(2);
909
895
  });
910
896
 
911
897
  it("feeds error back to queue on spawn failure", async () => {
912
898
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
913
- let rejectPromise: (e: Error) => void;
914
- const bgResult = new Promise<QueryResult<unknown>>((_, r) => { rejectPromise = r; });
899
+ const { query: bgQuery, reject: rejectBg } = pendingQuery("bg-sid");
915
900
 
916
901
  let callCount = 0;
917
902
  const claude = mockClaude((): RunningQuery<unknown> => {
918
903
  callCount++;
919
- if (callCount === 1) {
920
- return {
921
- sessionId: "bg-sid",
922
- startedAt: new Date(),
923
- result: bgResult,
924
- kill: mock(async () => {}),
925
- };
926
- }
904
+ if (callCount === 1) return bgQuery;
927
905
  return resolvedQuery({ action: "send", message: "error processed", actionReason: "ok" });
928
906
  });
929
907
  const { orch, responses } = makeOrchestrator(claude);
@@ -931,75 +909,12 @@ describe("Orchestrator", () => {
931
909
  orch.handleBackgroundCommand("failing task");
932
910
  await waitForProcessing();
933
911
 
934
- rejectPromise!(new Error("spawn failed"));
912
+ rejectBg(new Error("spawn failed"));
935
913
  await waitForProcessing(100);
936
914
 
937
- // Error should be fed back and processed
938
915
  expect(callCount).toBe(2);
939
916
  expect(responses[responses.length - 1].message).toBe("error processed");
940
917
  });
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
918
  });
1004
919
 
1005
920
  describe("onResponse error handling", () => {
@@ -1013,7 +928,7 @@ describe("Orchestrator", () => {
1013
928
  claude,
1014
929
  });
1015
930
 
1016
- orch.handleBackgroundList();
931
+ orch.handleSessions();
1017
932
  await waitForProcessing();
1018
933
 
1019
934
  expect(failingOnResponse).toHaveBeenCalled();