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