macroclaw 0.27.0 → 0.29.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 +7 -7
- package/src/app.ts +2 -2
- 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 +162 -11
- package/src/orchestrator.ts +208 -35
- package/src/prompts.test.ts +268 -6
- package/src/prompts.ts +143 -10
- package/src/scheduler.test.ts +9 -6
- package/src/scheduler.ts +10 -3
package/package.json
CHANGED
package/src/app.test.ts
CHANGED
|
@@ -166,7 +166,7 @@ describe("App", () => {
|
|
|
166
166
|
await new Promise((r) => setTimeout(r, 50));
|
|
167
167
|
|
|
168
168
|
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
169
|
-
expect(claude.calls[0].prompt).
|
|
169
|
+
expect(claude.calls[0].prompt).toContain("<text>hello</text>");
|
|
170
170
|
});
|
|
171
171
|
|
|
172
172
|
it("ignores messages from unauthorized chats", async () => {
|
|
@@ -219,7 +219,7 @@ describe("App", () => {
|
|
|
219
219
|
await new Promise((r) => setTimeout(r, 50));
|
|
220
220
|
|
|
221
221
|
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
222
|
-
expect(claude.calls[0].prompt).
|
|
222
|
+
expect(claude.calls[0].prompt).toContain("<text>bg: research pricing</text>");
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
it("sends error wrapped in response", async () => {
|
|
@@ -269,7 +269,7 @@ describe("App", () => {
|
|
|
269
269
|
expect(bot.api.getFile).toHaveBeenCalledWith("large");
|
|
270
270
|
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
271
271
|
expect(claude.calls).toHaveLength(1);
|
|
272
|
-
expect(claude.calls[0].prompt).toContain("
|
|
272
|
+
expect(claude.calls[0].prompt).toContain("<file path=");
|
|
273
273
|
|
|
274
274
|
globalThis.fetch = origFetch;
|
|
275
275
|
});
|
|
@@ -297,7 +297,7 @@ describe("App", () => {
|
|
|
297
297
|
expect(bot.api.getFile).toHaveBeenCalledWith("doc-id");
|
|
298
298
|
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
299
299
|
expect(claude.calls).toHaveLength(1);
|
|
300
|
-
expect(claude.calls[0].prompt).toContain("
|
|
300
|
+
expect(claude.calls[0].prompt).toContain("<file path=");
|
|
301
301
|
|
|
302
302
|
globalThis.fetch = origFetch;
|
|
303
303
|
});
|
|
@@ -363,7 +363,7 @@ describe("App", () => {
|
|
|
363
363
|
|
|
364
364
|
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
365
365
|
expect(claude.calls).toHaveLength(1);
|
|
366
|
-
expect(claude.calls[0].prompt).
|
|
366
|
+
expect(claude.calls[0].prompt).toContain("<text>hello from voice</text>");
|
|
367
367
|
|
|
368
368
|
globalThis.fetch = origFetch;
|
|
369
369
|
});
|
|
@@ -532,7 +532,7 @@ describe("App", () => {
|
|
|
532
532
|
expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Yes", callback_data: "_noop" }]] } });
|
|
533
533
|
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
534
534
|
expect(claude.calls).toHaveLength(1);
|
|
535
|
-
expect(claude.calls[0].prompt).
|
|
535
|
+
expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
|
|
536
536
|
});
|
|
537
537
|
|
|
538
538
|
it("handles _dismiss callback by removing reply markup", async () => {
|
|
@@ -688,7 +688,7 @@ describe("App", () => {
|
|
|
688
688
|
|
|
689
689
|
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
690
690
|
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
691
|
-
expect(calls[calls.length - 1][1]).toBe("Usage: /bg
|
|
691
|
+
expect(calls[calls.length - 1][1]).toBe("Usage: /bg <prompt>");
|
|
692
692
|
});
|
|
693
693
|
|
|
694
694
|
it("/bg with prompt spawns a background agent via sendMessage", async () => {
|
package/src/app.ts
CHANGED
|
@@ -43,7 +43,7 @@ export class App {
|
|
|
43
43
|
start() {
|
|
44
44
|
log.info("Starting macroclaw...");
|
|
45
45
|
const scheduler = new Scheduler(this.#config.workspace, {
|
|
46
|
-
onJob: (name, prompt, model) => this.#orchestrator.handleCron(name, prompt, model),
|
|
46
|
+
onJob: (name, prompt, model, missed) => this.#orchestrator.handleCron(name, prompt, model, missed),
|
|
47
47
|
});
|
|
48
48
|
scheduler.start();
|
|
49
49
|
this.#bot.api.setMyCommands([
|
|
@@ -78,7 +78,7 @@ export class App {
|
|
|
78
78
|
const prompt = ctx.match?.trim();
|
|
79
79
|
if (!prompt) {
|
|
80
80
|
log.debug("Command /bg without prompt");
|
|
81
|
-
sendResponse(this.#bot, this.#config.authorizedChatId, "Usage: /bg
|
|
81
|
+
sendResponse(this.#bot, this.#config.authorizedChatId, "Usage: /bg <prompt>");
|
|
82
82
|
return;
|
|
83
83
|
}
|
|
84
84
|
log.debug({ prompt }, "Command /bg spawn");
|
package/src/claude.ts
CHANGED
|
@@ -123,7 +123,7 @@ export class Claude {
|
|
|
123
123
|
if (systemPrompt) args.push("--append-system-prompt", systemPrompt);
|
|
124
124
|
args.push(prompt);
|
|
125
125
|
|
|
126
|
-
log.debug({ model, sessionId, promptLen: prompt.length, mode: mode.kind, hasSystemPrompt: !!systemPrompt }, "Sending to Claude");
|
|
126
|
+
log.debug({ model, sessionId, promptLen: prompt.length, mode: mode.kind, hasSystemPrompt: !!systemPrompt, prompt }, "Sending to Claude");
|
|
127
127
|
|
|
128
128
|
const proc = Bun.spawn(args, { cwd: this.#workspace, env, stdout: "pipe", stderr: "pipe" });
|
|
129
129
|
const startedAt = new Date();
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,10 @@ import { SpeechToText } from "./speech-to-text";
|
|
|
8
8
|
export async function start(): Promise<void> {
|
|
9
9
|
const log = createLogger("index");
|
|
10
10
|
|
|
11
|
+
process.on("unhandledRejection", (err) => {
|
|
12
|
+
log.error({ err }, "Unhandled rejection");
|
|
13
|
+
});
|
|
14
|
+
|
|
11
15
|
const mgr = new SettingsManager();
|
|
12
16
|
const settings = mgr.load();
|
|
13
17
|
const { settings: resolved, overrides } = mgr.applyEnvOverrides(settings);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { generateName } from "./naming";
|
|
3
|
+
|
|
4
|
+
describe("generateName", () => {
|
|
5
|
+
test("extracts content words from English prompt", () => {
|
|
6
|
+
expect(generateName("please research the best coffee shops in Prague")).toBe("research-best-coffee-shops");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("extracts content words from Czech prompt", () => {
|
|
10
|
+
expect(generateName("najdi nejlepsi kavárny v Praze a porovnej ceny")).toBe("najdi-nejlepsi-kavrny-praze");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns 'task' for stop-words-only prompt", () => {
|
|
14
|
+
expect(generateName("please do this for me")).toBe("task");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("returns 'task' for empty prompt", () => {
|
|
18
|
+
expect(generateName("")).toBe("task");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("strips non-alphanumeric characters", () => {
|
|
22
|
+
expect(generateName("fix bug #123 in auth-service!")).toBe("fix-bug-123-auth");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("respects maxWords parameter", () => {
|
|
26
|
+
expect(generateName("deploy new redis cluster with monitoring", 2)).toBe("deploy-new");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("skips single-character words", () => {
|
|
30
|
+
expect(generateName("a b c deploy x y z")).toBe("deploy");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("handles mixed English and Czech", () => {
|
|
34
|
+
expect(generateName("zkontroluj jestli je deploy hotovy")).toBe("zkontroluj-jestli-deploy-hotovy");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("respects maxLength and drops words that would exceed it", () => {
|
|
38
|
+
expect(generateName("deploy infrastructure monitoring cluster", 4, 25)).toBe("deploy-infrastructure");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns 'task' for very long single nonsense word", () => {
|
|
42
|
+
expect(generateName("a".repeat(500))).toBe("task");
|
|
43
|
+
});
|
|
44
|
+
});
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Generates a short kebab-case name from a prompt by extracting content words. */
|
|
2
|
+
|
|
3
|
+
const STOP_WORDS = new Set([
|
|
4
|
+
// English
|
|
5
|
+
"a", "about", "above", "after", "again", "all", "also", "am", "an", "and",
|
|
6
|
+
"any", "are", "as", "at", "be", "because", "been", "before", "being",
|
|
7
|
+
"between", "both", "but", "by", "can", "could", "did", "do", "does",
|
|
8
|
+
"doing", "down", "during", "each", "few", "for", "from", "further", "get",
|
|
9
|
+
"got", "had", "has", "have", "having", "he", "her", "here", "hers",
|
|
10
|
+
"herself", "him", "himself", "his", "how", "i", "if", "in", "into", "is",
|
|
11
|
+
"it", "its", "itself", "just", "know", "let", "like", "make", "may", "me",
|
|
12
|
+
"might", "more", "most", "must", "my", "myself", "need", "no", "nor", "not",
|
|
13
|
+
"now", "of", "off", "on", "once", "only", "or", "other", "our", "ours",
|
|
14
|
+
"ourselves", "out", "over", "own", "please", "really", "right", "same",
|
|
15
|
+
"shall", "she", "should", "so", "some", "such", "take", "than", "that",
|
|
16
|
+
"the", "their", "theirs", "them", "themselves", "then", "there", "these",
|
|
17
|
+
"they", "this", "those", "through", "to", "too", "under", "until", "up",
|
|
18
|
+
"us", "very", "want", "was", "we", "were", "what", "when", "where", "which",
|
|
19
|
+
"while", "who", "whom", "why", "will", "with", "would", "you", "your",
|
|
20
|
+
"yours", "yourself", "yourselves",
|
|
21
|
+
// Czech
|
|
22
|
+
"a", "aby", "aj", "ale", "ani", "ano", "asi", "az", "bez", "bude", "budem",
|
|
23
|
+
"budes", "by", "byl", "byla", "byli", "bylo", "byt", "ci", "co", "dal",
|
|
24
|
+
"dane", "do", "ho", "i", "ja", "jak", "jako", "je", "jeho", "jej", "jeji",
|
|
25
|
+
"jen", "jeste", "ji", "jich", "jim", "jine", "jiz", "jsem", "jses", "jsi",
|
|
26
|
+
"jsme", "jsou", "jste", "k", "kam", "kde", "kdo", "kdyz", "ke", "ktera",
|
|
27
|
+
"ktere", "kteri", "kterou", "ktery", "ma", "mam", "mate", "me", "mezi",
|
|
28
|
+
"mi", "mit", "mne", "mnou", "moc", "moje", "moji", "mu", "muze", "my",
|
|
29
|
+
"na", "nad", "nam", "nami", "nas", "nasi", "ne", "nebo", "nebot", "necht",
|
|
30
|
+
"nejsou", "neni", "nez", "nic", "nim", "o", "od", "on", "ona", "oni",
|
|
31
|
+
"ono", "pak", "po", "pod", "podle", "pokud", "potom", "pouze", "prave",
|
|
32
|
+
"pro", "proc", "proto", "protoze", "prvni", "pred", "presto", "pri", "sam",
|
|
33
|
+
"se", "si", "sice", "sve", "svou", "svuj", "svych", "svym", "svymi", "ta",
|
|
34
|
+
"tak", "take", "takze", "tato", "te", "tedy", "ten", "tento", "ti", "tim",
|
|
35
|
+
"to", "toho", "tohle", "tom", "tomu", "tu", "tuto", "tvuj", "ty", "tyto",
|
|
36
|
+
"u", "uz", "v", "vam", "vas", "vase", "ve", "vsak", "vse", "vsech",
|
|
37
|
+
"vsechno", "vsichni", "z", "za", "zda", "zde", "ze",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export function generateName(prompt: string, maxWords = 4, maxLength = 40): string {
|
|
41
|
+
const words = prompt
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
44
|
+
.replace(/-/g, " ")
|
|
45
|
+
.split(/\s+/)
|
|
46
|
+
.filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
|
47
|
+
let name = "";
|
|
48
|
+
for (const word of words.slice(0, maxWords)) {
|
|
49
|
+
const candidate = name ? `${name}-${word}` : word;
|
|
50
|
+
if (candidate.length > maxLength) break;
|
|
51
|
+
name = candidate;
|
|
52
|
+
}
|
|
53
|
+
return name || "task";
|
|
54
|
+
}
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -103,7 +103,8 @@ describe("Orchestrator", () => {
|
|
|
103
103
|
orch.handleMessage("hello");
|
|
104
104
|
await waitForProcessing();
|
|
105
105
|
|
|
106
|
-
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>");
|
|
107
108
|
});
|
|
108
109
|
|
|
109
110
|
it("prepends file references for user requests", async () => {
|
|
@@ -113,7 +114,9 @@ describe("Orchestrator", () => {
|
|
|
113
114
|
orch.handleMessage("check this", ["/tmp/photo.jpg", "/tmp/doc.pdf"]);
|
|
114
115
|
await waitForProcessing();
|
|
115
116
|
|
|
116
|
-
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>");
|
|
117
120
|
});
|
|
118
121
|
|
|
119
122
|
it("sends only file references when message is empty", async () => {
|
|
@@ -123,7 +126,8 @@ describe("Orchestrator", () => {
|
|
|
123
126
|
orch.handleMessage("", ["/tmp/photo.jpg"]);
|
|
124
127
|
await waitForProcessing();
|
|
125
128
|
|
|
126
|
-
expect(claude.calls[0].prompt).
|
|
129
|
+
expect(claude.calls[0].prompt).toContain('<file path="/tmp/photo.jpg" />');
|
|
130
|
+
expect(claude.calls[0].prompt).not.toContain("<text>");
|
|
127
131
|
});
|
|
128
132
|
|
|
129
133
|
it("builds button click prompt", async () => {
|
|
@@ -133,7 +137,7 @@ describe("Orchestrator", () => {
|
|
|
133
137
|
orch.handleButton("Yes");
|
|
134
138
|
await waitForProcessing();
|
|
135
139
|
|
|
136
|
-
expect(claude.calls[0].prompt).
|
|
140
|
+
expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
|
|
137
141
|
});
|
|
138
142
|
});
|
|
139
143
|
|
|
@@ -338,9 +342,8 @@ describe("Orchestrator", () => {
|
|
|
338
342
|
expect(callCount).toBe(2);
|
|
339
343
|
const secondCall = claude.calls[1];
|
|
340
344
|
expect(secondCall.method).toBe("forkSession");
|
|
341
|
-
expect(secondCall.prompt).toContain("
|
|
342
|
-
expect(secondCall.prompt).toContain("
|
|
343
|
-
expect(secondCall.prompt).toContain("second");
|
|
345
|
+
expect(secondCall.prompt).toContain("<backgrounded-event");
|
|
346
|
+
expect(secondCall.prompt).toContain("<text>second</text>");
|
|
344
347
|
|
|
345
348
|
// Resolve both
|
|
346
349
|
resolve2(queryResult({ action: "send", message: "second done", actionReason: "ok" }, "q2-sid"));
|
|
@@ -405,7 +408,7 @@ describe("Orchestrator", () => {
|
|
|
405
408
|
|
|
406
409
|
expect(callCount).toBe(2);
|
|
407
410
|
const userCall = claude.calls[1];
|
|
408
|
-
expect(userCall.prompt).toContain("
|
|
411
|
+
expect(userCall.prompt).toContain("<backgrounded-event");
|
|
409
412
|
expect(userCall.prompt).toContain("follow up");
|
|
410
413
|
expect(responses.map((r) => r.message)).toContain("forked result");
|
|
411
414
|
});
|
|
@@ -451,7 +454,7 @@ describe("Orchestrator", () => {
|
|
|
451
454
|
orch.handleButton("Yes");
|
|
452
455
|
await waitForProcessing();
|
|
453
456
|
|
|
454
|
-
expect(claude.calls[0].prompt).
|
|
457
|
+
expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
|
|
455
458
|
expect(responses[0].message).toBe("button response");
|
|
456
459
|
});
|
|
457
460
|
|
|
@@ -503,8 +506,8 @@ describe("Orchestrator", () => {
|
|
|
503
506
|
await waitForProcessing();
|
|
504
507
|
|
|
505
508
|
expect(claude.calls[0].method).toBe("forkSession");
|
|
506
|
-
expect(claude.calls[0].prompt).toContain("
|
|
507
|
-
expect(claude.calls[0].prompt).toContain("
|
|
509
|
+
expect(claude.calls[0].prompt).toContain('<schedule name="daily-check" />');
|
|
510
|
+
expect(claude.calls[0].prompt).toContain("<text>Check for updates</text>");
|
|
508
511
|
expect(claude.calls[0].model).toBe("haiku");
|
|
509
512
|
});
|
|
510
513
|
|
|
@@ -917,6 +920,154 @@ describe("Orchestrator", () => {
|
|
|
917
920
|
});
|
|
918
921
|
});
|
|
919
922
|
|
|
923
|
+
describe("health checks", () => {
|
|
924
|
+
it("runs health check after interval and reports finished agent", async () => {
|
|
925
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
926
|
+
const { query: bgQuery } = pendingQuery("bg-sid");
|
|
927
|
+
|
|
928
|
+
let callCount = 0;
|
|
929
|
+
const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
|
|
930
|
+
callCount++;
|
|
931
|
+
if (callCount === 1) return bgQuery; // background agent spawn
|
|
932
|
+
if (info.prompt.includes("health-check")) {
|
|
933
|
+
return resolvedQuery({
|
|
934
|
+
finished: true,
|
|
935
|
+
output: { action: "send", message: "task complete", actionReason: "done" },
|
|
936
|
+
}, "hc-sid");
|
|
937
|
+
}
|
|
938
|
+
// Main session processes the background-agent-result
|
|
939
|
+
return resolvedQuery({ action: "send", message: "relayed", actionReason: "ok" });
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const { orch } = makeOrchestrator(claude, {
|
|
943
|
+
healthCheckInterval: 50,
|
|
944
|
+
healthCheckTimeout: 5000,
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
orch.handleBackgroundCommand("long task");
|
|
948
|
+
await waitForProcessing(200);
|
|
949
|
+
|
|
950
|
+
// Health check should have fired, detected finished, killed original, and pushed result
|
|
951
|
+
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
952
|
+
const hcCall = claude.calls.find((c: CallInfo) => c.prompt.includes("health-check"));
|
|
953
|
+
expect(hcCall).toBeDefined();
|
|
954
|
+
expect(hcCall!.model).toBe("haiku");
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it("reports progress and schedules next check when not finished", async () => {
|
|
958
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
959
|
+
const { query: bgQuery } = pendingQuery("bg-sid");
|
|
960
|
+
|
|
961
|
+
let hcCount = 0;
|
|
962
|
+
const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
|
|
963
|
+
if (info.prompt.includes("health-check")) {
|
|
964
|
+
hcCount++;
|
|
965
|
+
return resolvedQuery({ finished: false, progress: "still working" }, "hc-sid");
|
|
966
|
+
}
|
|
967
|
+
if (info.prompt.includes("background-agent-result")) {
|
|
968
|
+
return resolvedQuery({ action: "silent", message: "ok", actionReason: "progress" });
|
|
969
|
+
}
|
|
970
|
+
return bgQuery; // background agent spawn
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
const { orch } = makeOrchestrator(claude, {
|
|
974
|
+
healthCheckInterval: 50,
|
|
975
|
+
healthCheckTimeout: 5000,
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
orch.handleBackgroundCommand("long task");
|
|
979
|
+
// Wait for two health check cycles
|
|
980
|
+
await waitForProcessing(250);
|
|
981
|
+
|
|
982
|
+
expect(hcCount).toBeGreaterThanOrEqual(2);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("kills unresponsive agent on health check timeout", async () => {
|
|
986
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
987
|
+
const { query: bgQuery } = pendingQuery("bg-sid");
|
|
988
|
+
|
|
989
|
+
const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
|
|
990
|
+
if (info.prompt.includes("health-check")) {
|
|
991
|
+
// Never resolves — simulates unresponsive agent
|
|
992
|
+
return { sessionId: "hc-sid", startedAt: new Date(), result: new Promise(() => {}), kill: mock(async () => {}) };
|
|
993
|
+
}
|
|
994
|
+
return bgQuery;
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
const { orch, responses } = makeOrchestrator(claude, {
|
|
998
|
+
healthCheckInterval: 30,
|
|
999
|
+
healthCheckTimeout: 60,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
orch.handleBackgroundCommand("stuck task");
|
|
1003
|
+
await waitForProcessing(200);
|
|
1004
|
+
|
|
1005
|
+
const killMsg = responses.find((r) => r.message.includes("unresponsive"));
|
|
1006
|
+
expect(killMsg).toBeDefined();
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it("does not run health checks when interval is 0", async () => {
|
|
1010
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
1011
|
+
const { query: bgQuery } = pendingQuery("bg-sid");
|
|
1012
|
+
|
|
1013
|
+
const claude = mockClaude((): RunningQuery<unknown> => bgQuery);
|
|
1014
|
+
const { orch } = makeOrchestrator(claude, { healthCheckInterval: 0 });
|
|
1015
|
+
|
|
1016
|
+
orch.handleBackgroundCommand("some task");
|
|
1017
|
+
await waitForProcessing(100);
|
|
1018
|
+
|
|
1019
|
+
// Only the spawn call, no health check fork
|
|
1020
|
+
expect(claude.calls).toHaveLength(1);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("clears health check timer when session is killed", async () => {
|
|
1024
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
1025
|
+
const { query: bgQuery } = pendingQuery("bg-sid");
|
|
1026
|
+
|
|
1027
|
+
let hcCount = 0;
|
|
1028
|
+
const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
|
|
1029
|
+
if (info.prompt.includes("health-check")) hcCount++;
|
|
1030
|
+
return bgQuery;
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
const { orch } = makeOrchestrator(claude, { healthCheckInterval: 100 });
|
|
1034
|
+
|
|
1035
|
+
orch.handleBackgroundCommand("killable task");
|
|
1036
|
+
await waitForProcessing();
|
|
1037
|
+
|
|
1038
|
+
// Get the session ID and kill it before health check fires
|
|
1039
|
+
orch.handleKill("bg-sid");
|
|
1040
|
+
await waitForProcessing(200);
|
|
1041
|
+
|
|
1042
|
+
expect(hcCount).toBe(0);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it("stops health check if session completes organically before timer", async () => {
|
|
1046
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
1047
|
+
const { query: bgQuery, resolve: resolveBg } = pendingQuery("bg-sid");
|
|
1048
|
+
|
|
1049
|
+
let hcCount = 0;
|
|
1050
|
+
let callCount = 0;
|
|
1051
|
+
const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
|
|
1052
|
+
callCount++;
|
|
1053
|
+
if (info.prompt.includes("health-check")) { hcCount++; return pendingQuery().query; }
|
|
1054
|
+
if (callCount === 1) return bgQuery;
|
|
1055
|
+
return resolvedQuery({ action: "send", message: "processed", actionReason: "ok" });
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
const { orch } = makeOrchestrator(claude, { healthCheckInterval: 200 });
|
|
1059
|
+
|
|
1060
|
+
orch.handleBackgroundCommand("fast task");
|
|
1061
|
+
await waitForProcessing();
|
|
1062
|
+
|
|
1063
|
+
// Complete before health check fires
|
|
1064
|
+
resolveBg(queryResult({ action: "send", message: "done", actionReason: "done" }));
|
|
1065
|
+
await waitForProcessing(350);
|
|
1066
|
+
|
|
1067
|
+
expect(hcCount).toBe(0);
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
|
|
920
1071
|
describe("onResponse error handling", () => {
|
|
921
1072
|
it("logs error and does not throw when onResponse callback fails", async () => {
|
|
922
1073
|
const claude = mockClaude({ action: "send", message: "hello", actionReason: "ok" });
|