macroclaw 0.8.0 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.8.0",
3
+ "version": "0.10.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/cli.ts",
21
- "dev": "bun run --watch src/cli.ts",
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 { Claude, ClaudeDeferredResult, ClaudeResult, ClaudeRunOptions } from "./claude";
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 successResult(output: unknown, sessionId = "test-session-id"): ClaudeResult {
77
- return { structuredOutput: output, sessionId, duration: "1.0s", cost: "$0.05" };
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 mockClaude(handler: (opts: ClaudeRunOptions) => Promise<ClaudeResult | ClaudeDeferredResult>): Claude {
81
- const claude = { run: mock(handler) };
82
- return claude as unknown as Claude;
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: mockClaude(async (opts: ClaudeRunOptions): Promise<ClaudeResult> =>
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 any;
139
- const opts = claude.run.mock.calls[0][0] as ClaudeRunOptions;
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 any).run).not.toHaveBeenCalled();
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 config = makeConfig({
157
- claude: mockClaude(async (): Promise<ClaudeResult> => successResult({ action: "send", message: "", actionReason: "empty" })),
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 config = makeConfig({
173
- claude: mockClaude(async (): Promise<ClaudeResult> => successResult({ action: "silent", actionReason: "no new results" })),
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 opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
195
- expect(opts.prompt).toBe("bg: research pricing");
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 ClaudeResponse", async () => {
199
- const { ClaudeProcessError } = await import("./claude");
200
- const config = makeConfig({
201
- claude: mockClaude(async (): Promise<ClaudeResult> => {
202
- throw new ClaudeProcessError(1, "spawn failed");
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
- expect((config.claude as any).run).toHaveBeenCalled();
243
- const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
244
- expect(opts.prompt).toContain("[File:");
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
- expect((config.claude as any).run).toHaveBeenCalled();
271
- const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
272
- expect(opts.prompt).toContain("[File:");
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
- expect((config.claude as any).run).toHaveBeenCalled();
291
- const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
292
- expect(opts.prompt).toContain("[File download failed: photo.jpg]");
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
- expect((config.claude as any).run).toHaveBeenCalled();
309
- const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
310
- expect(opts.prompt).toContain("[File download failed: huge.pdf]");
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
- // Should route transcribed text to orchestrator
338
- expect((config.claude as any).run).toHaveBeenCalled();
339
- const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
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 any).run).not.toHaveBeenCalled();
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 any).run).not.toHaveBeenCalled();
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 any).run).not.toHaveBeenCalled();
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 any).run).not.toHaveBeenCalled();
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 any).run).not.toHaveBeenCalled();
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 config = makeConfig({
450
- claude: mockClaude(async (): Promise<ClaudeResult> =>
451
- successResult({
452
- action: "send",
453
- message: "Here's your chart",
454
- actionReason: "ok",
455
- files: [tmpFile],
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 config = makeConfig({
475
- claude: mockClaude(async (): Promise<ClaudeResult> =>
476
- successResult({
477
- action: "send",
478
- message: "Choose one",
479
- actionReason: "ok",
480
- buttons: ["Yes", "No"],
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
- expect((config.claude as any).run).toHaveBeenCalled();
515
- const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
516
- expect(opts.prompt).toBe('[Context: button-click] User tapped "Yes"');
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 any).run).not.toHaveBeenCalled();
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 any).run).not.toHaveBeenCalled();
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 config = makeConfig({
586
- claude: mockClaude(async (): Promise<ClaudeResult> =>
587
- successResult({
588
- action: "send",
589
- message: "Done",
590
- actionReason: "ok",
591
- files: ["/tmp/nonexistent-xyz.png"],
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 config = makeConfig({
661
- claude: mockClaude(() => new Promise<ClaudeResult>(() => {})),
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((config.claude as any).run).toHaveBeenCalledTimes(1);
701
+ expect(claude.calls).toHaveLength(1);
675
702
  });
676
703
 
677
704
  it("/bg lists active background agents via sendMessage", async () => {
678
- const config = makeConfig({
679
- claude: mockClaude(() => new Promise<ClaudeResult>(() => {})),
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("generates new session ID when settings is empty", async () => {
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).toMatch(/Session: <code>[0-9a-f]{8}-/);
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.test.ts --timeout 120000
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 { randomUUID } from "node:crypto";
7
- import { Claude, type ClaudeResult, isDeferred } from "./claude";
6
+ import { z } from "zod/v4";
7
+ import { Claude } from "./claude";
8
8
 
9
9
  const WORKSPACE = "/tmp/macroclaw-integration-test";
10
- const SIMPLE_SCHEMA = JSON.stringify({
11
- type: "object",
12
- properties: {
13
- action: { type: "string", enum: ["send", "silent"] },
14
- actionReason: { type: "string" },
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 FULL_SCHEMA = JSON.stringify({
22
- type: "object",
23
- properties: {
24
- action: { type: "string", enum: ["send", "silent"], description: "'send' to reply to the user, 'silent' to do nothing" },
25
- actionReason: { type: "string", description: "Why the agent chose this action (logged, not sent)" },
26
- message: { type: "string", description: "The message to send to Telegram (required when action is 'send')" },
27
- files: { type: "array", items: { type: "string" }, description: "Absolute paths to files to send to Telegram" },
28
- backgroundAgents: {
29
- type: "array",
30
- items: {
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
- async function runSync(claude: Claude, ...args: Parameters<Claude["run"]>): Promise<ClaudeResult> {
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, jsonSchema: SIMPLE_SCHEMA });
55
- const result = await runSync(claude, {
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(result, null, 2));
63
- expect(result.structuredOutput).not.toBeNull();
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, jsonSchema: SIMPLE_SCHEMA });
68
- const result = await runSync(claude, {
69
- prompt: "Say hello",
70
- resume: false,
71
- sessionId: randomUUID(),
72
- model: "haiku",
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(result, null, 2));
77
- expect(result.structuredOutput).not.toBeNull();
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, jsonSchema: FULL_SCHEMA });
82
- const result = await runSync(claude, {
83
- prompt: "Say hello",
84
- resume: false,
85
- sessionId: randomUUID(),
86
- model: "haiku",
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(result, null, 2));
91
- expect(result.structuredOutput).not.toBeNull();
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, jsonSchema: FULL_SCHEMA });
97
- const result = await runSync(claude, {
98
- prompt: "Say hello",
99
- resume: false,
100
- sessionId: randomUUID(),
101
- model: "sonnet",
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(result, null, 2));
106
- expect(result.structuredOutput).not.toBeNull();
73
+ console.log("Full (real workspace):", JSON.stringify(value, null, 2));
74
+ expect(value).not.toBeNull();
107
75
  }, 120_000);
108
76
  });