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.
- package/package.json +1 -1
- package/src/app.test.ts +33 -71
- package/src/app.ts +14 -14
- package/src/claude.ts +1 -1
- package/src/index.ts +4 -0
- package/src/naming.test.ts +44 -0
- package/src/naming.ts +54 -0
- package/src/orchestrator.test.ts +253 -335
- package/src/orchestrator.ts +213 -169
- package/src/prompts.test.ts +255 -7
- package/src/prompts.ts +137 -21
- package/src/scheduler.test.ts +9 -6
- package/src/scheduler.ts +10 -3
package/src/orchestrator.test.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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("
|
|
336
|
-
it("
|
|
337
|
-
const
|
|
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("
|
|
306
|
+
orch.handleMessage("hello");
|
|
341
307
|
await waitForProcessing();
|
|
342
308
|
|
|
343
|
-
|
|
344
|
-
expect(responses
|
|
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("
|
|
348
|
-
const
|
|
349
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
expect(
|
|
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("
|
|
359
|
-
const
|
|
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
|
-
|
|
369
|
+
// First message — handler returns immediately
|
|
370
|
+
orch.handleMessage("slow task");
|
|
363
371
|
await waitForProcessing();
|
|
372
|
+
expect(callCount).toBe(1);
|
|
364
373
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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("
|
|
371
|
-
const
|
|
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
|
|
394
|
+
let callCount = 0;
|
|
376
395
|
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
400
|
+
// Short threshold so we don't wait long
|
|
401
|
+
const { orch, responses } = makeOrchestrator(claude, { waitThreshold: 10 });
|
|
394
402
|
|
|
395
|
-
orch.handleMessage("
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
firstResolve!();
|
|
454
|
+
orch.handleButton("Yes");
|
|
400
455
|
await waitForProcessing();
|
|
401
456
|
|
|
402
|
-
expect(claude.calls).
|
|
403
|
-
expect(
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
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.
|
|
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
|
-
|
|
470
|
-
expect(
|
|
471
|
-
|
|
472
|
-
|
|
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("
|
|
480
|
-
saveSessions({ mainSessionId: "
|
|
481
|
-
|
|
482
|
-
const
|
|
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
|
-
|
|
509
|
-
orch.handleMessage("follow up");
|
|
519
|
+
orch.handleCron("basic", "check");
|
|
510
520
|
await waitForProcessing();
|
|
511
521
|
|
|
512
|
-
expect(claude.calls[
|
|
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("
|
|
519
|
-
saveSessions({ mainSessionId: "
|
|
520
|
-
let
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
542
|
-
await waitForProcessing(
|
|
534
|
+
orch.handleCron("check", "any updates?");
|
|
535
|
+
await waitForProcessing(150);
|
|
543
536
|
|
|
544
|
-
|
|
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("
|
|
552
|
-
it("sends 'no
|
|
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.
|
|
546
|
+
orch.handleSessions();
|
|
557
547
|
await waitForProcessing();
|
|
558
548
|
|
|
559
|
-
expect(responses[0].message).toBe("No
|
|
549
|
+
expect(responses[0].message).toBe("No running sessions.");
|
|
560
550
|
});
|
|
561
551
|
|
|
562
|
-
it("includes detail buttons and dismiss when
|
|
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.
|
|
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);
|
|
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("
|
|
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
|
-
|
|
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);
|
|
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.
|
|
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);
|
|
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("
|
|
688
|
+
expect(responses[0].message).toBe("Session not found or already finished.");
|
|
686
689
|
});
|
|
687
690
|
|
|
688
|
-
it("shows
|
|
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.
|
|
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.
|
|
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.
|
|
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("
|
|
788
|
+
expect(responses[0].message).toBe("Session not found or already finished.");
|
|
787
789
|
});
|
|
788
790
|
|
|
789
|
-
it("kills running
|
|
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.
|
|
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
|
-
|
|
818
|
-
orch.handleBackgroundList();
|
|
819
|
+
orch.handleSessions();
|
|
819
820
|
await waitForProcessing();
|
|
820
|
-
expect(responses[responses.length - 1].message).toBe("No
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
894
|
+
resolveBg(queryResult({ action: "send", message: "done!", actionReason: "completed" }));
|
|
905
895
|
await waitForProcessing(100);
|
|
906
896
|
|
|
907
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
934
|
+
orch.handleSessions();
|
|
1017
935
|
await waitForProcessing();
|
|
1018
936
|
|
|
1019
937
|
expect(failingOnResponse).toHaveBeenCalled();
|