macroclaw 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.test.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import { existsSync, rmSync } from "node:fs";
3
3
  import { App, type AppConfig } from "./app";
4
- import { type Claude, QueryProcessError, type QueryResult, type RunningQuery } from "./claude";
4
+ import { type Claude, type ClaudeProcess, type ProcessState, QueryProcessError, type QueryResult } from "./claude";
5
5
  import { saveSessions } from "./sessions";
6
6
  import type { SpeechToText } from "./speech-to-text";
7
7
 
@@ -53,6 +53,7 @@ mock.module("grammy", () => ({
53
53
  }));
54
54
 
55
55
  const tmpSettingsDir = "/tmp/macroclaw-test-settings";
56
+ const activeApps: App[] = [];
56
57
 
57
58
  beforeEach(() => {
58
59
  mockTranscribe.mockReset();
@@ -61,7 +62,10 @@ beforeEach(() => {
61
62
  saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
62
63
  });
63
64
 
64
- afterEach(() => {
65
+ afterEach(async () => {
66
+ for (const app of activeApps.splice(0)) {
67
+ await app.dispose();
68
+ }
65
69
  if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
66
70
  });
67
71
 
@@ -69,47 +73,57 @@ function queryResult<T>(value: T, sessionId = "test-session-id"): QueryResult<T>
69
73
  return { value, sessionId, duration: "1.0s", cost: "$0.05" };
70
74
  }
71
75
 
72
- function resolvedQuery<T>(value: T, sessionId = "test-session-id"): RunningQuery<T> {
76
+ function autoProcess(value: unknown, sessionId = "test-sid"): ClaudeProcess<unknown> {
73
77
  return {
74
78
  sessionId,
75
79
  startedAt: new Date(),
76
- result: Promise.resolve(queryResult(value, sessionId)),
80
+ get state(): ProcessState { return "idle"; },
81
+ send: mock(async (_prompt: string) => queryResult(value, sessionId)),
77
82
  kill: mock(async () => {}),
78
- };
83
+ } as unknown as ClaudeProcess<unknown>;
79
84
  }
80
85
 
81
86
  interface CallInfo {
82
87
  method: string;
83
- prompt: string;
84
88
  sessionId?: string;
85
89
  }
86
90
 
87
- function mockClaude(handler: (info: CallInfo) => RunningQuery<unknown>): Claude & { calls: CallInfo[] } {
91
+ function mockClaude(handler: (info: CallInfo) => ClaudeProcess<unknown>): Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] } {
88
92
  const calls: CallInfo[] = [];
93
+ const processes: ClaudeProcess<unknown>[] = [];
94
+
95
+ function handleCall(info: CallInfo): ClaudeProcess<unknown> {
96
+ calls.push(info);
97
+ const proc = handler(info);
98
+ processes.push(proc);
99
+ return proc;
100
+ }
101
+
89
102
  const claude = {
90
- newSession: mock((prompt: string, _resultType: unknown, _options?: any) => {
91
- const info: CallInfo = { method: "newSession", prompt };
92
- calls.push(info);
93
- return handler(info);
103
+ newSession: mock((_resultType: unknown, _options?: unknown) => {
104
+ return handleCall({ method: "newSession" });
94
105
  }),
95
- resumeSession: mock((sessionId: string, prompt: string, _resultType: unknown, _options?: any) => {
96
- const info: CallInfo = { method: "resumeSession", sessionId, prompt };
97
- calls.push(info);
98
- return handler(info);
106
+ resumeSession: mock((sessionId: string, _resultType: unknown, _options?: unknown) => {
107
+ return handleCall({ method: "resumeSession", sessionId });
99
108
  }),
100
- forkSession: mock((sessionId: string, prompt: string, _resultType: unknown, _options?: any) => {
101
- const info: CallInfo = { method: "forkSession", sessionId, prompt };
102
- calls.push(info);
103
- return handler(info);
109
+ forkSession: mock((sessionId: string, _resultType: unknown, _options?: unknown) => {
110
+ return handleCall({ method: "forkSession", sessionId });
104
111
  }),
105
112
  calls,
106
- } as unknown as Claude & { calls: CallInfo[] };
113
+ processes,
114
+ } as unknown as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
107
115
  return claude;
108
116
  }
109
117
 
110
- function defaultMockClaude(): Claude & { calls: CallInfo[] } {
111
- return mockClaude((info) =>
112
- resolvedQuery({ action: "send", message: `Response to: ${info.prompt}`, actionReason: "user message" }),
118
+ function defaultMockClaude(): Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] } {
119
+ return mockClaude(() =>
120
+ autoProcess({ action: "send", message: "Response", actionReason: "user message" }),
121
+ );
122
+ }
123
+
124
+ function sentPrompts(claude: { processes: ClaudeProcess<unknown>[] }): string[] {
125
+ return claude.processes.flatMap((p) =>
126
+ (p.send as ReturnType<typeof mock>).mock.calls.map((c: unknown[]) => c[0] as string),
113
127
  );
114
128
  }
115
129
 
@@ -121,18 +135,30 @@ function makeConfig(overrides?: Partial<AppConfig>): AppConfig {
121
135
  settingsDir: tmpSettingsDir,
122
136
  claude: defaultMockClaude(),
123
137
  stt: mockStt(),
138
+ healthCheckInterval: 0,
124
139
  ...overrides,
125
140
  };
126
141
  }
127
142
 
143
+ function makeApp(overrides?: Partial<AppConfig>): App {
144
+ const app = new App(makeConfig(overrides));
145
+ activeApps.push(app);
146
+ return app;
147
+ }
148
+
149
+ function trackApp(app: App): App {
150
+ activeApps.push(app);
151
+ return app;
152
+ }
153
+
128
154
  describe("App", () => {
129
155
  it("creates bot", () => {
130
- const app = new App(makeConfig());
156
+ const app = makeApp();
131
157
  expect(app.bot).toBeDefined();
132
158
  });
133
159
 
134
160
  it("registers message:text, message:photo, message:document, message:voice, and callback_query:data handlers", () => {
135
- const app = new App(makeConfig());
161
+ const app = makeApp();
136
162
  const bot = app.bot as any;
137
163
  expect(bot.filterHandlers.has("message:text")).toBe(true);
138
164
  expect(bot.filterHandlers.has("message:photo")).toBe(true);
@@ -141,16 +167,17 @@ describe("App", () => {
141
167
  expect(bot.filterHandlers.has("callback_query:data")).toBe(true);
142
168
  });
143
169
 
144
- it("registers chatid, bg, and sessions commands", () => {
145
- const app = new App(makeConfig());
170
+ it("registers chatid, bg, sessions, and clear commands", () => {
171
+ const app = makeApp();
146
172
  const bot = app.bot as any;
147
173
  expect(bot.commandHandlers.has("chatid")).toBe(true);
148
174
  expect(bot.commandHandlers.has("bg")).toBe(true);
149
175
  expect(bot.commandHandlers.has("sessions")).toBe(true);
176
+ expect(bot.commandHandlers.has("clear")).toBe(true);
150
177
  });
151
178
 
152
179
  it("registers error handler", () => {
153
- const app = new App(makeConfig());
180
+ const app = makeApp();
154
181
  const bot = app.bot as any;
155
182
  expect(bot.errorHandler).not.toBeNull();
156
183
  });
@@ -158,33 +185,33 @@ describe("App", () => {
158
185
  describe("message handler", () => {
159
186
  it("routes messages from authorized chat to orchestrator", async () => {
160
187
  const config = makeConfig();
161
- const app = new App(config);
188
+ const app = trackApp(new App(config));
162
189
  const bot = app.bot as any;
163
190
  const handler = bot.filterHandlers.get("message:text")![0];
164
191
 
165
192
  handler({ chat: { id: 12345 }, message: { text: "hello" } });
166
193
  await new Promise((r) => setTimeout(r, 50));
167
194
 
168
- const claude = config.claude as Claude & { calls: CallInfo[] };
169
- expect(claude.calls[0].prompt).toContain("<text>hello</text>");
195
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
196
+ expect(sentPrompts(claude)[0]).toContain("<text>hello</text>");
170
197
  });
171
198
 
172
199
  it("ignores messages from unauthorized chats", async () => {
173
200
  const config = makeConfig();
174
- const app = new App(config);
201
+ const app = trackApp(new App(config));
175
202
  const bot = app.bot as any;
176
203
  const handler = bot.filterHandlers.get("message:text")![0];
177
204
 
178
205
  handler({ chat: { id: 99999 }, message: { text: "hello" } });
179
206
  await new Promise((r) => setTimeout(r, 50));
180
207
 
181
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
208
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
182
209
  });
183
210
 
184
211
  it("sends [No output] for empty claude response", async () => {
185
- const claude = mockClaude(() => resolvedQuery({ action: "send", message: "", actionReason: "empty" }));
212
+ const claude = mockClaude(() => autoProcess({ action: "send", message: "", actionReason: "empty" }));
186
213
  const config = makeConfig({ claude });
187
- const app = new App(config);
214
+ const app = trackApp(new App(config));
188
215
  const bot = app.bot as any;
189
216
  const handler = bot.filterHandlers.get("message:text")![0];
190
217
 
@@ -197,9 +224,9 @@ describe("App", () => {
197
224
  });
198
225
 
199
226
  it("skips sending when action is silent", async () => {
200
- const claude = mockClaude(() => resolvedQuery({ action: "silent", actionReason: "no new results" }));
227
+ const claude = mockClaude(() => autoProcess({ action: "silent", actionReason: "no new results" }));
201
228
  const config = makeConfig({ claude });
202
- const app = new App(config);
229
+ const app = trackApp(new App(config));
203
230
  const bot = app.bot as any;
204
231
  const handler = bot.filterHandlers.get("message:text")![0];
205
232
 
@@ -211,26 +238,25 @@ describe("App", () => {
211
238
 
212
239
  it("does not treat bg: prefix as special", async () => {
213
240
  const config = makeConfig();
214
- const app = new App(config);
241
+ const app = trackApp(new App(config));
215
242
  const bot = app.bot as any;
216
243
  const handler = bot.filterHandlers.get("message:text")![0];
217
244
 
218
245
  handler({ chat: { id: 12345 }, message: { text: "bg: research pricing" } });
219
246
  await new Promise((r) => setTimeout(r, 50));
220
247
 
221
- const claude = config.claude as Claude & { calls: CallInfo[] };
222
- expect(claude.calls[0].prompt).toContain("<text>bg: research pricing</text>");
248
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
249
+ expect(sentPrompts(claude)[0]).toContain("<text>bg: research pricing</text>");
223
250
  });
224
251
 
225
252
  it("sends error wrapped in response", async () => {
226
- const claude = mockClaude((): RunningQuery<unknown> => ({
227
- sessionId: "err-sid",
228
- startedAt: new Date(),
229
- result: Promise.reject(new QueryProcessError(1, "spawn failed")),
230
- kill: mock(async () => {}),
231
- }));
253
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
254
+ const proc = autoProcess(null, "err-sid");
255
+ (proc as any).send = mock(async () => { throw new QueryProcessError(1, "spawn failed"); });
256
+ return proc;
257
+ });
232
258
  const config = makeConfig({ claude });
233
- const app = new App(config);
259
+ const app = trackApp(new App(config));
234
260
  const bot = app.bot as any;
235
261
  const handler = bot.filterHandlers.get("message:text")![0];
236
262
 
@@ -250,7 +276,7 @@ describe("App", () => {
250
276
  ) as any;
251
277
 
252
278
  const config = makeConfig();
253
- const app = new App(config);
279
+ const app = trackApp(new App(config));
254
280
  const bot = app.bot as any;
255
281
  const handler = bot.filterHandlers.get("message:photo")![0];
256
282
 
@@ -267,9 +293,9 @@ describe("App", () => {
267
293
  await new Promise((r) => setTimeout(r, 50));
268
294
 
269
295
  expect(bot.api.getFile).toHaveBeenCalledWith("large");
270
- const claude = config.claude as Claude & { calls: CallInfo[] };
296
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
271
297
  expect(claude.calls).toHaveLength(1);
272
- expect(claude.calls[0].prompt).toContain("<file path=");
298
+ expect(sentPrompts(claude)[0]).toContain("<file path=");
273
299
 
274
300
  globalThis.fetch = origFetch;
275
301
  });
@@ -281,7 +307,7 @@ describe("App", () => {
281
307
  ) as any;
282
308
 
283
309
  const config = makeConfig();
284
- const app = new App(config);
310
+ const app = trackApp(new App(config));
285
311
  const bot = app.bot as any;
286
312
  const handler = bot.filterHandlers.get("message:document")![0];
287
313
 
@@ -295,16 +321,16 @@ describe("App", () => {
295
321
  await new Promise((r) => setTimeout(r, 50));
296
322
 
297
323
  expect(bot.api.getFile).toHaveBeenCalledWith("doc-id");
298
- const claude = config.claude as Claude & { calls: CallInfo[] };
324
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
299
325
  expect(claude.calls).toHaveLength(1);
300
- expect(claude.calls[0].prompt).toContain("<file path=");
326
+ expect(sentPrompts(claude)[0]).toContain("<file path=");
301
327
 
302
328
  globalThis.fetch = origFetch;
303
329
  });
304
330
 
305
331
  it("routes error message when photo download fails", async () => {
306
332
  const config = makeConfig();
307
- const app = new App(config);
333
+ const app = trackApp(new App(config));
308
334
  const bot = app.bot as any;
309
335
  bot.api.getFile = mock(async () => { throw new Error("too large"); });
310
336
  const handler = bot.filterHandlers.get("message:photo")![0];
@@ -315,14 +341,14 @@ describe("App", () => {
315
341
  });
316
342
  await new Promise((r) => setTimeout(r, 50));
317
343
 
318
- const claude = config.claude as Claude & { calls: CallInfo[] };
344
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
319
345
  expect(claude.calls).toHaveLength(1);
320
- expect(claude.calls[0].prompt).toContain("[File download failed: photo.jpg]");
346
+ expect(sentPrompts(claude)[0]).toContain("[File download failed: photo.jpg]");
321
347
  });
322
348
 
323
349
  it("routes error message when document download fails", async () => {
324
350
  const config = makeConfig();
325
- const app = new App(config);
351
+ const app = trackApp(new App(config));
326
352
  const bot = app.bot as any;
327
353
  bot.api.getFile = mock(async () => { throw new Error("too large"); });
328
354
  const handler = bot.filterHandlers.get("message:document")![0];
@@ -333,9 +359,9 @@ describe("App", () => {
333
359
  });
334
360
  await new Promise((r) => setTimeout(r, 50));
335
361
 
336
- const claude = config.claude as Claude & { calls: CallInfo[] };
362
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
337
363
  expect(claude.calls).toHaveLength(1);
338
- expect(claude.calls[0].prompt).toContain("[File download failed: huge.pdf]");
364
+ expect(sentPrompts(claude)[0]).toContain("[File download failed: huge.pdf]");
339
365
  });
340
366
 
341
367
  it("handles voice messages by transcribing and routing text to orchestrator", async () => {
@@ -346,7 +372,7 @@ describe("App", () => {
346
372
  mockTranscribe.mockImplementationOnce(async () => "hello from voice");
347
373
 
348
374
  const config = makeConfig();
349
- const app = new App(config);
375
+ const app = trackApp(new App(config));
350
376
  const bot = app.bot as any;
351
377
  const handler = bot.filterHandlers.get("message:voice")![0];
352
378
 
@@ -361,9 +387,9 @@ describe("App", () => {
361
387
  expect(echoCall).toBeDefined();
362
388
  expect(echoCall[1]).toContain("hello from voice");
363
389
 
364
- const claude = config.claude as Claude & { calls: CallInfo[] };
390
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
365
391
  expect(claude.calls).toHaveLength(1);
366
- expect(claude.calls[0].prompt).toContain("<text>hello from voice</text>");
392
+ expect(sentPrompts(claude)[0]).toContain("<text>hello from voice</text>");
367
393
 
368
394
  globalThis.fetch = origFetch;
369
395
  });
@@ -376,7 +402,7 @@ describe("App", () => {
376
402
  mockTranscribe.mockImplementationOnce(async () => { throw new Error("API error"); });
377
403
 
378
404
  const config = makeConfig();
379
- const app = new App(config);
405
+ const app = trackApp(new App(config));
380
406
  const bot = app.bot as any;
381
407
  const handler = bot.filterHandlers.get("message:voice")![0];
382
408
 
@@ -389,7 +415,7 @@ describe("App", () => {
389
415
  const sendCalls = (bot.api.sendMessage as any).mock.calls;
390
416
  const errorCall = sendCalls.find((c: any) => c[1].includes("[Failed to transcribe audio]"));
391
417
  expect(errorCall).toBeDefined();
392
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
418
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
393
419
 
394
420
  globalThis.fetch = origFetch;
395
421
  });
@@ -402,7 +428,7 @@ describe("App", () => {
402
428
  mockTranscribe.mockImplementationOnce(async () => " ");
403
429
 
404
430
  const config = makeConfig();
405
- const app = new App(config);
431
+ const app = trackApp(new App(config));
406
432
  const bot = app.bot as any;
407
433
  const handler = bot.filterHandlers.get("message:voice")![0];
408
434
 
@@ -415,14 +441,14 @@ describe("App", () => {
415
441
  const sendCalls = (bot.api.sendMessage as any).mock.calls;
416
442
  const emptyCall = sendCalls.find((c: any) => c[1].includes("[Could not understand audio]"));
417
443
  expect(emptyCall).toBeDefined();
418
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
444
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
419
445
 
420
446
  globalThis.fetch = origFetch;
421
447
  });
422
448
 
423
449
  it("ignores voice messages from unauthorized chats", async () => {
424
450
  const config = makeConfig();
425
- const app = new App(config);
451
+ const app = trackApp(new App(config));
426
452
  const bot = app.bot as any;
427
453
  const handler = bot.filterHandlers.get("message:voice")![0];
428
454
 
@@ -432,12 +458,12 @@ describe("App", () => {
432
458
  });
433
459
  await new Promise((r) => setTimeout(r, 50));
434
460
 
435
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
461
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
436
462
  });
437
463
 
438
464
  it("responds with unavailable message when stt is not configured", async () => {
439
465
  const config = makeConfig({ stt: undefined });
440
- const app = new App(config);
466
+ const app = trackApp(new App(config));
441
467
  const bot = app.bot as any;
442
468
  const handler = bot.filterHandlers.get("message:voice")![0];
443
469
 
@@ -449,12 +475,12 @@ describe("App", () => {
449
475
  const sendCalls = (bot.api.sendMessage as any).mock.calls;
450
476
  const call = sendCalls.find((c: any) => c[1].includes("openaiApiKey"));
451
477
  expect(call).toBeDefined();
452
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
478
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
453
479
  });
454
480
 
455
481
  it("ignores photo messages from unauthorized chats", async () => {
456
482
  const config = makeConfig();
457
- const app = new App(config);
483
+ const app = trackApp(new App(config));
458
484
  const bot = app.bot as any;
459
485
  const handler = bot.filterHandlers.get("message:photo")![0];
460
486
 
@@ -464,21 +490,21 @@ describe("App", () => {
464
490
  });
465
491
  await new Promise((r) => setTimeout(r, 50));
466
492
 
467
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
493
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
468
494
  });
469
495
 
470
496
  it("sends outbound files before text message (onResponse delivery)", async () => {
471
497
  const tmpFile = `/tmp/macroclaw-test-outbound-${Date.now()}.png`;
472
498
  await Bun.write(tmpFile, "fake png");
473
499
 
474
- const claude = mockClaude(() => resolvedQuery({
500
+ const claude = mockClaude(() => autoProcess({
475
501
  action: "send",
476
502
  message: "Here's your chart",
477
503
  actionReason: "ok",
478
504
  files: [tmpFile],
479
505
  }));
480
506
  const config = makeConfig({ claude });
481
- const app = new App(config);
507
+ const app = trackApp(new App(config));
482
508
  const bot = app.bot as any;
483
509
  const handler = bot.filterHandlers.get("message:text")![0];
484
510
 
@@ -493,14 +519,14 @@ describe("App", () => {
493
519
  });
494
520
 
495
521
  it("passes buttons to sendResponse (onResponse delivery)", async () => {
496
- const claude = mockClaude(() => resolvedQuery({
522
+ const claude = mockClaude(() => autoProcess({
497
523
  action: "send",
498
524
  message: "Choose one",
499
525
  actionReason: "ok",
500
526
  buttons: ["Yes", "No"],
501
527
  }));
502
528
  const config = makeConfig({ claude });
503
- const app = new App(config);
529
+ const app = trackApp(new App(config));
504
530
  const bot = app.bot as any;
505
531
  const handler = bot.filterHandlers.get("message:text")![0];
506
532
 
@@ -514,7 +540,7 @@ describe("App", () => {
514
540
 
515
541
  it("handles callback_query by routing button event to orchestrator", async () => {
516
542
  const config = makeConfig();
517
- const app = new App(config);
543
+ const app = trackApp(new App(config));
518
544
  const bot = app.bot as any;
519
545
  const handler = bot.filterHandlers.get("callback_query:data")![0];
520
546
 
@@ -530,14 +556,14 @@ describe("App", () => {
530
556
 
531
557
  expect(ctx.answerCallbackQuery).toHaveBeenCalled();
532
558
  expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Yes", callback_data: "_noop" }]] } });
533
- const claude = config.claude as Claude & { calls: CallInfo[] };
559
+ const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
534
560
  expect(claude.calls).toHaveLength(1);
535
- expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
561
+ expect(sentPrompts(claude)[0]).toContain('<button>Yes</button>');
536
562
  });
537
563
 
538
564
  it("handles _dismiss callback by removing reply markup", async () => {
539
565
  const config = makeConfig();
540
- const app = new App(config);
566
+ const app = trackApp(new App(config));
541
567
  const bot = app.bot as any;
542
568
  const handler = bot.filterHandlers.get("callback_query:data")![0];
543
569
 
@@ -553,12 +579,12 @@ describe("App", () => {
553
579
 
554
580
  expect(ctx.answerCallbackQuery).toHaveBeenCalled();
555
581
  expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: undefined });
556
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
582
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
557
583
  });
558
584
 
559
585
  it("handles detail: callback by routing to orchestrator.handleDetail", async () => {
560
586
  const config = makeConfig();
561
- const app = new App(config);
587
+ const app = trackApp(new App(config));
562
588
  const bot = app.bot as any;
563
589
  const handler = bot.filterHandlers.get("callback_query:data")![0];
564
590
 
@@ -581,7 +607,7 @@ describe("App", () => {
581
607
 
582
608
  it("handles peek: callback by routing to orchestrator.handlePeek", async () => {
583
609
  const config = makeConfig();
584
- const app = new App(config);
610
+ const app = trackApp(new App(config));
585
611
  const bot = app.bot as any;
586
612
  const handler = bot.filterHandlers.get("callback_query:data")![0];
587
613
 
@@ -604,7 +630,7 @@ describe("App", () => {
604
630
 
605
631
  it("handles kill: callback by routing to orchestrator.handleKill", async () => {
606
632
  const config = makeConfig();
607
- const app = new App(config);
633
+ const app = trackApp(new App(config));
608
634
  const bot = app.bot as any;
609
635
  const handler = bot.filterHandlers.get("callback_query:data")![0];
610
636
 
@@ -627,7 +653,7 @@ describe("App", () => {
627
653
 
628
654
  it("ignores callback_query from unauthorized chats", async () => {
629
655
  const config = makeConfig();
630
- const app = new App(config);
656
+ const app = trackApp(new App(config));
631
657
  const bot = app.bot as any;
632
658
  const handler = bot.filterHandlers.get("callback_query:data")![0];
633
659
 
@@ -642,18 +668,18 @@ describe("App", () => {
642
668
  await new Promise((r) => setTimeout(r, 50));
643
669
 
644
670
  expect(ctx.answerCallbackQuery).toHaveBeenCalled();
645
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
671
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
646
672
  });
647
673
 
648
674
  it("skips outbound files that don't exist", async () => {
649
- const claude = mockClaude(() => resolvedQuery({
675
+ const claude = mockClaude(() => autoProcess({
650
676
  action: "send",
651
677
  message: "Done",
652
678
  actionReason: "ok",
653
679
  files: ["/tmp/nonexistent-xyz.png"],
654
680
  }));
655
681
  const config = makeConfig({ claude });
656
- const app = new App(config);
682
+ const app = trackApp(new App(config));
657
683
  const bot = app.bot as any;
658
684
  const handler = bot.filterHandlers.get("message:text")![0];
659
685
 
@@ -667,7 +693,7 @@ describe("App", () => {
667
693
 
668
694
  describe("commands", () => {
669
695
  it("/chatid replies with chat ID", () => {
670
- const app = new App(makeConfig());
696
+ const app = makeApp();
671
697
  const bot = app.bot as any;
672
698
  const handler = bot.commandHandlers.get("chatid")!;
673
699
  const ctx = { chat: { id: 12345 }, reply: mock(() => {}) };
@@ -678,7 +704,7 @@ describe("App", () => {
678
704
 
679
705
  it("/bg without prompt sends usage hint", async () => {
680
706
  const config = makeConfig();
681
- const app = new App(config);
707
+ const app = trackApp(new App(config));
682
708
  const bot = app.bot as any;
683
709
  const handler = bot.commandHandlers.get("bg")!;
684
710
  const ctx = { chat: { id: 12345 }, match: "" };
@@ -686,20 +712,23 @@ describe("App", () => {
686
712
  handler(ctx);
687
713
  await new Promise((r) => setTimeout(r, 50));
688
714
 
689
- expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
715
+ expect((config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] }).calls).toHaveLength(0);
690
716
  const calls = (bot.api.sendMessage as any).mock.calls;
691
717
  expect(calls[calls.length - 1][1]).toBe("Usage: /bg &lt;prompt&gt;");
692
718
  });
693
719
 
694
720
  it("/bg with prompt spawns a background agent via sendMessage", async () => {
695
- const claude = mockClaude((): RunningQuery<unknown> => ({
696
- sessionId: "bg-sid",
697
- startedAt: new Date(),
698
- result: new Promise(() => {}),
699
- kill: mock(async () => {}),
700
- }));
721
+ const claude = mockClaude((): ClaudeProcess<unknown> => {
722
+ return {
723
+ sessionId: "bg-sid",
724
+ startedAt: new Date(),
725
+ get state(): ProcessState { return "busy"; },
726
+ send: mock(async () => new Promise(() => {})),
727
+ kill: mock(async () => {}),
728
+ } as unknown as ClaudeProcess<unknown>;
729
+ });
701
730
  const config = makeConfig({ claude });
702
- const app = new App(config);
731
+ const app = trackApp(new App(config));
703
732
  const bot = app.bot as any;
704
733
  const handler = bot.commandHandlers.get("bg")!;
705
734
  const ctx = { chat: { id: 12345 }, match: "research pricing" };
@@ -714,7 +743,7 @@ describe("App", () => {
714
743
  });
715
744
 
716
745
  it("/sessions lists running sessions via sendMessage", async () => {
717
- const app = new App(makeConfig());
746
+ const app = makeApp();
718
747
  const bot = app.bot as any;
719
748
  const handler = bot.commandHandlers.get("sessions")!;
720
749
  const ctx = { chat: { id: 12345 } };
@@ -727,8 +756,34 @@ describe("App", () => {
727
756
  expect(text).toBe("No running sessions.");
728
757
  });
729
758
 
759
+ it("/clear sends confirmation", async () => {
760
+ const app = makeApp();
761
+ const bot = app.bot as any;
762
+ const handler = bot.commandHandlers.get("clear")!;
763
+ const ctx = { chat: { id: 12345 } };
764
+
765
+ handler(ctx);
766
+ await new Promise((r) => setTimeout(r, 50));
767
+
768
+ const calls = (bot.api.sendMessage as any).mock.calls;
769
+ const text = calls[calls.length - 1][1];
770
+ expect(text).toBe("Session cleared.");
771
+ });
772
+
773
+ it("/clear is ignored for unauthorized chats", async () => {
774
+ const app = makeApp();
775
+ const bot = app.bot as any;
776
+ const handler = bot.commandHandlers.get("clear")!;
777
+ const ctx = { chat: { id: 99999 } };
778
+
779
+ handler(ctx);
780
+ await new Promise((r) => setTimeout(r, 50));
781
+
782
+ expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
783
+ });
784
+
730
785
  it("/sessions is ignored for unauthorized chats", async () => {
731
- const app = new App(makeConfig());
786
+ const app = makeApp();
732
787
  const bot = app.bot as any;
733
788
  const handler = bot.commandHandlers.get("sessions")!;
734
789
  const ctx = { chat: { id: 99999 } };
@@ -745,7 +800,7 @@ describe("App", () => {
745
800
 
746
801
  describe("error handler", () => {
747
802
  it("does not throw on bot errors", () => {
748
- const app = new App(makeConfig());
803
+ const app = makeApp();
749
804
  const bot = app.bot as any;
750
805
 
751
806
  expect(() => bot.errorHandler({ message: "connection lost" })).not.toThrow();
@@ -754,12 +809,12 @@ describe("App", () => {
754
809
 
755
810
  describe("start", () => {
756
811
  it("starts the bot and logs info", () => {
757
- const app = new App(makeConfig());
812
+ const app = makeApp();
758
813
  expect(() => app.start()).not.toThrow();
759
814
  });
760
815
 
761
816
  it("registers commands with Telegram on start", () => {
762
- const app = new App(makeConfig());
817
+ const app = makeApp();
763
818
  const bot = app.bot as any;
764
819
 
765
820
  app.start();
@@ -767,6 +822,7 @@ describe("App", () => {
767
822
  { command: "chatid", description: "Show current chat ID" },
768
823
  { command: "bg", description: "Spawn a background agent" },
769
824
  { command: "sessions", description: "List running sessions" },
825
+ { command: "clear", description: "Clear session and start fresh" },
770
826
  ]);
771
827
  });
772
828
  });