macroclaw 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/app.test.ts +126 -97
- package/src/claude.integration-test.ts +47 -79
- package/src/claude.test.ts +263 -213
- package/src/claude.ts +129 -97
- package/src/history.test.ts +10 -10
- package/src/history.ts +2 -2
- package/src/main.ts +3 -0
- package/src/orchestrator.test.ts +320 -197
- package/src/orchestrator.ts +172 -237
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "macroclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Telegram-to-Claude-Code bridge",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"url": "https://github.com/macrosak/macroclaw"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"start": "bun run src/
|
|
21
|
-
"dev": "bun run --watch src/
|
|
20
|
+
"start": "bun run src/main.ts",
|
|
21
|
+
"dev": "bun run --watch src/main.ts",
|
|
22
22
|
"check": "tsc --noEmit && biome check && bun test",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"lint": "biome check",
|
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
|
|
4
|
+
import { type Claude, QueryProcessError, type QueryResult, type RunningQuery } from "./claude";
|
|
5
5
|
import { saveSessions } from "./sessions";
|
|
6
6
|
|
|
7
7
|
const mockOpenAICreate = mock(async () => ({ text: "transcribed text" }));
|
|
@@ -73,13 +73,52 @@ afterEach(() => {
|
|
|
73
73
|
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
function
|
|
77
|
-
return {
|
|
76
|
+
function queryResult<T>(value: T, sessionId = "test-session-id"): QueryResult<T> {
|
|
77
|
+
return { value, sessionId, duration: "1.0s", cost: "$0.05" };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
function resolvedQuery<T>(value: T, sessionId = "test-session-id"): RunningQuery<T> {
|
|
81
|
+
return {
|
|
82
|
+
sessionId,
|
|
83
|
+
startedAt: new Date(),
|
|
84
|
+
result: Promise.resolve(queryResult(value, sessionId)),
|
|
85
|
+
kill: mock(async () => {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface CallInfo {
|
|
90
|
+
method: string;
|
|
91
|
+
prompt: string;
|
|
92
|
+
sessionId?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mockClaude(handler: (info: CallInfo) => RunningQuery<unknown>): Claude & { calls: CallInfo[] } {
|
|
96
|
+
const calls: CallInfo[] = [];
|
|
97
|
+
const claude = {
|
|
98
|
+
newSession: mock((prompt: string, _resultType: unknown, _options?: any) => {
|
|
99
|
+
const info: CallInfo = { method: "newSession", prompt };
|
|
100
|
+
calls.push(info);
|
|
101
|
+
return handler(info);
|
|
102
|
+
}),
|
|
103
|
+
resumeSession: mock((sessionId: string, prompt: string, _resultType: unknown, _options?: any) => {
|
|
104
|
+
const info: CallInfo = { method: "resumeSession", sessionId, prompt };
|
|
105
|
+
calls.push(info);
|
|
106
|
+
return handler(info);
|
|
107
|
+
}),
|
|
108
|
+
forkSession: mock((sessionId: string, prompt: string, _resultType: unknown, _options?: any) => {
|
|
109
|
+
const info: CallInfo = { method: "forkSession", sessionId, prompt };
|
|
110
|
+
calls.push(info);
|
|
111
|
+
return handler(info);
|
|
112
|
+
}),
|
|
113
|
+
calls,
|
|
114
|
+
} as unknown as Claude & { calls: CallInfo[] };
|
|
115
|
+
return claude;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function defaultMockClaude(): Claude & { calls: CallInfo[] } {
|
|
119
|
+
return mockClaude((info) =>
|
|
120
|
+
resolvedQuery({ action: "send", message: `Response to: ${info.prompt}`, actionReason: "user message" }),
|
|
121
|
+
);
|
|
83
122
|
}
|
|
84
123
|
|
|
85
124
|
function makeConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|
@@ -88,9 +127,7 @@ function makeConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|
|
88
127
|
authorizedChatId: "12345",
|
|
89
128
|
workspace: "/tmp/macroclaw-test-workspace",
|
|
90
129
|
settingsDir: tmpSettingsDir,
|
|
91
|
-
claude:
|
|
92
|
-
successResult({ action: "send", message: `Response to: ${opts.prompt}`, actionReason: "user message" }),
|
|
93
|
-
),
|
|
130
|
+
claude: defaultMockClaude(),
|
|
94
131
|
...overrides,
|
|
95
132
|
};
|
|
96
133
|
}
|
|
@@ -135,9 +172,8 @@ describe("App", () => {
|
|
|
135
172
|
handler({ chat: { id: 12345 }, message: { text: "hello" } });
|
|
136
173
|
await new Promise((r) => setTimeout(r, 50));
|
|
137
174
|
|
|
138
|
-
const claude = config.claude as
|
|
139
|
-
|
|
140
|
-
expect(opts.prompt).toBe("hello");
|
|
175
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
176
|
+
expect(claude.calls[0].prompt).toBe("hello");
|
|
141
177
|
});
|
|
142
178
|
|
|
143
179
|
it("ignores messages from unauthorized chats", async () => {
|
|
@@ -149,13 +185,12 @@ describe("App", () => {
|
|
|
149
185
|
handler({ chat: { id: 99999 }, message: { text: "hello" } });
|
|
150
186
|
await new Promise((r) => setTimeout(r, 50));
|
|
151
187
|
|
|
152
|
-
expect((config.claude as
|
|
188
|
+
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
153
189
|
});
|
|
154
190
|
|
|
155
191
|
it("sends [No output] for empty claude response", async () => {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
});
|
|
192
|
+
const claude = mockClaude(() => resolvedQuery({ action: "send", message: "", actionReason: "empty" }));
|
|
193
|
+
const config = makeConfig({ claude });
|
|
159
194
|
const app = new App(config);
|
|
160
195
|
const bot = app.bot as any;
|
|
161
196
|
const handler = bot.filterHandlers.get("message:text")![0];
|
|
@@ -169,9 +204,8 @@ describe("App", () => {
|
|
|
169
204
|
});
|
|
170
205
|
|
|
171
206
|
it("skips sending when action is silent", async () => {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
});
|
|
207
|
+
const claude = mockClaude(() => resolvedQuery({ action: "silent", actionReason: "no new results" }));
|
|
208
|
+
const config = makeConfig({ claude });
|
|
175
209
|
const app = new App(config);
|
|
176
210
|
const bot = app.bot as any;
|
|
177
211
|
const handler = bot.filterHandlers.get("message:text")![0];
|
|
@@ -191,17 +225,18 @@ describe("App", () => {
|
|
|
191
225
|
handler({ chat: { id: 12345 }, message: { text: "bg: research pricing" } });
|
|
192
226
|
await new Promise((r) => setTimeout(r, 50));
|
|
193
227
|
|
|
194
|
-
const
|
|
195
|
-
expect(
|
|
228
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
229
|
+
expect(claude.calls[0].prompt).toBe("bg: research pricing");
|
|
196
230
|
});
|
|
197
231
|
|
|
198
|
-
it("sends error wrapped in
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}),
|
|
204
|
-
});
|
|
232
|
+
it("sends error wrapped in response", async () => {
|
|
233
|
+
const claude = mockClaude((): RunningQuery<unknown> => ({
|
|
234
|
+
sessionId: "err-sid",
|
|
235
|
+
startedAt: new Date(),
|
|
236
|
+
result: Promise.reject(new QueryProcessError(1, "spawn failed")),
|
|
237
|
+
kill: mock(async () => {}),
|
|
238
|
+
}));
|
|
239
|
+
const config = makeConfig({ claude });
|
|
205
240
|
const app = new App(config);
|
|
206
241
|
const bot = app.bot as any;
|
|
207
242
|
const handler = bot.filterHandlers.get("message:text")![0];
|
|
@@ -239,9 +274,9 @@ describe("App", () => {
|
|
|
239
274
|
await new Promise((r) => setTimeout(r, 50));
|
|
240
275
|
|
|
241
276
|
expect(bot.api.getFile).toHaveBeenCalledWith("large");
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
expect(
|
|
277
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
278
|
+
expect(claude.calls).toHaveLength(1);
|
|
279
|
+
expect(claude.calls[0].prompt).toContain("[File:");
|
|
245
280
|
|
|
246
281
|
globalThis.fetch = origFetch;
|
|
247
282
|
});
|
|
@@ -267,9 +302,9 @@ describe("App", () => {
|
|
|
267
302
|
await new Promise((r) => setTimeout(r, 50));
|
|
268
303
|
|
|
269
304
|
expect(bot.api.getFile).toHaveBeenCalledWith("doc-id");
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
expect(
|
|
305
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
306
|
+
expect(claude.calls).toHaveLength(1);
|
|
307
|
+
expect(claude.calls[0].prompt).toContain("[File:");
|
|
273
308
|
|
|
274
309
|
globalThis.fetch = origFetch;
|
|
275
310
|
});
|
|
@@ -287,9 +322,9 @@ describe("App", () => {
|
|
|
287
322
|
});
|
|
288
323
|
await new Promise((r) => setTimeout(r, 50));
|
|
289
324
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
expect(
|
|
325
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
326
|
+
expect(claude.calls).toHaveLength(1);
|
|
327
|
+
expect(claude.calls[0].prompt).toContain("[File download failed: photo.jpg]");
|
|
293
328
|
});
|
|
294
329
|
|
|
295
330
|
it("routes error message when document download fails", async () => {
|
|
@@ -305,9 +340,9 @@ describe("App", () => {
|
|
|
305
340
|
});
|
|
306
341
|
await new Promise((r) => setTimeout(r, 50));
|
|
307
342
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
expect(
|
|
343
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
344
|
+
expect(claude.calls).toHaveLength(1);
|
|
345
|
+
expect(claude.calls[0].prompt).toContain("[File download failed: huge.pdf]");
|
|
311
346
|
});
|
|
312
347
|
|
|
313
348
|
it("handles voice messages by transcribing and routing text to orchestrator", async () => {
|
|
@@ -328,16 +363,14 @@ describe("App", () => {
|
|
|
328
363
|
});
|
|
329
364
|
await new Promise((r) => setTimeout(r, 50));
|
|
330
365
|
|
|
331
|
-
// Should echo transcription to user
|
|
332
366
|
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
333
367
|
const echoCall = sendCalls.find((c: any) => c[1].includes("[Received audio]"));
|
|
334
368
|
expect(echoCall).toBeDefined();
|
|
335
369
|
expect(echoCall[1]).toContain("hello from voice");
|
|
336
370
|
|
|
337
|
-
|
|
338
|
-
expect(
|
|
339
|
-
|
|
340
|
-
expect(opts.prompt).toBe("hello from voice");
|
|
371
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
372
|
+
expect(claude.calls).toHaveLength(1);
|
|
373
|
+
expect(claude.calls[0].prompt).toBe("hello from voice");
|
|
341
374
|
|
|
342
375
|
globalThis.fetch = origFetch;
|
|
343
376
|
});
|
|
@@ -363,7 +396,7 @@ describe("App", () => {
|
|
|
363
396
|
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
364
397
|
const errorCall = sendCalls.find((c: any) => c[1].includes("[Failed to transcribe audio]"));
|
|
365
398
|
expect(errorCall).toBeDefined();
|
|
366
|
-
expect((config.claude as
|
|
399
|
+
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
367
400
|
|
|
368
401
|
globalThis.fetch = origFetch;
|
|
369
402
|
});
|
|
@@ -389,7 +422,7 @@ describe("App", () => {
|
|
|
389
422
|
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
390
423
|
const emptyCall = sendCalls.find((c: any) => c[1].includes("[Could not understand audio]"));
|
|
391
424
|
expect(emptyCall).toBeDefined();
|
|
392
|
-
expect((config.claude as
|
|
425
|
+
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
393
426
|
|
|
394
427
|
globalThis.fetch = origFetch;
|
|
395
428
|
});
|
|
@@ -406,7 +439,7 @@ describe("App", () => {
|
|
|
406
439
|
});
|
|
407
440
|
await new Promise((r) => setTimeout(r, 50));
|
|
408
441
|
|
|
409
|
-
expect((config.claude as
|
|
442
|
+
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
410
443
|
});
|
|
411
444
|
|
|
412
445
|
it("responds with unavailable message when OPENAI_API_KEY is not set", async () => {
|
|
@@ -424,7 +457,7 @@ describe("App", () => {
|
|
|
424
457
|
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
425
458
|
const call = sendCalls.find((c: any) => c[1].includes("OPENAI_API_KEY"));
|
|
426
459
|
expect(call).toBeDefined();
|
|
427
|
-
expect((config.claude as
|
|
460
|
+
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
428
461
|
});
|
|
429
462
|
|
|
430
463
|
it("ignores photo messages from unauthorized chats", async () => {
|
|
@@ -439,23 +472,20 @@ describe("App", () => {
|
|
|
439
472
|
});
|
|
440
473
|
await new Promise((r) => setTimeout(r, 50));
|
|
441
474
|
|
|
442
|
-
expect((config.claude as
|
|
475
|
+
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
443
476
|
});
|
|
444
477
|
|
|
445
478
|
it("sends outbound files before text message (onResponse delivery)", async () => {
|
|
446
479
|
const tmpFile = `/tmp/macroclaw-test-outbound-${Date.now()}.png`;
|
|
447
480
|
await Bun.write(tmpFile, "fake png");
|
|
448
481
|
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}),
|
|
457
|
-
),
|
|
458
|
-
});
|
|
482
|
+
const claude = mockClaude(() => resolvedQuery({
|
|
483
|
+
action: "send",
|
|
484
|
+
message: "Here's your chart",
|
|
485
|
+
actionReason: "ok",
|
|
486
|
+
files: [tmpFile],
|
|
487
|
+
}));
|
|
488
|
+
const config = makeConfig({ claude });
|
|
459
489
|
const app = new App(config);
|
|
460
490
|
const bot = app.bot as any;
|
|
461
491
|
const handler = bot.filterHandlers.get("message:text")![0];
|
|
@@ -471,16 +501,13 @@ describe("App", () => {
|
|
|
471
501
|
});
|
|
472
502
|
|
|
473
503
|
it("passes buttons to sendResponse (onResponse delivery)", async () => {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}),
|
|
482
|
-
),
|
|
483
|
-
});
|
|
504
|
+
const claude = mockClaude(() => resolvedQuery({
|
|
505
|
+
action: "send",
|
|
506
|
+
message: "Choose one",
|
|
507
|
+
actionReason: "ok",
|
|
508
|
+
buttons: ["Yes", "No"],
|
|
509
|
+
}));
|
|
510
|
+
const config = makeConfig({ claude });
|
|
484
511
|
const app = new App(config);
|
|
485
512
|
const bot = app.bot as any;
|
|
486
513
|
const handler = bot.filterHandlers.get("message:text")![0];
|
|
@@ -511,9 +538,9 @@ describe("App", () => {
|
|
|
511
538
|
|
|
512
539
|
expect(ctx.answerCallbackQuery).toHaveBeenCalled();
|
|
513
540
|
expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Yes", callback_data: "_noop" }]] } });
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
expect(
|
|
541
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
542
|
+
expect(claude.calls).toHaveLength(1);
|
|
543
|
+
expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
|
|
517
544
|
});
|
|
518
545
|
|
|
519
546
|
it("handles _dismiss callback by removing reply markup", async () => {
|
|
@@ -534,7 +561,7 @@ describe("App", () => {
|
|
|
534
561
|
|
|
535
562
|
expect(ctx.answerCallbackQuery).toHaveBeenCalled();
|
|
536
563
|
expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: undefined });
|
|
537
|
-
expect((config.claude as
|
|
564
|
+
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
538
565
|
});
|
|
539
566
|
|
|
540
567
|
it("handles peek: callback by routing to orchestrator.handlePeek", async () => {
|
|
@@ -555,7 +582,6 @@ describe("App", () => {
|
|
|
555
582
|
|
|
556
583
|
expect(ctx.answerCallbackQuery).toHaveBeenCalled();
|
|
557
584
|
expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Peeked", callback_data: "_noop" }]] } });
|
|
558
|
-
// handlePeek sends "Agent not found" since no active agents
|
|
559
585
|
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
560
586
|
const text = calls[calls.length - 1][1];
|
|
561
587
|
expect(text).toBe("Agent not found or already finished.");
|
|
@@ -578,20 +604,17 @@ describe("App", () => {
|
|
|
578
604
|
await new Promise((r) => setTimeout(r, 50));
|
|
579
605
|
|
|
580
606
|
expect(ctx.answerCallbackQuery).toHaveBeenCalled();
|
|
581
|
-
expect((config.claude as
|
|
607
|
+
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
582
608
|
});
|
|
583
609
|
|
|
584
610
|
it("skips outbound files that don't exist", async () => {
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}),
|
|
593
|
-
),
|
|
594
|
-
});
|
|
611
|
+
const claude = mockClaude(() => resolvedQuery({
|
|
612
|
+
action: "send",
|
|
613
|
+
message: "Done",
|
|
614
|
+
actionReason: "ok",
|
|
615
|
+
files: ["/tmp/nonexistent-xyz.png"],
|
|
616
|
+
}));
|
|
617
|
+
const config = makeConfig({ claude });
|
|
595
618
|
const app = new App(config);
|
|
596
619
|
const bot = app.bot as any;
|
|
597
620
|
const handler = bot.filterHandlers.get("message:text")![0];
|
|
@@ -657,9 +680,13 @@ describe("App", () => {
|
|
|
657
680
|
});
|
|
658
681
|
|
|
659
682
|
it("/bg with prompt spawns a background agent via sendMessage", async () => {
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
683
|
+
const claude = mockClaude((): RunningQuery<unknown> => ({
|
|
684
|
+
sessionId: "bg-sid",
|
|
685
|
+
startedAt: new Date(),
|
|
686
|
+
result: new Promise(() => {}),
|
|
687
|
+
kill: mock(async () => {}),
|
|
688
|
+
}));
|
|
689
|
+
const config = makeConfig({ claude });
|
|
663
690
|
const app = new App(config);
|
|
664
691
|
const bot = app.bot as any;
|
|
665
692
|
const handler = bot.commandHandlers.get("bg")!;
|
|
@@ -671,23 +698,25 @@ describe("App", () => {
|
|
|
671
698
|
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
672
699
|
const text = calls[calls.length - 1][1];
|
|
673
700
|
expect(text).toBe('Background agent "research-pricing" started.');
|
|
674
|
-
expect(
|
|
701
|
+
expect(claude.calls).toHaveLength(1);
|
|
675
702
|
});
|
|
676
703
|
|
|
677
704
|
it("/bg lists active background agents via sendMessage", async () => {
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
705
|
+
const claude = mockClaude((): RunningQuery<unknown> => ({
|
|
706
|
+
sessionId: `bg-${Date.now()}`,
|
|
707
|
+
startedAt: new Date(),
|
|
708
|
+
result: new Promise(() => {}),
|
|
709
|
+
kill: mock(async () => {}),
|
|
710
|
+
}));
|
|
711
|
+
const config = makeConfig({ claude });
|
|
681
712
|
const app = new App(config);
|
|
682
713
|
const bot = app.bot as any;
|
|
683
714
|
const bgHandler = bot.commandHandlers.get("bg")!;
|
|
684
715
|
|
|
685
|
-
// Spawn via /bg command
|
|
686
716
|
const spawnCtx = { chat: { id: 12345 }, match: "long task" };
|
|
687
717
|
bgHandler(spawnCtx);
|
|
688
718
|
await new Promise((r) => setTimeout(r, 10));
|
|
689
719
|
|
|
690
|
-
// List via /bg with no args
|
|
691
720
|
const listCtx = { chat: { id: 12345 }, match: "" };
|
|
692
721
|
bgHandler(listCtx);
|
|
693
722
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -698,7 +727,7 @@ describe("App", () => {
|
|
|
698
727
|
expect(lastText).toMatch(/\d+s/);
|
|
699
728
|
});
|
|
700
729
|
|
|
701
|
-
it("
|
|
730
|
+
it("shows 'none' when no session exists yet", async () => {
|
|
702
731
|
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
703
732
|
const app = new App(makeConfig());
|
|
704
733
|
const bot = app.bot as any;
|
|
@@ -710,7 +739,7 @@ describe("App", () => {
|
|
|
710
739
|
|
|
711
740
|
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
712
741
|
const text = calls[calls.length - 1][1];
|
|
713
|
-
expect(text).
|
|
742
|
+
expect(text).toBe("Session: <code>none</code>");
|
|
714
743
|
});
|
|
715
744
|
});
|
|
716
745
|
|
|
@@ -1,108 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Integration test for Claude CLI structured output.
|
|
3
|
-
* Run manually: bun test src/claude.integration
|
|
3
|
+
* Run manually: bun test src/claude.integration-test.ts --timeout 120000
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, it } from "bun:test";
|
|
6
|
-
import {
|
|
7
|
-
import { Claude
|
|
6
|
+
import { z } from "zod/v4";
|
|
7
|
+
import { Claude } from "./claude";
|
|
8
8
|
|
|
9
9
|
const WORKSPACE = "/tmp/macroclaw-integration-test";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
message: { type: "string" },
|
|
16
|
-
},
|
|
17
|
-
required: ["action", "actionReason"],
|
|
18
|
-
additionalProperties: false,
|
|
10
|
+
|
|
11
|
+
const simpleSchema = z.object({
|
|
12
|
+
action: z.enum(["send", "silent"]),
|
|
13
|
+
actionReason: z.string(),
|
|
14
|
+
message: z.string().optional(),
|
|
19
15
|
});
|
|
20
16
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
type: "object",
|
|
32
|
-
properties: {
|
|
33
|
-
name: { type: "string" },
|
|
34
|
-
prompt: { type: "string" },
|
|
35
|
-
model: { type: "string", enum: ["haiku", "sonnet", "opus"] },
|
|
36
|
-
},
|
|
37
|
-
required: ["name", "prompt"],
|
|
38
|
-
additionalProperties: false,
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
required: ["action", "actionReason"],
|
|
43
|
-
additionalProperties: false,
|
|
17
|
+
const fullSchema = z.object({
|
|
18
|
+
action: z.enum(["send", "silent"]),
|
|
19
|
+
actionReason: z.string(),
|
|
20
|
+
message: z.string().optional(),
|
|
21
|
+
files: z.array(z.string()).optional(),
|
|
22
|
+
backgroundAgents: z.array(z.object({
|
|
23
|
+
name: z.string(),
|
|
24
|
+
prompt: z.string(),
|
|
25
|
+
model: z.enum(["haiku", "sonnet", "opus"]).optional(),
|
|
26
|
+
})).optional(),
|
|
44
27
|
});
|
|
45
28
|
|
|
46
|
-
|
|
47
|
-
const result = await claude.run(...args);
|
|
48
|
-
if (isDeferred(result)) throw new Error("Expected sync result, got deferred");
|
|
49
|
-
return result;
|
|
50
|
-
}
|
|
29
|
+
const objectResultType = (schema: z.ZodType) => ({ type: "object" as const, schema });
|
|
51
30
|
|
|
52
31
|
describe("claude CLI structured output", () => {
|
|
53
32
|
it("simple schema without system prompt", async () => {
|
|
54
|
-
const claude = new Claude({ workspace: WORKSPACE
|
|
55
|
-
const
|
|
56
|
-
prompt: "Say hello",
|
|
57
|
-
resume: false,
|
|
58
|
-
sessionId: randomUUID(),
|
|
59
|
-
model: "haiku",
|
|
60
|
-
});
|
|
33
|
+
const claude = new Claude({ workspace: WORKSPACE });
|
|
34
|
+
const { value } = await claude.newSession("Say hello", objectResultType(simpleSchema), { model: "haiku" }).result;
|
|
61
35
|
|
|
62
|
-
console.log("Simple (no sysprompt):", JSON.stringify(
|
|
63
|
-
expect(
|
|
36
|
+
console.log("Simple (no sysprompt):", JSON.stringify(value, null, 2));
|
|
37
|
+
expect(value).not.toBeNull();
|
|
64
38
|
}, 60_000);
|
|
65
39
|
|
|
66
40
|
it("simple schema with system prompt", async () => {
|
|
67
|
-
const claude = new Claude({ workspace: WORKSPACE
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
systemPrompt: "You are a helpful assistant. This is a direct message from the user.",
|
|
74
|
-
});
|
|
41
|
+
const claude = new Claude({ workspace: WORKSPACE });
|
|
42
|
+
const { value } = await claude.newSession(
|
|
43
|
+
"Say hello",
|
|
44
|
+
objectResultType(simpleSchema),
|
|
45
|
+
{ model: "haiku", systemPrompt: "You are a helpful assistant. This is a direct message from the user." },
|
|
46
|
+
).result;
|
|
75
47
|
|
|
76
|
-
console.log("Simple (with sysprompt):", JSON.stringify(
|
|
77
|
-
expect(
|
|
48
|
+
console.log("Simple (with sysprompt):", JSON.stringify(value, null, 2));
|
|
49
|
+
expect(value).not.toBeNull();
|
|
78
50
|
}, 60_000);
|
|
79
51
|
|
|
80
52
|
it("full schema with system prompt", async () => {
|
|
81
|
-
const claude = new Claude({ workspace: WORKSPACE
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
systemPrompt: "You are a helpful assistant. This is a direct message from the user.",
|
|
88
|
-
});
|
|
53
|
+
const claude = new Claude({ workspace: WORKSPACE });
|
|
54
|
+
const { value } = await claude.newSession(
|
|
55
|
+
"Say hello",
|
|
56
|
+
objectResultType(fullSchema),
|
|
57
|
+
{ model: "haiku", systemPrompt: "You are a helpful assistant. This is a direct message from the user." },
|
|
58
|
+
).result;
|
|
89
59
|
|
|
90
|
-
console.log("Full (with sysprompt):", JSON.stringify(
|
|
91
|
-
expect(
|
|
60
|
+
console.log("Full (with sysprompt):", JSON.stringify(value, null, 2));
|
|
61
|
+
expect(value).not.toBeNull();
|
|
92
62
|
}, 60_000);
|
|
93
63
|
|
|
94
64
|
it("full schema with real system prompt and workspace", async () => {
|
|
95
65
|
const workspace = process.env.MACROCLAW_WORKSPACE ?? WORKSPACE;
|
|
96
|
-
const claude = new Claude({ workspace
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
systemPrompt: `You are an AI assistant running inside macroclaw. This is a direct message from the user.`,
|
|
103
|
-
});
|
|
66
|
+
const claude = new Claude({ workspace });
|
|
67
|
+
const { value } = await claude.newSession(
|
|
68
|
+
"Say hello",
|
|
69
|
+
objectResultType(fullSchema),
|
|
70
|
+
{ model: "sonnet", systemPrompt: "You are an AI assistant running inside macroclaw. This is a direct message from the user." },
|
|
71
|
+
).result;
|
|
104
72
|
|
|
105
|
-
console.log("Full (real workspace):", JSON.stringify(
|
|
106
|
-
expect(
|
|
73
|
+
console.log("Full (real workspace):", JSON.stringify(value, null, 2));
|
|
74
|
+
expect(value).not.toBeNull();
|
|
107
75
|
}, 120_000);
|
|
108
76
|
});
|