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.
- package/package.json +1 -1
- package/src/app.test.ts +7 -7
- package/src/app.ts +2 -2
- package/src/claude.ts +1 -1
- package/src/index.ts +4 -0
- package/src/naming.test.ts +44 -0
- package/src/naming.ts +54 -0
- package/src/orchestrator.test.ts +162 -11
- package/src/orchestrator.ts +208 -35
- package/src/prompts.test.ts +268 -6
- package/src/prompts.ts +143 -10
- package/src/scheduler.test.ts +9 -6
- package/src/scheduler.ts +10 -3
package/src/orchestrator.ts
CHANGED
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
} from "./claude";
|
|
9
9
|
import { writeHistoryPrompt, writeHistoryResult } from "./history";
|
|
10
10
|
import { createLogger } from "./logger";
|
|
11
|
-
import {
|
|
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
|
|
121
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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 `
|
|
432
|
+
return `bg:${request.name}`;
|
|
433
|
+
case "background-agent-progress":
|
|
434
|
+
return `progress:${request.name}`;
|
|
356
435
|
case "button":
|
|
357
|
-
return `
|
|
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
|
|
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,
|
|
382
|
-
: this.#claude.newSession(
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
}
|
package/src/prompts.test.ts
CHANGED
|
@@ -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("
|
|
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
|
|
22
|
-
expect(SYSTEM_PROMPT).toContain("
|
|
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("
|
|
25
|
-
expect(SYSTEM_PROMPT).toContain("background-agent
|
|
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 & b < c > d "e"");
|
|
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 < b & c > 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 & "b""');
|
|
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 & "b"</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 & "stuff""');
|
|
304
|
+
});
|
|
305
|
+
});
|