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.
@@ -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, type ClaudeDeferredResult, ClaudeParseError, ClaudeProcessError, type ClaudeResult, type ClaudeRunOptions } from "./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
- function mockClaude(response: ClaudeResult | ((opts: ClaudeRunOptions) => Promise<ClaudeResult | ClaudeDeferredResult>)) {
19
- const run = typeof response === "function"
20
- ? mock(response)
21
- : mock(async () => response);
22
- return { run } as unknown as Claude & { run: ReturnType<typeof mock> };
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
- function successResult(structuredOutput: unknown, sessionId = "test-session-id"): ClaudeResult {
26
- return { structuredOutput, sessionId, duration: "1.0s", cost: "$0.05" };
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(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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
- const opts = claude.run.mock.calls[0][0];
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(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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
- const opts = claude.run.mock.calls[0][0];
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(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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.run.mock.calls[0][0].prompt).toBe("[File: /tmp/photo.jpg]");
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(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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
- const opts = claude.run.mock.calls[0][0];
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(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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.run.mock.calls[0][0].model).toBe("opus");
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(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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.run.mock.calls[0][0].model).toBe("sonnet");
144
+ expect(claude.calls[0].model).toBe("sonnet");
113
145
  });
114
146
 
115
147
  it("builds button click prompt", async () => {
116
- const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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
- const opts = claude.run.mock.calls[0][0];
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(successResult({ action: "send", message: "hello", actionReason: "ok" }));
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] &lt;b&gt;bold&lt;/b&gt; &amp; 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(successResult(output));
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(successResult(output));
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 ClaudeProcessError to process-error response", async () => {
211
- const claude = mockClaude(async () => { throw new ClaudeProcessError(1, "spawn failed"); });
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 ClaudeParseError to json-parse-failed response", async () => {
222
- const claude = mockClaude(async () => { throw new ClaudeParseError("not json"); });
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 resume=true for existing session", async () => {
232
+ it("uses resumeSession for existing session", async () => {
234
233
  saveSessions({ mainSessionId: "existing-session" }, tmpSettingsDir);
235
- const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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.run.mock.calls[0][0].resume).toBe(true);
242
- expect(claude.run.mock.calls[0][0].sessionId).toBe("existing-session");
240
+ expect(claude.calls[0].method).toBe("resumeSession");
241
+ expect(claude.calls[0].sessionId).toBe("existing-session");
243
242
  });
244
243
 
245
- it("creates new session when no settings exist", async () => {
246
- const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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.run.mock.calls[0][0].resume).toBe(false);
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(async (_opts: ClaudeRunOptions): Promise<ClaudeResult> => {
257
+ const claude = mockClaude((_info: CallInfo): RunningQuery<unknown> => {
260
258
  callCount++;
261
- if (callCount === 1) throw new ClaudeProcessError(1, "session not found");
262
- return successResult({ action: "send", message: "ok", actionReason: "ok" });
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.run.mock.calls[0][0].resume).toBe(true);
271
- expect(claude.run.mock.calls[1][0].resume).toBe(false);
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 resume=true after first success", async () => {
276
- const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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.run.mock.calls[0][0].resume).toBe(false);
285
- expect(claude.run.mock.calls[1][0].resume).toBe(true);
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(successResult({ action: "send", message: "", actionReason: "" }));
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 without affecting it", async () => {
304
+ it("background-agent forks from main session", async () => {
301
305
  saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
302
- const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }, "main-session"));
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
- // background-agent should use --resume and forkSession
317
- expect(claude.run.mock.calls[1][0].resume).toBe(true);
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(successResult({ action: "send", message: "ok", actionReason: "ok" }, "new-forked-session"));
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.run.mock.calls[1][0].sessionId).toBe("new-forked-session");
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(successResult({ action: "send", message: "result", actionReason: "ok" }));
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.run).toHaveBeenCalledTimes(1);
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(successResult({ action: "send", message: "button response", actionReason: "ok" }));
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.run.mock.calls[0][0].prompt).toBe('[Context: button-click] User tapped "Yes"');
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(successResult({ action: "send", message: "cron done", actionReason: "ok" }));
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.run.mock.calls[0][0].prompt).toBe("[Context: cron/daily-check] Check for updates");
368
- expect(claude.run.mock.calls[0][0].model).toBe("haiku");
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
- const claude = mockClaude(async (_opts: ClaudeRunOptions): Promise<ClaudeResult> => {
378
- const callNum = (claude as any).run.mock.calls.length;
379
- if (callNum === 1) {
380
- await firstCallDone;
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(callNum);
383
- return successResult({ action: "send", message: `call ${callNum}`, actionReason: "ok" });
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
- // Verify they ran in order
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(successResult({ action: "silent", actionReason: "no new results" }));
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(async (): Promise<ClaudeResult> => {
418
+ const claude = mockClaude((): RunningQuery<unknown> => {
413
419
  callCount++;
414
420
  if (callCount === 1) {
415
- return successResult({
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 successResult({ action: "send", message: "research result", actionReason: "done" });
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: ClaudeResult) => void;
441
- const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
442
- const claude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> =>
443
- ({ deferred: true, sessionId: "test-session", completion }),
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!(successResult({ action: "send", message: "done!", actionReason: "ok" }));
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: ClaudeResult) => void;
463
- const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
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(async (): Promise<ClaudeResult | ClaudeDeferredResult> => {
487
+ const claude = mockClaude((): RunningQuery<unknown> => {
466
488
  callCount++;
467
- if (callCount === 1) return { deferred: true, sessionId: "test-session", completion };
468
- return successResult({ action: "send", message: "forked response", actionReason: "ok" });
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
- const opts = (claude as any).run.mock.calls[1][0] as ClaudeRunOptions;
481
- expect(opts.forkSession).toBe(true);
512
+ expect(claude.calls[1].method).toBe("forkSession");
482
513
 
483
- resolveCompletion!(successResult({ action: "send", message: "bg done", actionReason: "ok" }));
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
- // Use a deferred claude to simulate the scenario where a task is backgrounded
490
- let resolveCompletion: (r: ClaudeResult) => void;
491
- const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
492
- const deferredClaude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> =>
493
- ({ deferred: true, sessionId: "test-session", completion }),
494
- );
495
- const { orch: orch2, responses: responses2 } = makeOrchestrator(deferredClaude);
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
- orch2.handleMessage("slow");
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
- // Resolve with a result that has sessionId matching
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 = responses2.map((r) => r.message);
544
+ const messages = responses.map((r) => r.message);
506
545
  expect(messages).toContain("direct result");
507
- // The deferred claude was only called once (for the initial slow request, not for the result)
508
- expect(deferredClaude.run).toHaveBeenCalledTimes(1);
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(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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
- const claude = mockClaude(() => new Promise<ClaudeResult>(() => {}));
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]).toBe("_dismiss");
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(successResult({ action: "send", message: "ok", actionReason: "ok" }));
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(async (): Promise<ClaudeResult> => {
604
+ const claude = mockClaude((): RunningQuery<unknown> => {
559
605
  callCount++;
560
- if (callCount === 1) return new Promise(() => {}); // bg agent never finishes
561
- return { structuredOutput: null, sessionId: "peek-session", result: "Working on it, 50% done." };
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(async (): Promise<ClaudeResult> => {
641
+ const claude = mockClaude((): RunningQuery<unknown> => {
586
642
  callCount++;
587
- if (callCount === 1) return new Promise(() => {}); // bg agent never finishes
588
- throw new Error("connection lost");
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
- const claude = mockClaude(() => new Promise<ClaudeResult>(() => {}));
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((claude as any).run).toHaveBeenCalledTimes(1);
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
- let resolvePromise: (r: ClaudeResult) => void;
626
- const claudePromise = new Promise<ClaudeResult>((r) => {
627
- resolvePromise = r;
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(async (): Promise<ClaudeResult> => {
703
+ const claude = mockClaude((): RunningQuery<unknown> => {
631
704
  callCount++;
632
- if (callCount === 1) return claudePromise;
633
- return successResult({ action: "send", message: "bg result processed", actionReason: "ok" });
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!(successResult({ action: "send", message: "done!", actionReason: "completed" }));
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 claudePromise = new Promise<ClaudeResult>((_, r) => {
653
- rejectPromise = r;
654
- });
732
+ const bgResult = new Promise<QueryResult<unknown>>((_, r) => { rejectPromise = r; });
733
+
655
734
  let callCount = 0;
656
- const claude = mockClaude(async (): Promise<ClaudeResult> => {
735
+ const claude = mockClaude((): RunningQuery<unknown> => {
657
736
  callCount++;
658
- if (callCount === 1) return claudePromise;
659
- return successResult({ action: "send", message: "error processed", actionReason: "ok" });
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: ClaudeResult) => void;
678
- const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
679
- const claude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> =>
680
- ({ deferred: true, sessionId: "adopted-session", completion }),
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!(successResult({ action: "send", message: "completed!", actionReason: "ok" }));
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(successResult({ action: "send", message: "hello", actionReason: "ok" }));
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
  });