macroclaw 0.7.0 → 0.9.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 +3 -3
- package/src/app.test.ts +126 -97
- package/src/claude.integration-test.ts +47 -79
- package/src/claude.test.ts +263 -213
- package/src/claude.ts +129 -88
- package/src/history.test.ts +10 -10
- package/src/history.ts +2 -2
- package/src/main.ts +3 -0
- package/src/orchestrator.test.ts +320 -197
- package/src/orchestrator.ts +172 -237
package/src/orchestrator.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
import { existsSync, rmSync } from "node:fs";
|
|
3
|
-
import { type Claude,
|
|
3
|
+
import { type Claude, QueryParseError, QueryProcessError, type QueryResult, type RunningQuery } from "./claude";
|
|
4
4
|
import { Orchestrator, type OrchestratorConfig, type OrchestratorResponse } from "./orchestrator";
|
|
5
5
|
import { saveSessions } from "./sessions";
|
|
6
6
|
|
|
@@ -15,15 +15,54 @@ afterEach(() => {
|
|
|
15
15
|
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
interface CallInfo {
|
|
19
|
+
method: "newSession" | "resumeSession" | "forkSession";
|
|
20
|
+
prompt: string;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
model?: string;
|
|
23
|
+
systemPrompt?: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
type MockHandler = (info: CallInfo) => RunningQuery<unknown>;
|
|
27
|
+
|
|
28
|
+
function queryResult<T>(value: T, sessionId = "test-session-id"): QueryResult<T> {
|
|
29
|
+
return { value, sessionId, duration: "1.0s", cost: "$0.05" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolvedQuery<T>(value: T, sessionId = "test-session-id"): RunningQuery<T> {
|
|
33
|
+
return {
|
|
34
|
+
sessionId,
|
|
35
|
+
startedAt: new Date(),
|
|
36
|
+
result: Promise.resolve(queryResult(value, sessionId)),
|
|
37
|
+
kill: mock(async () => {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mockClaude(handler: MockHandler | unknown) {
|
|
42
|
+
const calls: CallInfo[] = [];
|
|
43
|
+
const handlerFn: MockHandler = typeof handler === "function"
|
|
44
|
+
? handler as MockHandler
|
|
45
|
+
: () => resolvedQuery(handler);
|
|
46
|
+
|
|
47
|
+
const claude = {
|
|
48
|
+
newSession: mock((prompt: string, _resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
|
|
49
|
+
const info: CallInfo = { method: "newSession", prompt, model: options?.model, systemPrompt: options?.systemPrompt };
|
|
50
|
+
calls.push(info);
|
|
51
|
+
return handlerFn(info);
|
|
52
|
+
}),
|
|
53
|
+
resumeSession: mock((sessionId: string, prompt: string, _resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
|
|
54
|
+
const info: CallInfo = { method: "resumeSession", sessionId, prompt, model: options?.model, systemPrompt: options?.systemPrompt };
|
|
55
|
+
calls.push(info);
|
|
56
|
+
return handlerFn(info);
|
|
57
|
+
}),
|
|
58
|
+
forkSession: mock((sessionId: string, prompt: string, _resultType: unknown, options?: { model?: string; systemPrompt?: string }) => {
|
|
59
|
+
const info: CallInfo = { method: "forkSession", sessionId, prompt, model: options?.model, systemPrompt: options?.systemPrompt };
|
|
60
|
+
calls.push(info);
|
|
61
|
+
return handlerFn(info);
|
|
62
|
+
}),
|
|
63
|
+
calls,
|
|
64
|
+
} as unknown as Claude & { calls: CallInfo[]; newSession: ReturnType<typeof mock>; resumeSession: ReturnType<typeof mock>; forkSession: ReturnType<typeof mock> };
|
|
65
|
+
return claude;
|
|
27
66
|
}
|
|
28
67
|
|
|
29
68
|
function makeOrchestrator(claude: Claude, extraConfig?: Partial<OrchestratorConfig>) {
|
|
@@ -46,89 +85,79 @@ async function waitForProcessing(ms = 50) {
|
|
|
46
85
|
describe("Orchestrator", () => {
|
|
47
86
|
describe("prompt building", () => {
|
|
48
87
|
it("builds user prompt as-is", async () => {
|
|
49
|
-
const claude = mockClaude(
|
|
88
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
50
89
|
const { orch } = makeOrchestrator(claude);
|
|
51
90
|
|
|
52
91
|
orch.handleMessage("hello");
|
|
53
92
|
await waitForProcessing();
|
|
54
93
|
|
|
55
|
-
|
|
56
|
-
expect(opts.prompt).toBe("hello");
|
|
57
|
-
expect(opts.systemPrompt).toContain("macroclaw");
|
|
58
|
-
expect(opts.timeoutMs).toBe(60_000);
|
|
94
|
+
expect(claude.calls[0].prompt).toBe("hello");
|
|
59
95
|
});
|
|
60
96
|
|
|
61
97
|
it("prepends file references for user requests", async () => {
|
|
62
|
-
const claude = mockClaude(
|
|
98
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
63
99
|
const { orch } = makeOrchestrator(claude);
|
|
64
100
|
|
|
65
101
|
orch.handleMessage("check this", ["/tmp/photo.jpg", "/tmp/doc.pdf"]);
|
|
66
102
|
await waitForProcessing();
|
|
67
103
|
|
|
68
|
-
|
|
69
|
-
expect(opts.prompt).toBe("[File: /tmp/photo.jpg]\n[File: /tmp/doc.pdf]\ncheck this");
|
|
104
|
+
expect(claude.calls[0].prompt).toBe("[File: /tmp/photo.jpg]\n[File: /tmp/doc.pdf]\ncheck this");
|
|
70
105
|
});
|
|
71
106
|
|
|
72
107
|
it("sends only file references when message is empty", async () => {
|
|
73
|
-
const claude = mockClaude(
|
|
108
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
74
109
|
const { orch } = makeOrchestrator(claude);
|
|
75
110
|
|
|
76
111
|
orch.handleMessage("", ["/tmp/photo.jpg"]);
|
|
77
112
|
await waitForProcessing();
|
|
78
113
|
|
|
79
|
-
expect(claude.
|
|
114
|
+
expect(claude.calls[0].prompt).toBe("[File: /tmp/photo.jpg]");
|
|
80
115
|
});
|
|
81
116
|
|
|
82
117
|
it("builds cron prompt with prefix", async () => {
|
|
83
|
-
const claude = mockClaude(
|
|
118
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
84
119
|
const { orch } = makeOrchestrator(claude);
|
|
85
120
|
|
|
86
121
|
orch.handleCron("daily", "check updates");
|
|
87
122
|
await waitForProcessing();
|
|
88
123
|
|
|
89
|
-
|
|
90
|
-
expect(opts.prompt).toBe("[Context: cron/daily] check updates");
|
|
91
|
-
expect(opts.systemPrompt).toContain("macroclaw");
|
|
92
|
-
expect(opts.timeoutMs).toBe(300_000);
|
|
124
|
+
expect(claude.calls[0].prompt).toBe("[Context: cron/daily] check updates");
|
|
93
125
|
});
|
|
94
126
|
|
|
95
127
|
it("uses cron model override", async () => {
|
|
96
|
-
const claude = mockClaude(
|
|
128
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
97
129
|
const { orch } = makeOrchestrator(claude, { model: "sonnet" });
|
|
98
130
|
|
|
99
131
|
orch.handleCron("smart", "think", "opus");
|
|
100
132
|
await waitForProcessing();
|
|
101
133
|
|
|
102
|
-
expect(claude.
|
|
134
|
+
expect(claude.calls[0].model).toBe("opus");
|
|
103
135
|
});
|
|
104
136
|
|
|
105
137
|
it("falls back to config model when cron has no model", async () => {
|
|
106
|
-
const claude = mockClaude(
|
|
138
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
107
139
|
const { orch } = makeOrchestrator(claude, { model: "sonnet" });
|
|
108
140
|
|
|
109
141
|
orch.handleCron("basic", "check");
|
|
110
142
|
await waitForProcessing();
|
|
111
143
|
|
|
112
|
-
expect(claude.
|
|
144
|
+
expect(claude.calls[0].model).toBe("sonnet");
|
|
113
145
|
});
|
|
114
146
|
|
|
115
147
|
it("builds button click prompt", async () => {
|
|
116
|
-
const claude = mockClaude(
|
|
148
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
117
149
|
const { orch } = makeOrchestrator(claude);
|
|
118
150
|
|
|
119
151
|
orch.handleButton("Yes");
|
|
120
152
|
await waitForProcessing();
|
|
121
153
|
|
|
122
|
-
|
|
123
|
-
expect(opts.prompt).toBe('[Context: button-click] User tapped "Yes"');
|
|
124
|
-
expect(opts.systemPrompt).toContain("macroclaw");
|
|
125
|
-
expect(opts.timeoutMs).toBe(60_000);
|
|
154
|
+
expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
|
|
126
155
|
});
|
|
127
156
|
});
|
|
128
157
|
|
|
129
158
|
describe("schema validation", () => {
|
|
130
159
|
it("validates and returns structured output via onResponse", async () => {
|
|
131
|
-
const claude = mockClaude(
|
|
160
|
+
const claude = mockClaude({ action: "send", message: "hello", actionReason: "ok" });
|
|
132
161
|
const { orch, responses } = makeOrchestrator(claude);
|
|
133
162
|
|
|
134
163
|
orch.handleMessage("hi");
|
|
@@ -138,46 +167,6 @@ describe("Orchestrator", () => {
|
|
|
138
167
|
expect(responses[0].message).toBe("hello");
|
|
139
168
|
});
|
|
140
169
|
|
|
141
|
-
it("returns validation-failed response for wrong shape", async () => {
|
|
142
|
-
const claude = mockClaude(successResult({ action: "invalid", message: "hi", actionReason: "ok" }));
|
|
143
|
-
const { orch, responses } = makeOrchestrator(claude);
|
|
144
|
-
|
|
145
|
-
orch.handleMessage("hi");
|
|
146
|
-
await waitForProcessing();
|
|
147
|
-
|
|
148
|
-
expect(responses[0].message).toBe("hi");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("sends result with prefix when structured_output is missing", async () => {
|
|
152
|
-
const claude = mockClaude({ structuredOutput: null, sessionId: "s1", result: "Claude said this" });
|
|
153
|
-
const { orch, responses } = makeOrchestrator(claude);
|
|
154
|
-
|
|
155
|
-
orch.handleMessage("hi");
|
|
156
|
-
await waitForProcessing();
|
|
157
|
-
|
|
158
|
-
expect(responses[0].message).toBe("[No structured output] Claude said this");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("escapes HTML in fallback result to prevent Telegram parse errors", async () => {
|
|
162
|
-
const claude = mockClaude({ structuredOutput: null, sessionId: "s1", result: "<b>bold</b> & stuff" });
|
|
163
|
-
const { orch, responses } = makeOrchestrator(claude);
|
|
164
|
-
|
|
165
|
-
orch.handleMessage("hi");
|
|
166
|
-
await waitForProcessing();
|
|
167
|
-
|
|
168
|
-
expect(responses[0].message).toBe("[No structured output] <b>bold</b> & stuff");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("returns [No output] when both structured_output and result are missing", async () => {
|
|
172
|
-
const claude = mockClaude({ structuredOutput: null, sessionId: "s1" });
|
|
173
|
-
const { orch, responses } = makeOrchestrator(claude);
|
|
174
|
-
|
|
175
|
-
orch.handleMessage("hi");
|
|
176
|
-
await waitForProcessing();
|
|
177
|
-
|
|
178
|
-
expect(responses[0].message).toBe("[No output]");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
170
|
it("passes buttons through onResponse", async () => {
|
|
182
171
|
const output = {
|
|
183
172
|
action: "send",
|
|
@@ -185,7 +174,7 @@ describe("Orchestrator", () => {
|
|
|
185
174
|
actionReason: "ok",
|
|
186
175
|
buttons: ["Yes", "No", "Maybe"],
|
|
187
176
|
};
|
|
188
|
-
const claude = mockClaude(
|
|
177
|
+
const claude = mockClaude(output);
|
|
189
178
|
const { orch, responses } = makeOrchestrator(claude);
|
|
190
179
|
|
|
191
180
|
orch.handleMessage("hi");
|
|
@@ -196,7 +185,7 @@ describe("Orchestrator", () => {
|
|
|
196
185
|
|
|
197
186
|
it("passes files through onResponse", async () => {
|
|
198
187
|
const output = { action: "send", message: "chart", actionReason: "ok", files: ["/tmp/chart.png"] };
|
|
199
|
-
const claude = mockClaude(
|
|
188
|
+
const claude = mockClaude(output);
|
|
200
189
|
const { orch, responses } = makeOrchestrator(claude);
|
|
201
190
|
|
|
202
191
|
orch.handleMessage("hi");
|
|
@@ -207,8 +196,13 @@ describe("Orchestrator", () => {
|
|
|
207
196
|
});
|
|
208
197
|
|
|
209
198
|
describe("error mapping", () => {
|
|
210
|
-
it("maps
|
|
211
|
-
const claude = mockClaude(
|
|
199
|
+
it("maps QueryProcessError to process-error response", async () => {
|
|
200
|
+
const claude = mockClaude((): RunningQuery<unknown> => ({
|
|
201
|
+
sessionId: "err-sid",
|
|
202
|
+
startedAt: new Date(),
|
|
203
|
+
result: Promise.reject(new QueryProcessError(1, "spawn failed")),
|
|
204
|
+
kill: mock(async () => {}),
|
|
205
|
+
}));
|
|
212
206
|
const { orch, responses } = makeOrchestrator(claude);
|
|
213
207
|
|
|
214
208
|
orch.handleMessage("hi");
|
|
@@ -218,8 +212,13 @@ describe("Orchestrator", () => {
|
|
|
218
212
|
expect(responses[0].message).toContain("spawn failed");
|
|
219
213
|
});
|
|
220
214
|
|
|
221
|
-
it("maps
|
|
222
|
-
const claude = mockClaude(
|
|
215
|
+
it("maps QueryParseError to json-parse-failed response", async () => {
|
|
216
|
+
const claude = mockClaude((): RunningQuery<unknown> => ({
|
|
217
|
+
sessionId: "err-sid",
|
|
218
|
+
startedAt: new Date(),
|
|
219
|
+
result: Promise.reject(new QueryParseError("not json")),
|
|
220
|
+
kill: mock(async () => {}),
|
|
221
|
+
}));
|
|
223
222
|
const { orch, responses } = makeOrchestrator(claude);
|
|
224
223
|
|
|
225
224
|
orch.handleMessage("hi");
|
|
@@ -230,36 +229,42 @@ describe("Orchestrator", () => {
|
|
|
230
229
|
});
|
|
231
230
|
|
|
232
231
|
describe("session management", () => {
|
|
233
|
-
it("uses
|
|
232
|
+
it("uses resumeSession for existing session", async () => {
|
|
234
233
|
saveSessions({ mainSessionId: "existing-session" }, tmpSettingsDir);
|
|
235
|
-
const claude = mockClaude(
|
|
234
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
236
235
|
const { orch } = makeOrchestrator(claude);
|
|
237
236
|
|
|
238
237
|
orch.handleMessage("hello");
|
|
239
238
|
await waitForProcessing();
|
|
240
239
|
|
|
241
|
-
expect(claude.
|
|
242
|
-
expect(claude.
|
|
240
|
+
expect(claude.calls[0].method).toBe("resumeSession");
|
|
241
|
+
expect(claude.calls[0].sessionId).toBe("existing-session");
|
|
243
242
|
});
|
|
244
243
|
|
|
245
|
-
it("
|
|
246
|
-
const claude = mockClaude(
|
|
244
|
+
it("uses newSession when no settings exist", async () => {
|
|
245
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
247
246
|
const { orch } = makeOrchestrator(claude);
|
|
248
247
|
|
|
249
248
|
orch.handleMessage("hello");
|
|
250
249
|
await waitForProcessing();
|
|
251
250
|
|
|
252
|
-
expect(claude.
|
|
253
|
-
expect(claude.run.mock.calls[0][0].sessionId).toMatch(/^[0-9a-f]{8}-/);
|
|
251
|
+
expect(claude.calls[0].method).toBe("newSession");
|
|
254
252
|
});
|
|
255
253
|
|
|
256
254
|
it("creates new session when resume fails", async () => {
|
|
257
255
|
saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
|
|
258
256
|
let callCount = 0;
|
|
259
|
-
const claude = mockClaude(
|
|
257
|
+
const claude = mockClaude((_info: CallInfo): RunningQuery<unknown> => {
|
|
260
258
|
callCount++;
|
|
261
|
-
if (callCount === 1)
|
|
262
|
-
|
|
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" });
|
|
263
268
|
});
|
|
264
269
|
const { orch } = makeOrchestrator(claude);
|
|
265
270
|
|
|
@@ -267,13 +272,12 @@ describe("Orchestrator", () => {
|
|
|
267
272
|
await waitForProcessing();
|
|
268
273
|
|
|
269
274
|
expect(callCount).toBe(2);
|
|
270
|
-
expect(claude.
|
|
271
|
-
expect(claude.
|
|
272
|
-
expect(claude.run.mock.calls[1][0].sessionId).not.toBe("old-session");
|
|
275
|
+
expect(claude.calls[0].method).toBe("resumeSession");
|
|
276
|
+
expect(claude.calls[1].method).toBe("newSession");
|
|
273
277
|
});
|
|
274
278
|
|
|
275
|
-
it("switches to
|
|
276
|
-
const claude = mockClaude(
|
|
279
|
+
it("switches to resumeSession after first success", async () => {
|
|
280
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
277
281
|
const { orch } = makeOrchestrator(claude);
|
|
278
282
|
|
|
279
283
|
orch.handleMessage("first");
|
|
@@ -281,13 +285,13 @@ describe("Orchestrator", () => {
|
|
|
281
285
|
orch.handleMessage("second");
|
|
282
286
|
await waitForProcessing();
|
|
283
287
|
|
|
284
|
-
expect(claude.
|
|
285
|
-
expect(claude.
|
|
288
|
+
expect(claude.calls[0].method).toBe("newSession");
|
|
289
|
+
expect(claude.calls[1].method).toBe("resumeSession");
|
|
286
290
|
});
|
|
287
291
|
|
|
288
292
|
it("handleSessionCommand sends session via onResponse", async () => {
|
|
289
293
|
saveSessions({ mainSessionId: "test-id" }, tmpSettingsDir);
|
|
290
|
-
const claude = mockClaude(
|
|
294
|
+
const claude = mockClaude({ action: "send", message: "", actionReason: "" });
|
|
291
295
|
const { orch, responses } = makeOrchestrator(claude);
|
|
292
296
|
|
|
293
297
|
orch.handleSessionCommand();
|
|
@@ -297,30 +301,24 @@ describe("Orchestrator", () => {
|
|
|
297
301
|
expect(responses[0].message).toBe("Session: <code>test-id</code>");
|
|
298
302
|
});
|
|
299
303
|
|
|
300
|
-
it("background-agent forks from main session
|
|
304
|
+
it("background-agent forks from main session", async () => {
|
|
301
305
|
saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
|
|
302
|
-
const claude = mockClaude(
|
|
306
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
303
307
|
const { orch } = makeOrchestrator(claude);
|
|
304
308
|
|
|
305
309
|
orch.handleMessage("hello");
|
|
306
310
|
await waitForProcessing();
|
|
307
311
|
|
|
308
|
-
// Trigger a background agent spawn via a response with backgroundAgents
|
|
309
|
-
// The background agent is spawned by #deliverClaudeResponse
|
|
310
|
-
// We can't directly call handleBackgroundCommand here because it uses #spawnBackground
|
|
311
|
-
// which calls #processRequest (background-agent type)
|
|
312
|
-
// So let's verify via handleBackgroundCommand
|
|
313
312
|
orch.handleBackgroundCommand("do work");
|
|
314
313
|
await waitForProcessing();
|
|
315
314
|
|
|
316
|
-
//
|
|
317
|
-
expect(claude.
|
|
318
|
-
expect(claude.run.mock.calls[1][0].forkSession).toBe(true);
|
|
315
|
+
// bg agent uses forkSession
|
|
316
|
+
expect(claude.calls[1].method).toBe("forkSession");
|
|
319
317
|
});
|
|
320
318
|
|
|
321
319
|
it("updates session ID after forked call", async () => {
|
|
322
320
|
saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
|
|
323
|
-
const claude = mockClaude(
|
|
321
|
+
const claude = mockClaude(() => resolvedQuery({ action: "send", message: "ok", actionReason: "ok" }, "new-forked-session"));
|
|
324
322
|
const { orch } = makeOrchestrator(claude);
|
|
325
323
|
|
|
326
324
|
orch.handleMessage("hello");
|
|
@@ -330,42 +328,42 @@ describe("Orchestrator", () => {
|
|
|
330
328
|
orch.handleMessage("follow up");
|
|
331
329
|
await waitForProcessing();
|
|
332
330
|
|
|
333
|
-
expect(claude.
|
|
331
|
+
expect(claude.calls[1].sessionId).toBe("new-forked-session");
|
|
334
332
|
});
|
|
335
333
|
});
|
|
336
334
|
|
|
337
335
|
describe("queue-based processing", () => {
|
|
338
336
|
it("handleMessage queues a user request and calls onResponse", async () => {
|
|
339
|
-
const claude = mockClaude(
|
|
337
|
+
const claude = mockClaude({ action: "send", message: "result", actionReason: "ok" });
|
|
340
338
|
const { orch, responses } = makeOrchestrator(claude);
|
|
341
339
|
|
|
342
340
|
orch.handleMessage("test message");
|
|
343
341
|
await waitForProcessing();
|
|
344
342
|
|
|
345
|
-
expect(claude.
|
|
343
|
+
expect(claude.calls).toHaveLength(1);
|
|
346
344
|
expect(responses[0].message).toBe("result");
|
|
347
345
|
});
|
|
348
346
|
|
|
349
347
|
it("handleButton queues a button request", async () => {
|
|
350
|
-
const claude = mockClaude(
|
|
348
|
+
const claude = mockClaude({ action: "send", message: "button response", actionReason: "ok" });
|
|
351
349
|
const { orch, responses } = makeOrchestrator(claude);
|
|
352
350
|
|
|
353
351
|
orch.handleButton("Yes");
|
|
354
352
|
await waitForProcessing();
|
|
355
353
|
|
|
356
|
-
expect(claude.
|
|
354
|
+
expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
|
|
357
355
|
expect(responses[0].message).toBe("button response");
|
|
358
356
|
});
|
|
359
357
|
|
|
360
358
|
it("handleCron queues a cron request with right params", async () => {
|
|
361
|
-
const claude = mockClaude(
|
|
359
|
+
const claude = mockClaude({ action: "send", message: "cron done", actionReason: "ok" });
|
|
362
360
|
const { orch, responses } = makeOrchestrator(claude);
|
|
363
361
|
|
|
364
362
|
orch.handleCron("daily-check", "Check for updates", "haiku");
|
|
365
363
|
await waitForProcessing();
|
|
366
364
|
|
|
367
|
-
expect(claude.
|
|
368
|
-
expect(claude.
|
|
365
|
+
expect(claude.calls[0].prompt).toBe("[Context: cron/daily-check] Check for updates");
|
|
366
|
+
expect(claude.calls[0].model).toBe("haiku");
|
|
369
367
|
expect(responses[0].message).toBe("cron done");
|
|
370
368
|
});
|
|
371
369
|
|
|
@@ -374,31 +372,39 @@ describe("Orchestrator", () => {
|
|
|
374
372
|
let firstResolve: () => void;
|
|
375
373
|
const firstCallDone = new Promise<void>((r) => { firstResolve = r; });
|
|
376
374
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
375
|
+
let callNum = 0;
|
|
376
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
377
|
+
callNum++;
|
|
378
|
+
const n = callNum;
|
|
379
|
+
if (n === 1) {
|
|
380
|
+
return {
|
|
381
|
+
sessionId: "sid",
|
|
382
|
+
startedAt: new Date(),
|
|
383
|
+
result: firstCallDone.then(() => {
|
|
384
|
+
callOrder.push(n);
|
|
385
|
+
return queryResult({ action: "send", message: `call ${n}`, actionReason: "ok" });
|
|
386
|
+
}),
|
|
387
|
+
kill: mock(async () => {}),
|
|
388
|
+
};
|
|
381
389
|
}
|
|
382
|
-
callOrder.push(
|
|
383
|
-
return
|
|
390
|
+
callOrder.push(n);
|
|
391
|
+
return resolvedQuery({ action: "send", message: `call ${n}`, actionReason: "ok" });
|
|
384
392
|
});
|
|
385
393
|
const { orch } = makeOrchestrator(claude);
|
|
386
394
|
|
|
387
395
|
orch.handleMessage("first");
|
|
388
396
|
orch.handleMessage("second");
|
|
389
397
|
|
|
390
|
-
// Let first call start but not finish
|
|
391
398
|
await new Promise((r) => setTimeout(r, 10));
|
|
392
399
|
firstResolve!();
|
|
393
400
|
await waitForProcessing();
|
|
394
401
|
|
|
395
|
-
|
|
396
|
-
expect((claude as any).run).toHaveBeenCalledTimes(2);
|
|
402
|
+
expect(claude.calls).toHaveLength(2);
|
|
397
403
|
expect(callOrder).toEqual([1, 2]);
|
|
398
404
|
});
|
|
399
405
|
|
|
400
406
|
it("silent response: onResponse not called when action=silent", async () => {
|
|
401
|
-
const claude = mockClaude(
|
|
407
|
+
const claude = mockClaude({ action: "silent", actionReason: "no new results" });
|
|
402
408
|
const { orch, onResponse } = makeOrchestrator(claude);
|
|
403
409
|
|
|
404
410
|
orch.handleMessage("hello");
|
|
@@ -409,17 +415,17 @@ describe("Orchestrator", () => {
|
|
|
409
415
|
|
|
410
416
|
it("background agents spawned from Claude response: calls onResponse with started message", async () => {
|
|
411
417
|
let callCount = 0;
|
|
412
|
-
const claude = mockClaude(
|
|
418
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
413
419
|
callCount++;
|
|
414
420
|
if (callCount === 1) {
|
|
415
|
-
return
|
|
421
|
+
return resolvedQuery({
|
|
416
422
|
action: "send",
|
|
417
423
|
message: "Starting research",
|
|
418
424
|
actionReason: "needs research",
|
|
419
425
|
backgroundAgents: [{ name: "research", prompt: "research this" }],
|
|
420
426
|
});
|
|
421
427
|
}
|
|
422
|
-
return
|
|
428
|
+
return resolvedQuery({ action: "send", message: "research result", actionReason: "done" });
|
|
423
429
|
});
|
|
424
430
|
const { orch, responses } = makeOrchestrator(claude);
|
|
425
431
|
|
|
@@ -437,20 +443,33 @@ describe("Orchestrator", () => {
|
|
|
437
443
|
|
|
438
444
|
it("deferred → sends 'taking longer' via onResponse, feeds result back when resolved", async () => {
|
|
439
445
|
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
440
|
-
let resolveCompletion: (r:
|
|
441
|
-
const completion = new Promise<
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
446
|
+
let resolveCompletion: (r: QueryResult<unknown>) => void;
|
|
447
|
+
const completion = new Promise<QueryResult<unknown>>((r) => { resolveCompletion = r; });
|
|
448
|
+
|
|
449
|
+
// Mock setTimeout to fire immediately only for the timeout race, not for waitForProcessing
|
|
450
|
+
const origSetTimeout = globalThis.setTimeout;
|
|
451
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
452
|
+
// Mock setTimeout right before the race happens (synchronously after this returns)
|
|
453
|
+
globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
|
|
454
|
+
return {
|
|
455
|
+
sessionId: "test-session",
|
|
456
|
+
startedAt: new Date(),
|
|
457
|
+
result: completion,
|
|
458
|
+
kill: mock(async () => {}),
|
|
459
|
+
};
|
|
460
|
+
});
|
|
445
461
|
const { orch, responses } = makeOrchestrator(claude);
|
|
446
462
|
|
|
447
463
|
orch.handleMessage("slow task");
|
|
464
|
+
// Restore immediately so waitForProcessing works
|
|
465
|
+
await new Promise((r) => origSetTimeout(r, 10));
|
|
466
|
+
globalThis.setTimeout = origSetTimeout;
|
|
448
467
|
await waitForProcessing();
|
|
449
468
|
|
|
450
469
|
const messages = responses.map((r) => r.message);
|
|
451
470
|
expect(messages).toContain("This is taking longer, continuing in the background.");
|
|
452
471
|
|
|
453
|
-
resolveCompletion!(
|
|
472
|
+
resolveCompletion!(queryResult({ action: "send", message: "done!", actionReason: "ok" }));
|
|
454
473
|
await waitForProcessing(100);
|
|
455
474
|
|
|
456
475
|
const allMessages = responses.map((r) => r.message);
|
|
@@ -459,59 +478,79 @@ describe("Orchestrator", () => {
|
|
|
459
478
|
|
|
460
479
|
it("session fork when background agent running on main session", async () => {
|
|
461
480
|
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
462
|
-
let resolveCompletion: (r:
|
|
463
|
-
const completion = new Promise<
|
|
481
|
+
let resolveCompletion: (r: QueryResult<unknown>) => void;
|
|
482
|
+
const completion = new Promise<QueryResult<unknown>>((r) => { resolveCompletion = r; });
|
|
483
|
+
|
|
484
|
+
const origSetTimeout = globalThis.setTimeout;
|
|
485
|
+
|
|
464
486
|
let callCount = 0;
|
|
465
|
-
const claude = mockClaude(
|
|
487
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
466
488
|
callCount++;
|
|
467
|
-
if (callCount === 1)
|
|
468
|
-
|
|
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" });
|
|
469
499
|
});
|
|
470
500
|
const { orch } = makeOrchestrator(claude);
|
|
471
501
|
|
|
472
502
|
// First message gets deferred (backgrounded on test-session)
|
|
473
503
|
orch.handleMessage("slow task");
|
|
504
|
+
await new Promise((r) => origSetTimeout(r, 10));
|
|
505
|
+
globalThis.setTimeout = origSetTimeout;
|
|
474
506
|
await waitForProcessing();
|
|
475
507
|
|
|
476
508
|
// Second message should trigger a fork (background running on test-session = main session)
|
|
477
509
|
orch.handleMessage("follow up");
|
|
478
510
|
await waitForProcessing();
|
|
479
511
|
|
|
480
|
-
|
|
481
|
-
expect(opts.forkSession).toBe(true);
|
|
512
|
+
expect(claude.calls[1].method).toBe("forkSession");
|
|
482
513
|
|
|
483
|
-
resolveCompletion!(
|
|
514
|
+
resolveCompletion!(queryResult({ action: "send", message: "bg done", actionReason: "ok" }));
|
|
484
515
|
await waitForProcessing(50);
|
|
485
516
|
});
|
|
486
517
|
|
|
487
518
|
it("background result with matching session: applied directly (no extra Claude call)", async () => {
|
|
488
519
|
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
)
|
|
495
|
-
|
|
520
|
+
let resolveCompletion: (r: QueryResult<unknown>) => void;
|
|
521
|
+
const completion = new Promise<QueryResult<unknown>>((r) => { resolveCompletion = r; });
|
|
522
|
+
|
|
523
|
+
const origSetTimeout = globalThis.setTimeout;
|
|
524
|
+
|
|
525
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
526
|
+
globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
|
|
527
|
+
return {
|
|
528
|
+
sessionId: "test-session",
|
|
529
|
+
startedAt: new Date(),
|
|
530
|
+
result: completion,
|
|
531
|
+
kill: mock(async () => {}),
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
const { orch, responses } = makeOrchestrator(claude);
|
|
496
535
|
|
|
497
|
-
|
|
536
|
+
orch.handleMessage("slow");
|
|
537
|
+
await new Promise((r) => origSetTimeout(r, 10));
|
|
538
|
+
globalThis.setTimeout = origSetTimeout;
|
|
498
539
|
await waitForProcessing();
|
|
499
|
-
// Now test-session is tracked as adopted
|
|
500
540
|
|
|
501
|
-
|
|
502
|
-
resolveCompletion!(successResult({ action: "send", message: "direct result", actionReason: "ok" }, "test-session"));
|
|
541
|
+
resolveCompletion!(queryResult({ action: "send", message: "direct result", actionReason: "ok" }, "test-session"));
|
|
503
542
|
await waitForProcessing(100);
|
|
504
543
|
|
|
505
|
-
const messages =
|
|
544
|
+
const messages = responses.map((r) => r.message);
|
|
506
545
|
expect(messages).toContain("direct result");
|
|
507
|
-
//
|
|
508
|
-
expect(
|
|
546
|
+
// Only called once (for the initial slow request, not for the result)
|
|
547
|
+
expect(claude.calls).toHaveLength(1);
|
|
509
548
|
});
|
|
510
549
|
});
|
|
511
550
|
|
|
512
551
|
describe("handleBackgroundList", () => {
|
|
513
552
|
it("sends 'no agents' message when none running", async () => {
|
|
514
|
-
const claude = mockClaude(
|
|
553
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
515
554
|
const { orch, responses } = makeOrchestrator(claude);
|
|
516
555
|
|
|
517
556
|
orch.handleBackgroundList();
|
|
@@ -521,7 +560,13 @@ describe("Orchestrator", () => {
|
|
|
521
560
|
});
|
|
522
561
|
|
|
523
562
|
it("includes peek buttons and dismiss when agents are running", async () => {
|
|
524
|
-
|
|
563
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
564
|
+
const claude = mockClaude((): RunningQuery<unknown> => ({
|
|
565
|
+
sessionId: `bg-${Date.now()}`,
|
|
566
|
+
startedAt: new Date(),
|
|
567
|
+
result: new Promise(() => {}),
|
|
568
|
+
kill: mock(async () => {}),
|
|
569
|
+
}));
|
|
525
570
|
const { orch, responses } = makeOrchestrator(claude);
|
|
526
571
|
|
|
527
572
|
orch.handleBackgroundCommand("long-task");
|
|
@@ -538,13 +583,13 @@ describe("Orchestrator", () => {
|
|
|
538
583
|
expect(typeof peekBtn).toBe("object");
|
|
539
584
|
expect((peekBtn as any).data).toMatch(/^peek:/);
|
|
540
585
|
expect((peekBtn as any).text).toContain("long-task");
|
|
541
|
-
expect(listResponse.buttons![1]).
|
|
586
|
+
expect(listResponse.buttons![1]).toEqual({ text: "Dismiss", data: "_dismiss" });
|
|
542
587
|
});
|
|
543
588
|
});
|
|
544
589
|
|
|
545
590
|
describe("handlePeek", () => {
|
|
546
591
|
it("returns 'not found' for unknown sessionId", async () => {
|
|
547
|
-
const claude = mockClaude(
|
|
592
|
+
const claude = mockClaude({ action: "send", message: "ok", actionReason: "ok" });
|
|
548
593
|
const { orch, responses } = makeOrchestrator(claude);
|
|
549
594
|
|
|
550
595
|
await orch.handlePeek("nonexistent-session");
|
|
@@ -554,11 +599,21 @@ describe("Orchestrator", () => {
|
|
|
554
599
|
});
|
|
555
600
|
|
|
556
601
|
it("peeks at running agent and returns status", async () => {
|
|
602
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
557
603
|
let callCount = 0;
|
|
558
|
-
const claude = mockClaude(
|
|
604
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
559
605
|
callCount++;
|
|
560
|
-
if (callCount === 1)
|
|
561
|
-
|
|
606
|
+
if (callCount === 1) {
|
|
607
|
+
// bg agent — never finishes
|
|
608
|
+
return {
|
|
609
|
+
sessionId: "bg-sid",
|
|
610
|
+
startedAt: new Date(),
|
|
611
|
+
result: new Promise(() => {}),
|
|
612
|
+
kill: mock(async () => {}),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
// peek fork call
|
|
616
|
+
return resolvedQuery("Working on it, 50% done.", "peek-session");
|
|
562
617
|
});
|
|
563
618
|
const { orch, responses } = makeOrchestrator(claude);
|
|
564
619
|
|
|
@@ -581,18 +636,30 @@ describe("Orchestrator", () => {
|
|
|
581
636
|
});
|
|
582
637
|
|
|
583
638
|
it("handles Claude error during peek gracefully", async () => {
|
|
639
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
584
640
|
let callCount = 0;
|
|
585
|
-
const claude = mockClaude(
|
|
641
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
586
642
|
callCount++;
|
|
587
|
-
if (callCount === 1)
|
|
588
|
-
|
|
643
|
+
if (callCount === 1) {
|
|
644
|
+
return {
|
|
645
|
+
sessionId: "bg-sid",
|
|
646
|
+
startedAt: new Date(),
|
|
647
|
+
result: new Promise(() => {}),
|
|
648
|
+
kill: mock(async () => {}),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
sessionId: "peek-err",
|
|
653
|
+
startedAt: new Date(),
|
|
654
|
+
result: Promise.reject(new Error("connection lost")),
|
|
655
|
+
kill: mock(async () => {}),
|
|
656
|
+
};
|
|
589
657
|
});
|
|
590
658
|
const { orch, responses } = makeOrchestrator(claude);
|
|
591
659
|
|
|
592
660
|
orch.handleBackgroundCommand("failing-peek");
|
|
593
661
|
await waitForProcessing();
|
|
594
662
|
|
|
595
|
-
// Get the internal session ID from the peek button
|
|
596
663
|
orch.handleBackgroundList();
|
|
597
664
|
await waitForProcessing();
|
|
598
665
|
const listResponse = responses[responses.length - 1];
|
|
@@ -609,28 +676,41 @@ describe("Orchestrator", () => {
|
|
|
609
676
|
|
|
610
677
|
describe("handleBackgroundCommand", () => {
|
|
611
678
|
it("spawns background agent and sends started message", async () => {
|
|
612
|
-
|
|
679
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
680
|
+
const claude = mockClaude((): RunningQuery<unknown> => ({
|
|
681
|
+
sessionId: "bg-sid",
|
|
682
|
+
startedAt: new Date(),
|
|
683
|
+
result: new Promise(() => {}),
|
|
684
|
+
kill: mock(async () => {}),
|
|
685
|
+
}));
|
|
613
686
|
const { orch, responses } = makeOrchestrator(claude);
|
|
614
687
|
|
|
615
688
|
orch.handleBackgroundCommand("research pricing");
|
|
616
689
|
await waitForProcessing();
|
|
617
690
|
|
|
618
691
|
expect(responses[0].message).toBe('Background agent "research-pricing" started.');
|
|
619
|
-
expect(
|
|
692
|
+
expect(claude.calls).toHaveLength(1);
|
|
620
693
|
});
|
|
621
694
|
});
|
|
622
695
|
|
|
623
696
|
describe("background management (spawn/adopt)", () => {
|
|
624
697
|
it("spawns background agent and feeds result back to queue", async () => {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
698
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
699
|
+
let resolvePromise: (r: QueryResult<unknown>) => void;
|
|
700
|
+
const bgResult = new Promise<QueryResult<unknown>>((r) => { resolvePromise = r; });
|
|
701
|
+
|
|
629
702
|
let callCount = 0;
|
|
630
|
-
const claude = mockClaude(
|
|
703
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
631
704
|
callCount++;
|
|
632
|
-
if (callCount === 1)
|
|
633
|
-
|
|
705
|
+
if (callCount === 1) {
|
|
706
|
+
return {
|
|
707
|
+
sessionId: "bg-sid",
|
|
708
|
+
startedAt: new Date(),
|
|
709
|
+
result: bgResult,
|
|
710
|
+
kill: mock(async () => {}),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
return resolvedQuery({ action: "send", message: "bg result processed", actionReason: "ok" });
|
|
634
714
|
});
|
|
635
715
|
const { orch, responses } = makeOrchestrator(claude);
|
|
636
716
|
|
|
@@ -639,8 +719,7 @@ describe("Orchestrator", () => {
|
|
|
639
719
|
|
|
640
720
|
expect(responses[0].message).toContain('started.');
|
|
641
721
|
|
|
642
|
-
resolvePromise!(
|
|
643
|
-
await claudePromise;
|
|
722
|
+
resolvePromise!(queryResult({ action: "send", message: "done!", actionReason: "completed" }));
|
|
644
723
|
await waitForProcessing(100);
|
|
645
724
|
|
|
646
725
|
// The bg result gets fed back to the queue and processed
|
|
@@ -648,15 +727,22 @@ describe("Orchestrator", () => {
|
|
|
648
727
|
});
|
|
649
728
|
|
|
650
729
|
it("feeds error back to queue on spawn failure", async () => {
|
|
730
|
+
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
651
731
|
let rejectPromise: (e: Error) => void;
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
});
|
|
732
|
+
const bgResult = new Promise<QueryResult<unknown>>((_, r) => { rejectPromise = r; });
|
|
733
|
+
|
|
655
734
|
let callCount = 0;
|
|
656
|
-
const claude = mockClaude(
|
|
735
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
657
736
|
callCount++;
|
|
658
|
-
if (callCount === 1)
|
|
659
|
-
|
|
737
|
+
if (callCount === 1) {
|
|
738
|
+
return {
|
|
739
|
+
sessionId: "bg-sid",
|
|
740
|
+
startedAt: new Date(),
|
|
741
|
+
result: bgResult,
|
|
742
|
+
kill: mock(async () => {}),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
return resolvedQuery({ action: "send", message: "error processed", actionReason: "ok" });
|
|
660
746
|
});
|
|
661
747
|
const { orch, responses } = makeOrchestrator(claude);
|
|
662
748
|
|
|
@@ -664,7 +750,6 @@ describe("Orchestrator", () => {
|
|
|
664
750
|
await waitForProcessing();
|
|
665
751
|
|
|
666
752
|
rejectPromise!(new Error("spawn failed"));
|
|
667
|
-
try { await claudePromise; } catch {}
|
|
668
753
|
await waitForProcessing(100);
|
|
669
754
|
|
|
670
755
|
// Error should be fed back and processed
|
|
@@ -672,32 +757,72 @@ describe("Orchestrator", () => {
|
|
|
672
757
|
expect(responses[responses.length - 1].message).toBe("error processed");
|
|
673
758
|
});
|
|
674
759
|
|
|
760
|
+
it("adopt feeds error back when deferred rejects", async () => {
|
|
761
|
+
saveSessions({ mainSessionId: "adopted-session" }, tmpSettingsDir);
|
|
762
|
+
let rejectCompletion: (err: Error) => void;
|
|
763
|
+
const completion = new Promise<QueryResult<unknown>>((_, r) => { rejectCompletion = r; });
|
|
764
|
+
|
|
765
|
+
const origSetTimeout = globalThis.setTimeout;
|
|
766
|
+
|
|
767
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
768
|
+
globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
|
|
769
|
+
return {
|
|
770
|
+
sessionId: "adopted-session",
|
|
771
|
+
startedAt: new Date(),
|
|
772
|
+
result: completion,
|
|
773
|
+
kill: mock(async () => {}),
|
|
774
|
+
};
|
|
775
|
+
});
|
|
776
|
+
const { orch, responses } = makeOrchestrator(claude);
|
|
777
|
+
|
|
778
|
+
orch.handleMessage("slow");
|
|
779
|
+
await new Promise((r) => origSetTimeout(r, 10));
|
|
780
|
+
globalThis.setTimeout = origSetTimeout;
|
|
781
|
+
await waitForProcessing();
|
|
782
|
+
|
|
783
|
+
rejectCompletion!(new Error("process crashed"));
|
|
784
|
+
await waitForProcessing(100);
|
|
785
|
+
|
|
786
|
+
const messages = responses.map((r) => r.message);
|
|
787
|
+
expect(messages.some((m) => m.includes("[Error]"))).toBe(true);
|
|
788
|
+
});
|
|
789
|
+
|
|
675
790
|
it("adopt feeds result back when deferred resolves", async () => {
|
|
676
791
|
saveSessions({ mainSessionId: "adopted-session" }, tmpSettingsDir);
|
|
677
|
-
let resolveCompletion: (r:
|
|
678
|
-
const completion = new Promise<
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
792
|
+
let resolveCompletion: (r: QueryResult<unknown>) => void;
|
|
793
|
+
const completion = new Promise<QueryResult<unknown>>((r) => { resolveCompletion = r; });
|
|
794
|
+
|
|
795
|
+
const origSetTimeout = globalThis.setTimeout;
|
|
796
|
+
|
|
797
|
+
const claude = mockClaude((): RunningQuery<unknown> => {
|
|
798
|
+
globalThis.setTimeout = ((fn: Function) => { fn(); return 0 as any; }) as any;
|
|
799
|
+
return {
|
|
800
|
+
sessionId: "adopted-session",
|
|
801
|
+
startedAt: new Date(),
|
|
802
|
+
result: completion,
|
|
803
|
+
kill: mock(async () => {}),
|
|
804
|
+
};
|
|
805
|
+
});
|
|
682
806
|
const { orch, responses } = makeOrchestrator(claude);
|
|
683
807
|
|
|
684
808
|
orch.handleMessage("slow");
|
|
809
|
+
await new Promise((r) => origSetTimeout(r, 10));
|
|
810
|
+
globalThis.setTimeout = origSetTimeout;
|
|
685
811
|
await waitForProcessing();
|
|
686
812
|
|
|
687
813
|
expect(responses[0].message).toContain("taking longer");
|
|
688
814
|
|
|
689
|
-
resolveCompletion!(
|
|
815
|
+
resolveCompletion!(queryResult({ action: "send", message: "completed!", actionReason: "ok" }));
|
|
690
816
|
await waitForProcessing(100);
|
|
691
817
|
|
|
692
818
|
const messages = responses.map((r) => r.message);
|
|
693
819
|
expect(messages).toContain("completed!");
|
|
694
820
|
});
|
|
695
|
-
|
|
696
821
|
});
|
|
697
822
|
|
|
698
823
|
describe("onResponse error handling", () => {
|
|
699
824
|
it("logs error and does not throw when onResponse callback fails", async () => {
|
|
700
|
-
const claude = mockClaude(
|
|
825
|
+
const claude = mockClaude({ action: "send", message: "hello", actionReason: "ok" });
|
|
701
826
|
const failingOnResponse = mock(async (_r: OrchestratorResponse) => { throw new Error("send failed"); });
|
|
702
827
|
const orch = new Orchestrator({
|
|
703
828
|
workspace: TEST_WORKSPACE,
|
|
@@ -706,11 +831,9 @@ describe("Orchestrator", () => {
|
|
|
706
831
|
claude,
|
|
707
832
|
});
|
|
708
833
|
|
|
709
|
-
// handleBackgroundList and handleBackgroundCommand use #callOnResponse
|
|
710
834
|
orch.handleBackgroundList();
|
|
711
835
|
await waitForProcessing();
|
|
712
836
|
|
|
713
|
-
// Should not throw — error is caught internally
|
|
714
837
|
expect(failingOnResponse).toHaveBeenCalled();
|
|
715
838
|
});
|
|
716
839
|
});
|