macroclaw 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app.test.ts +159 -103
- package/src/app.ts +13 -0
- package/src/claude.integration-test.ts +65 -27
- package/src/claude.test.ts +369 -189
- package/src/claude.ts +171 -71
- package/src/orchestrator.test.ts +301 -249
- package/src/orchestrator.ts +157 -96
- package/workspace-template/.claude/skills/self-update/SKILL.md +1 -1
- package/workspace-template/.claude/skills/self-update/scripts/update.sh +3 -4
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,
|
|
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
|
|
76
|
+
function autoProcess(value: unknown, sessionId = "test-sid"): ClaudeProcess<unknown> {
|
|
73
77
|
return {
|
|
74
78
|
sessionId,
|
|
75
79
|
startedAt: new Date(),
|
|
76
|
-
|
|
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) =>
|
|
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((
|
|
91
|
-
|
|
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,
|
|
96
|
-
|
|
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,
|
|
101
|
-
|
|
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
|
-
|
|
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((
|
|
112
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
145
|
-
const app =
|
|
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 =
|
|
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
|
|
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(() =>
|
|
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(() =>
|
|
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
|
|
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(():
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(() =>
|
|
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(() =>
|
|
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
|
|
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(() =>
|
|
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 =
|
|
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 <prompt>");
|
|
692
718
|
});
|
|
693
719
|
|
|
694
720
|
it("/bg with prompt spawns a background agent via sendMessage", async () => {
|
|
695
|
-
const claude = mockClaude(():
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
});
|