macroclaw 0.27.0 → 0.29.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.
@@ -8,7 +8,8 @@ import {
8
8
  } from "./claude";
9
9
  import { writeHistoryPrompt, writeHistoryResult } from "./history";
10
10
  import { createLogger } from "./logger";
11
- import { SYSTEM_PROMPT } from "./prompts";
11
+ import { generateName } from "./naming";
12
+ import { buildEvent, type EventInput, SYSTEM_PROMPT } from "./prompts";
12
13
  import { Queue } from "./queue";
13
14
  import { loadSessions, saveSessions } from "./sessions";
14
15
 
@@ -19,6 +20,8 @@ const log = createLogger("orchestrator");
19
20
  // --- Constants ---
20
21
 
21
22
  const WAIT_THRESHOLD = 60_000;
23
+ const HEALTH_CHECK_INTERVAL_MS = 5 * 60 * 1000;
24
+ const HEALTH_CHECK_TIMEOUT_MS = 120 * 1000;
22
25
 
23
26
  // --- Response schema ---
24
27
 
@@ -39,7 +42,14 @@ const agentOutputSchema = z.object({
39
42
 
40
43
  type AgentOutput = z.infer<typeof agentOutputSchema>;
41
44
 
45
+ const healthCheckSchema = z.object({
46
+ finished: z.boolean().describe("True if the task is complete, false if still working"),
47
+ output: agentOutputSchema.optional().describe("Full output when finished=true"),
48
+ progress: z.string().optional().describe("One-sentence status when finished=false"),
49
+ });
50
+
42
51
  const responseResultType = { type: "object" as const, schema: agentOutputSchema };
52
+ const healthCheckResultType = { type: "object" as const, schema: healthCheckSchema };
43
53
 
44
54
  const textResultType = { type: "text" } as const;
45
55
 
@@ -58,8 +68,8 @@ export interface OrchestratorResponse {
58
68
 
59
69
  type OrchestratorRequest =
60
70
  | { type: "user"; message: string; files?: string[] }
61
- | { type: "cron"; name: string; prompt: string; model?: string }
62
71
  | { type: "background-agent-result"; name: string; response: AgentOutput }
72
+ | { type: "background-agent-progress"; name: string; progress: string }
63
73
  | { type: "button"; label: string };
64
74
 
65
75
  function escapeHtml(text: string): string {
@@ -74,6 +84,7 @@ interface SessionInfo {
74
84
  model?: string;
75
85
  query: RunningQuery<AgentOutput>;
76
86
  lastMessageAt: Date;
87
+ healthCheckTimer?: Timer;
77
88
  }
78
89
 
79
90
  export interface OrchestratorConfig {
@@ -84,12 +95,18 @@ export interface OrchestratorConfig {
84
95
  claude?: Claude;
85
96
  /** How long to wait for a running main session before demoting it (ms). Default: 60000 */
86
97
  waitThreshold?: number;
98
+ /** Interval between background agent health checks (ms). Default: 300000. Set to 0 to disable. */
99
+ healthCheckInterval?: number;
100
+ /** Timeout for health check fork responses (ms). Default: 120000 */
101
+ healthCheckTimeout?: number;
87
102
  }
88
103
 
89
104
  export class Orchestrator {
90
105
  #config: Omit<OrchestratorConfig , 'claude'>;
91
106
  #claude: Claude;
92
107
  #waitThreshold: number;
108
+ #healthCheckInterval: number;
109
+ #healthCheckTimeout: number;
93
110
 
94
111
  #mainSessionId: string | undefined;
95
112
  #runningSessions = new Map<string, SessionInfo>();
@@ -99,6 +116,8 @@ export class Orchestrator {
99
116
  this.#config = config;
100
117
  this.#claude = config.claude ?? new Claude({ workspace: config.workspace, systemPrompt: SYSTEM_PROMPT });
101
118
  this.#waitThreshold = config.waitThreshold ?? WAIT_THRESHOLD;
119
+ this.#healthCheckInterval = config.healthCheckInterval ?? HEALTH_CHECK_INTERVAL_MS;
120
+ this.#healthCheckTimeout = config.healthCheckTimeout ?? HEALTH_CHECK_TIMEOUT_MS;
102
121
  this.#queue = new Queue<OrchestratorRequest>();
103
122
  this.#queue.setHandler((request) => this.#handleRequest(request));
104
123
 
@@ -115,14 +134,20 @@ export class Orchestrator {
115
134
  this.#queue.push({ type: "button", label });
116
135
  }
117
136
 
118
- handleCron(name: string, prompt: string, model?: string): void {
137
+ handleCron(name: string, prompt: string, model?: string, missed?: { missedBy: string; scheduledAt: string }): void {
119
138
  const cronName = `cron-${name}`;
120
- const cronPrompt = `[Context: cron/${name}] ${prompt}`;
121
- this.#spawnBackground(cronName, cronPrompt, model ?? this.#config.model);
139
+ const formatted = buildEvent({
140
+ name: cronName,
141
+ type: "schedule-trigger",
142
+ session: "background",
143
+ schedule: { name, missedBy: missed?.missedBy, scheduledAt: missed?.scheduledAt },
144
+ text: prompt,
145
+ });
146
+ this.#spawnBackgroundRaw(cronName, prompt, formatted, model ?? this.#config.model);
122
147
  }
123
148
 
124
149
  handleBackgroundCommand(prompt: string): void {
125
- const name = prompt.slice(0, 30).replace(/\s+/g, "-");
150
+ const name = generateName(prompt);
126
151
  this.#spawnBackground(name, prompt, this.#config.model);
127
152
  this.#callOnResponse({ message: `Background agent "${escapeHtml(name)}" started.` });
128
153
  }
@@ -184,10 +209,16 @@ export class Orchestrator {
184
209
  this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(session.name)}</b>...` });
185
210
 
186
211
  try {
187
- const startedAt = session.query.startedAt.toISOString();
212
+ const prompt = buildEvent({
213
+ name: `peek-${session.name}`,
214
+ type: "peek",
215
+ session: "background",
216
+ targetEvent: session.name,
217
+ instructions: `Only consider progress since the "${session.name}" event. Brief status update: done, in progress, remaining. 2-3 sentences max, plain text.`,
218
+ });
188
219
  const query = this.#claude.forkSession(
189
220
  sessionId,
190
- `This session started at ${startedAt}. Only consider events after that time. Give a brief status update: what has been done so far, what's currently happening, and what's remaining. 2-3 sentences max.`,
221
+ prompt,
191
222
  textResultType,
192
223
  { model: "haiku" },
193
224
  );
@@ -205,7 +236,7 @@ export class Orchestrator {
205
236
  return;
206
237
  }
207
238
 
208
- this.#runningSessions.delete(sessionId);
239
+ this.#clearSession(sessionId);
209
240
 
210
241
  try {
211
242
  await session.query.kill();
@@ -249,13 +280,12 @@ export class Orchestrator {
249
280
 
250
281
  await writeHistoryPrompt(request);
251
282
 
252
- let prompt = this.#formatPrompt(request);
253
- if (movedToBackground) {
254
- const truncated = movedToBackground.length > 100 ? `${movedToBackground.slice(0, 100)}...` : movedToBackground;
255
- prompt = `[Context: previous task "${truncated}" moved to background]\n${prompt}`;
256
- }
283
+ const label = Orchestrator.#requestLabel(request);
284
+ const name = generateName(label);
285
+ const backgroundedName = movedToBackground ? mainInfo?.name : undefined;
286
+ const prompt = this.#formatPrompt(request, name, backgroundedName);
257
287
 
258
- this.#startMainQuery(prompt, this.#config.model);
288
+ this.#startMainQuery(name, prompt, this.#config.model);
259
289
  }
260
290
 
261
291
  // --- Response delivery ---
@@ -289,7 +319,7 @@ export class Orchestrator {
289
319
 
290
320
  // --- Main session query ---
291
321
 
292
- #startMainQuery(prompt: string, model: string | undefined): void {
322
+ #startMainQuery(name: string, prompt: string, model: string | undefined): void {
293
323
  const opts = { model };
294
324
  let query: RunningQuery<AgentOutput>;
295
325
 
@@ -302,7 +332,6 @@ export class Orchestrator {
302
332
  }
303
333
 
304
334
  const sid = query.sessionId;
305
- const name = prompt.slice(0, 30).replace(/\s+/g, "-");
306
335
  this.#runningSessions.set(sid, { name, prompt, model, query, lastMessageAt: new Date() });
307
336
 
308
337
  if (sid !== this.#mainSessionId) {
@@ -320,7 +349,7 @@ export class Orchestrator {
320
349
  await this.#deliverResponse(response);
321
350
  return;
322
351
  }
323
- this.#runningSessions.delete(sid);
352
+ this.#clearSession(sid);
324
353
 
325
354
  if (sid === this.#mainSessionId) {
326
355
  log.debug({ name, sessionId: sid }, "Main query finished, delivering directly");
@@ -334,7 +363,7 @@ export class Orchestrator {
334
363
  if (!this.#runningSessions.has(sid)) {
335
364
  log.error({ name, sessionId: sid, err }, "Failed session not in runningSessions — delivering error");
336
365
  } else {
337
- this.#runningSessions.delete(sid);
366
+ this.#clearSession(sid);
338
367
  log.error({ name, sessionId: sid, err }, "Main query failed");
339
368
  }
340
369
  await this.#deliverResponse(this.#errorResponse(err));
@@ -342,19 +371,69 @@ export class Orchestrator {
342
371
  );
343
372
  }
344
373
 
345
- #formatPrompt(request: OrchestratorRequest): string {
374
+ #formatPrompt(request: OrchestratorRequest, name: string, backgroundedEvent?: string): string {
375
+ let input: EventInput;
376
+
346
377
  switch (request.type) {
347
- case "user": {
348
- if (!request.files?.length) return request.message;
349
- const prefix = request.files.map((f) => `[File: ${f}]`).join("\n");
350
- return request.message ? `${prefix}\n${request.message}` : prefix;
351
- }
352
- case "cron":
353
- return `[Context: cron/${request.name}] ${request.prompt}`;
378
+ case "user":
379
+ input = {
380
+ name,
381
+ type: "user-message",
382
+ session: "main",
383
+ text: request.message || undefined,
384
+ files: request.files,
385
+ backgroundedEvent,
386
+ };
387
+ break;
388
+ case "background-agent-result":
389
+ input = {
390
+ name,
391
+ type: "background-agent-result",
392
+ session: "main",
393
+ originalEvent: request.name,
394
+ result: {
395
+ text: request.response.message || "[No output]",
396
+ files: request.response.files,
397
+ },
398
+ backgroundedEvent,
399
+ instructions: "Forward this result to the user (action=\"send\"). Summarize or add context from the conversation as appropriate.",
400
+ };
401
+ break;
402
+ case "background-agent-progress":
403
+ input = {
404
+ name,
405
+ type: "background-agent-progress",
406
+ session: "main",
407
+ originalEvent: request.name,
408
+ progress: request.progress,
409
+ instructions: "This is an interim progress update, not a final result. Do not report to the user unless it contains exceptionally important information.",
410
+ backgroundedEvent,
411
+ };
412
+ break;
413
+ case "button":
414
+ input = {
415
+ name,
416
+ type: "button-click",
417
+ session: "main",
418
+ button: request.label,
419
+ backgroundedEvent,
420
+ };
421
+ break;
422
+ }
423
+
424
+ return buildEvent(input);
425
+ }
426
+
427
+ static #requestLabel(request: OrchestratorRequest): string {
428
+ switch (request.type) {
429
+ case "user":
430
+ return request.message;
354
431
  case "background-agent-result":
355
- return `[Context: background-result/${request.name}] ${request.response.message || "[No output]"}`;
432
+ return `bg:${request.name}`;
433
+ case "background-agent-progress":
434
+ return `progress:${request.name}`;
356
435
  case "button":
357
- return `[Context: button-click] User tapped "${request.label}"`;
436
+ return `btn:${request.label}`;
358
437
  }
359
438
  }
360
439
 
@@ -376,32 +455,126 @@ export class Orchestrator {
376
455
  // --- Background management ---
377
456
 
378
457
  #spawnBackground(name: string, prompt: string, model: string | undefined) {
379
- const bgPrompt = `[Context: background-agent/${name}] ${prompt}`;
458
+ const formatted = buildEvent({
459
+ name,
460
+ type: "background-agent-start",
461
+ session: "background",
462
+ text: prompt,
463
+ });
464
+ this.#spawnBackgroundRaw(name, prompt, formatted, model);
465
+ }
466
+
467
+ #spawnBackgroundRaw(name: string, prompt: string, formatted: string, model: string | undefined) {
380
468
  const query = this.#mainSessionId
381
- ? this.#claude.forkSession(this.#mainSessionId, bgPrompt, responseResultType, { model })
382
- : this.#claude.newSession(bgPrompt, responseResultType, { model });
469
+ ? this.#claude.forkSession(this.#mainSessionId, formatted, responseResultType, { model })
470
+ : this.#claude.newSession(formatted, responseResultType, { model });
383
471
  this.#registerBackground(name, prompt, model, query);
384
472
  }
385
473
 
386
474
  #registerBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
387
475
  const sid = query.sessionId;
388
- this.#runningSessions.set(sid, { name, prompt, model, query, lastMessageAt: new Date() });
476
+ const info: SessionInfo = { name, prompt, model, query, lastMessageAt: new Date() };
477
+ this.#runningSessions.set(sid, info);
389
478
 
390
479
  log.debug({ name, sessionId: sid }, "Background session registered");
391
480
 
481
+ this.#scheduleHealthCheck(sid);
482
+
392
483
  query.result.then(
393
484
  ({ value: response }) => {
394
485
  if (!this.#runningSessions.has(sid)) return;
395
- this.#runningSessions.delete(sid);
486
+ this.#clearSession(sid);
396
487
  log.debug({ name, message: response.message }, "Background session finished");
397
488
  this.#queue.push({ type: "background-agent-result", name, response });
398
489
  },
399
490
  (err) => {
400
491
  if (!this.#runningSessions.has(sid)) return;
401
- this.#runningSessions.delete(sid);
492
+ this.#clearSession(sid);
402
493
  log.error({ name, err }, "Background session failed");
403
494
  this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-failed" } });
404
495
  },
405
496
  );
406
497
  }
498
+
499
+ // --- Session cleanup ---
500
+
501
+ #clearSession(sessionId: string) {
502
+ const info = this.#runningSessions.get(sessionId);
503
+ if (info?.healthCheckTimer) clearTimeout(info.healthCheckTimer);
504
+ this.#runningSessions.delete(sessionId);
505
+ }
506
+
507
+ // --- Health checks ---
508
+
509
+ #scheduleHealthCheck(sessionId: string) {
510
+ if (this.#healthCheckInterval <= 0) return;
511
+
512
+ const info = this.#runningSessions.get(sessionId);
513
+ if (!info) return;
514
+
515
+ info.healthCheckTimer = setTimeout(() => {
516
+ this.#runHealthCheck(sessionId);
517
+ }, this.#healthCheckInterval);
518
+ }
519
+
520
+ async #runHealthCheck(sessionId: string): Promise<void> {
521
+ const info = this.#runningSessions.get(sessionId);
522
+ if (!info) return;
523
+
524
+ log.debug({ name: info.name, sessionId }, "Running health check");
525
+
526
+ const prompt = buildEvent({
527
+ name: `health-check-${info.name}`,
528
+ type: "health-check",
529
+ session: "background",
530
+ targetEvent: info.name,
531
+ instructions: "Report your current status. If your task is complete, set finished=true and provide the full output. If still working, set finished=false and describe current progress in one sentence.",
532
+ });
533
+
534
+ let query: RunningQuery<z.infer<typeof healthCheckSchema>>;
535
+ try {
536
+ query = this.#claude.forkSession(sessionId, prompt, healthCheckResultType, { model: "haiku" });
537
+ } catch (err) {
538
+ log.error({ name: info.name, sessionId, err }, "Health check fork failed");
539
+ this.#scheduleHealthCheck(sessionId);
540
+ return;
541
+ }
542
+
543
+ const result = await Promise.race([
544
+ query.result.then((r) => r.value),
545
+ new Promise<"timeout">((r) => setTimeout(() => r("timeout"), this.#healthCheckTimeout)),
546
+ ]);
547
+
548
+ // Session may have completed/been killed while health check was running
549
+ if (!this.#runningSessions.has(sessionId)) return;
550
+
551
+ if (result === "timeout") {
552
+ log.warn({ name: info.name, sessionId }, "Health check timed out, killing session");
553
+ try { await query.kill(); } catch { /* ignore */ }
554
+ this.#clearSession(sessionId);
555
+ try { await info.query.kill(); } catch { /* ignore */ }
556
+ this.#callOnResponse({ message: `Agent <b>${escapeHtml(info.name)}</b> appears unresponsive, killed it.` });
557
+ return;
558
+ }
559
+
560
+ if (result.finished) {
561
+ log.info({ name: info.name, sessionId }, "Health check: agent reports finished");
562
+ this.#clearSession(sessionId);
563
+ try { await info.query.kill(); } catch { /* ignore */ }
564
+ const response = result.output ?? { action: "send" as const, message: "[Agent finished but returned no output]", actionReason: "health-check-finished" };
565
+ this.#queue.push({ type: "background-agent-result", name: info.name, response });
566
+ return;
567
+ }
568
+
569
+ log.debug({ name: info.name, progress: result.progress }, "Health check: still running");
570
+ if (result.progress) {
571
+ this.#queue.push({
572
+ type: "background-agent-progress",
573
+ name: info.name,
574
+ progress: result.progress,
575
+ });
576
+ }
577
+
578
+ this.#scheduleHealthCheck(sessionId);
579
+ }
407
580
  }
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { SYSTEM_PROMPT } from "./prompts";
2
+ import { buildEvent, escapeXml, SYSTEM_PROMPT } from "./prompts";
3
3
 
4
4
  describe("SYSTEM_PROMPT", () => {
5
5
  it("contains key sections", () => {
6
6
  expect(SYSTEM_PROMPT).toContain("macroclaw");
7
7
  expect(SYSTEM_PROMPT).toContain("Structured output");
8
- expect(SYSTEM_PROMPT).toContain("Context tags");
8
+ expect(SYSTEM_PROMPT).toContain("Event format");
9
9
  expect(SYSTEM_PROMPT).toContain("Background agents");
10
10
  expect(SYSTEM_PROMPT).toContain("Cron");
11
11
  expect(SYSTEM_PROMPT).toContain("Buttons");
@@ -18,11 +18,19 @@ describe("SYSTEM_PROMPT", () => {
18
18
  expect(SYSTEM_PROMPT).toContain("<b>");
19
19
  });
20
20
 
21
- it("documents all context tag types", () => {
22
- expect(SYSTEM_PROMPT).toContain("cron/<name>");
21
+ it("documents all event types", () => {
22
+ expect(SYSTEM_PROMPT).toContain("user-message");
23
23
  expect(SYSTEM_PROMPT).toContain("button-click");
24
- expect(SYSTEM_PROMPT).toContain("background-result/<name>");
25
- expect(SYSTEM_PROMPT).toContain("background-agent/<name>");
24
+ expect(SYSTEM_PROMPT).toContain("schedule-trigger");
25
+ expect(SYSTEM_PROMPT).toContain("background-agent-start");
26
+ expect(SYSTEM_PROMPT).toContain("background-agent-result");
27
+ expect(SYSTEM_PROMPT).toContain("peek");
28
+ });
29
+
30
+ it("documents backgrounded events", () => {
31
+ expect(SYSTEM_PROMPT).toContain("backgrounded-event");
32
+ expect(SYSTEM_PROMPT).toContain("moved to background");
33
+ expect(SYSTEM_PROMPT).toContain("Do not re-execute");
26
34
  });
27
35
 
28
36
  it("contains structured output reinforcement", () => {
@@ -41,3 +49,257 @@ describe("SYSTEM_PROMPT", () => {
41
49
  expect(SYSTEM_PROMPT).toContain("opus");
42
50
  });
43
51
  });
52
+
53
+ describe("escapeXml", () => {
54
+ it("escapes &, <, >, \"", () => {
55
+ expect(escapeXml('a & b < c > d "e"')).toBe("a &amp; b &lt; c &gt; d &quot;e&quot;");
56
+ });
57
+
58
+ it("returns plain text unchanged", () => {
59
+ expect(escapeXml("hello world")).toBe("hello world");
60
+ });
61
+ });
62
+
63
+ describe("buildEvent", () => {
64
+ it("builds user message event", () => {
65
+ const result = buildEvent({
66
+ name: "check-logs",
67
+ type: "user-message",
68
+ session: "main",
69
+ text: "hello",
70
+ });
71
+ expect(result).toStartWith('<event name="check-logs" type="user-message" session="main">');
72
+ expect(result).toContain("<text>hello</text>");
73
+ expect(result).toEndWith("</event>");
74
+ });
75
+
76
+ it("builds user message with files", () => {
77
+ const result = buildEvent({
78
+ name: "analyze-photo",
79
+ type: "user-message",
80
+ session: "main",
81
+ text: "what's in this image?",
82
+ files: ["/tmp/photo.jpg", "/tmp/doc.pdf"],
83
+ });
84
+ expect(result).toContain("<text>what's in this image?</text>");
85
+ expect(result).toContain("<files>");
86
+ expect(result).toContain('<file path="/tmp/photo.jpg" />');
87
+ expect(result).toContain('<file path="/tmp/doc.pdf" />');
88
+ expect(result).toContain("</files>");
89
+ });
90
+
91
+ it("builds user message with files only (no text)", () => {
92
+ const result = buildEvent({
93
+ name: "task",
94
+ type: "user-message",
95
+ session: "main",
96
+ files: ["/tmp/photo.jpg"],
97
+ });
98
+ expect(result).not.toContain("<text>");
99
+ expect(result).toContain('<file path="/tmp/photo.jpg" />');
100
+ });
101
+
102
+ it("builds user message with backgrounded event", () => {
103
+ const result = buildEvent({
104
+ name: "check-logs",
105
+ type: "user-message",
106
+ session: "main",
107
+ backgroundedEvent: "deploy-cluster",
108
+ text: "check the logs",
109
+ });
110
+ expect(result).toContain('<backgrounded-event name="deploy-cluster" />');
111
+ expect(result).toContain("<text>check the logs</text>");
112
+ });
113
+
114
+ it("places backgrounded-event before text", () => {
115
+ const result = buildEvent({
116
+ name: "check-logs",
117
+ type: "user-message",
118
+ session: "main",
119
+ backgroundedEvent: "deploy",
120
+ text: "hello",
121
+ });
122
+ const bgIdx = result.indexOf("backgrounded-event");
123
+ const textIdx = result.indexOf("<text>");
124
+ expect(bgIdx).toBeLessThan(textIdx);
125
+ });
126
+
127
+ it("builds button click event", () => {
128
+ const result = buildEvent({
129
+ name: "btn-yes",
130
+ type: "button-click",
131
+ session: "main",
132
+ button: "Yes",
133
+ });
134
+ expect(result).toContain('type="button-click"');
135
+ expect(result).toContain("<button>Yes</button>");
136
+ expect(result).not.toContain("<text>");
137
+ });
138
+
139
+ it("builds button click with backgrounded event", () => {
140
+ const result = buildEvent({
141
+ name: "btn-yes",
142
+ type: "button-click",
143
+ session: "main",
144
+ button: "Yes",
145
+ backgroundedEvent: "deploy-cluster",
146
+ });
147
+ expect(result).toContain('<backgrounded-event name="deploy-cluster" />');
148
+ expect(result).toContain("<button>Yes</button>");
149
+ });
150
+
151
+ it("builds schedule trigger event", () => {
152
+ const result = buildEvent({
153
+ name: "cron-daily",
154
+ type: "schedule-trigger",
155
+ session: "background",
156
+ schedule: { name: "daily" },
157
+ text: "check updates",
158
+ });
159
+ expect(result).toContain('type="schedule-trigger"');
160
+ expect(result).toContain('session="background"');
161
+ expect(result).toContain('<schedule name="daily" />');
162
+ expect(result).toContain("<text>check updates</text>");
163
+ });
164
+
165
+ it("builds missed schedule trigger with attributes", () => {
166
+ const result = buildEvent({
167
+ name: "cron-reminder",
168
+ type: "schedule-trigger",
169
+ session: "background",
170
+ schedule: { name: "reminder", missedBy: "15m", scheduledAt: "2026-03-20T06:00:00Z" },
171
+ text: "buy milk",
172
+ });
173
+ expect(result).toContain('missed-by="15m"');
174
+ expect(result).toContain('scheduled-at="2026-03-20T06:00:00Z"');
175
+ expect(result).toContain("<text>buy milk</text>");
176
+ });
177
+
178
+ it("builds background agent start event", () => {
179
+ const result = buildEvent({
180
+ name: "research",
181
+ type: "background-agent-start",
182
+ session: "background",
183
+ text: "find papers about transformers",
184
+ });
185
+ expect(result).toContain('type="background-agent-start"');
186
+ expect(result).toContain('session="background"');
187
+ expect(result).toContain("<text>find papers about transformers</text>");
188
+ });
189
+
190
+ it("builds background agent result (text only)", () => {
191
+ const result = buildEvent({
192
+ name: "bg-research",
193
+ type: "background-agent-result",
194
+ session: "main",
195
+ originalEvent: "research",
196
+ result: { text: "found 3 papers" },
197
+ });
198
+ expect(result).toContain('type="background-agent-result"');
199
+ expect(result).toContain('<original-event name="research" />');
200
+ expect(result).toContain("<result>");
201
+ expect(result).toContain("<text>found 3 papers</text>");
202
+ expect(result).toContain("</result>");
203
+ expect(result).not.toContain("<files>");
204
+ });
205
+
206
+ it("builds background agent result with files", () => {
207
+ const result = buildEvent({
208
+ name: "bg-research",
209
+ type: "background-agent-result",
210
+ session: "main",
211
+ originalEvent: "research",
212
+ result: { text: "here are the screenshots", files: ["/tmp/screenshot.png"] },
213
+ });
214
+ expect(result).toContain("<result>");
215
+ expect(result).toContain("<text>here are the screenshots</text>");
216
+ expect(result).toContain('<file path="/tmp/screenshot.png" />');
217
+ expect(result).toContain("</result>");
218
+ });
219
+
220
+ it("builds peek event with instructions", () => {
221
+ const result = buildEvent({
222
+ name: "peek-deploy",
223
+ type: "peek",
224
+ session: "background",
225
+ targetEvent: "deploy",
226
+ instructions: "Brief status update.",
227
+ });
228
+ expect(result).toContain('type="peek"');
229
+ expect(result).toContain('<target-event name="deploy" />');
230
+ expect(result).toContain("<instructions>Brief status update.</instructions>");
231
+ expect(result).not.toContain("<text>");
232
+ });
233
+
234
+ it("builds progress event with progress tag", () => {
235
+ const result = buildEvent({
236
+ name: "progress-research",
237
+ type: "background-agent-progress",
238
+ session: "main",
239
+ originalEvent: "research",
240
+ progress: "indexing 500 documents",
241
+ });
242
+ expect(result).toContain('type="background-agent-progress"');
243
+ expect(result).toContain('<original-event name="research" />');
244
+ expect(result).toContain("<progress>indexing 500 documents</progress>");
245
+ expect(result).not.toContain("<result>");
246
+ });
247
+
248
+ it("includes instructions in event", () => {
249
+ const result = buildEvent({
250
+ name: "bg-research",
251
+ type: "background-agent-result",
252
+ session: "main",
253
+ originalEvent: "research",
254
+ result: { text: "done" },
255
+ instructions: "Forward to user.",
256
+ });
257
+ expect(result).toContain("<instructions>Forward to user.</instructions>");
258
+ // instructions come last, before </event>
259
+ const instrIdx = result.indexOf("<instructions>");
260
+ const closeIdx = result.indexOf("</event>");
261
+ expect(instrIdx).toBeLessThan(closeIdx);
262
+ expect(instrIdx).toBeGreaterThan(result.indexOf("</result>"));
263
+ });
264
+
265
+ it("escapes XML in text content", () => {
266
+ const result = buildEvent({
267
+ name: "test",
268
+ type: "user-message",
269
+ session: "main",
270
+ text: "a < b & c > d",
271
+ });
272
+ expect(result).toContain("<text>a &lt; b &amp; c &gt; d</text>");
273
+ });
274
+
275
+ it("escapes XML in name attribute", () => {
276
+ const result = buildEvent({
277
+ name: 'a & "b"',
278
+ type: "user-message",
279
+ session: "main",
280
+ text: "test",
281
+ });
282
+ expect(result).toContain('name="a &amp; &quot;b&quot;"');
283
+ });
284
+
285
+ it("escapes XML in button label", () => {
286
+ const result = buildEvent({
287
+ name: "btn",
288
+ type: "button-click",
289
+ session: "main",
290
+ button: 'a & "b"',
291
+ });
292
+ expect(result).toContain("<button>a &amp; &quot;b&quot;</button>");
293
+ });
294
+
295
+ it("escapes XML in backgrounded event name", () => {
296
+ const result = buildEvent({
297
+ name: "test",
298
+ type: "user-message",
299
+ session: "main",
300
+ backgroundedEvent: 'task & "stuff"',
301
+ text: "hello",
302
+ });
303
+ expect(result).toContain('backgrounded-event name="task &amp; &quot;stuff&quot;"');
304
+ });
305
+ });