@electric-ax/agents-server-conformance-tests 0.1.2

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/dist/index.cjs ADDED
@@ -0,0 +1,3750 @@
1
+ "use strict";
2
+ //#region rolldown:runtime
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+
24
+ //#endregion
25
+ const vitest = __toESM(require("vitest"));
26
+ const fast_check = __toESM(require("fast-check"));
27
+ const node_http = __toESM(require("node:http"));
28
+ const __electric_sql_client = __toESM(require("@electric-sql/client"));
29
+ const node_child_process = __toESM(require("node:child_process"));
30
+
31
+ //#region src/electric-agents-dsl.ts
32
+ async function fetchShapeRows(baseUrl, table) {
33
+ const stream = new __electric_sql_client.ShapeStream({
34
+ url: `${baseUrl}/_electric/electric/v1/shape`,
35
+ params: { table },
36
+ subscribe: false
37
+ });
38
+ const shape = new __electric_sql_client.Shape(stream);
39
+ const rows = await shape.rows;
40
+ return rows;
41
+ }
42
+ async function fetchEntitiesViaShape(baseUrl, filter) {
43
+ let entities = await fetchShapeRows(baseUrl, `entities`);
44
+ if (filter?.type) entities = entities.filter((e) => e.type === filter.type);
45
+ if (filter?.status) entities = entities.filter((e) => e.status === filter.status);
46
+ if (filter?.parent) entities = entities.filter((e) => e.parent === filter.parent);
47
+ return entities;
48
+ }
49
+ function toServerEntityTypeRegistration(registration) {
50
+ const body = {
51
+ name: registration.name,
52
+ description: registration.description,
53
+ ...registration.creation_schema && { creation_schema: registration.creation_schema },
54
+ ...registration.metadata_schema && { metadata_schema: registration.metadata_schema },
55
+ ...registration.serve_endpoint && { serve_endpoint: registration.serve_endpoint }
56
+ };
57
+ const inboxSchemas = registration.inbox_schemas ?? registration.input_schemas;
58
+ const stateSchemas = registration.state_schemas ?? registration.output_schemas;
59
+ if (inboxSchemas) body.inbox_schemas = inboxSchemas;
60
+ if (stateSchemas) body.state_schemas = stateSchemas;
61
+ return body;
62
+ }
63
+ function normalizeWebhookPayload(body) {
64
+ const parsed = JSON.parse(body);
65
+ return {
66
+ consumer_id: String(parsed.consumer_id ?? parsed.consumerId ?? ``),
67
+ epoch: Number(parsed.epoch ?? 0),
68
+ wake_id: String(parsed.wake_id ?? parsed.wakeId ?? ``),
69
+ primary_stream: String(parsed.primary_stream ?? parsed.streamPath ?? ``),
70
+ streams: Array.isArray(parsed.streams) ? parsed.streams : [],
71
+ triggered_by: Array.isArray(parsed.triggered_by) ? parsed.triggered_by : Array.isArray(parsed.triggeredBy) ? parsed.triggeredBy : [],
72
+ callback: String(parsed.callback ?? ``),
73
+ token: String(parsed.token ?? parsed.claimToken ?? ``),
74
+ entity: parsed.entity,
75
+ trigger_event: parsed.trigger_event ?? parsed.triggerEvent
76
+ };
77
+ }
78
+ var WebhookReceiver = class {
79
+ server = null;
80
+ _url = null;
81
+ notifications = [];
82
+ waitResolvers = [];
83
+ consumedCount = 0;
84
+ async start() {
85
+ return new Promise((resolve, reject) => {
86
+ this.server = (0, node_http.createServer)((req, res) => {
87
+ this.handleRequest(req, res);
88
+ });
89
+ this.server.on(`error`, reject);
90
+ this.server.listen(0, `127.0.0.1`, () => {
91
+ const addr = this.server.address();
92
+ if (typeof addr === `object` && addr) this._url = `http://127.0.0.1:${addr.port}`;
93
+ resolve(this._url);
94
+ });
95
+ });
96
+ }
97
+ async stop() {
98
+ if (!this.server) return;
99
+ return new Promise((resolve) => {
100
+ this.server.closeAllConnections();
101
+ this.server.close(() => {
102
+ this.server = null;
103
+ this._url = null;
104
+ resolve();
105
+ });
106
+ });
107
+ }
108
+ get url() {
109
+ if (!this._url) throw new Error(`WebhookReceiver not started`);
110
+ return this._url;
111
+ }
112
+ handleRequest(req, res) {
113
+ const chunks = [];
114
+ req.on(`data`, (chunk) => chunks.push(chunk));
115
+ req.on(`end`, () => {
116
+ const body = Buffer.concat(chunks).toString(`utf-8`);
117
+ try {
118
+ const parsed = normalizeWebhookPayload(body);
119
+ const notification = {
120
+ body,
121
+ parsed,
122
+ resolve: (response) => {
123
+ res.writeHead(response.status, { "content-type": `application/json` });
124
+ res.end(response.body);
125
+ }
126
+ };
127
+ this.notifications.push(notification);
128
+ for (const waiter of this.waitResolvers) waiter();
129
+ this.waitResolvers = [];
130
+ } catch {
131
+ res.writeHead(400);
132
+ res.end(`Invalid JSON`);
133
+ }
134
+ });
135
+ }
136
+ async waitForNotification(timeoutMs = 1e4) {
137
+ const targetIdx = this.consumedCount;
138
+ this.consumedCount++;
139
+ if (this.notifications.length > targetIdx) return this.notifications[targetIdx];
140
+ return new Promise((resolve, reject) => {
141
+ const timeout = setTimeout(() => {
142
+ reject(new Error(`Timed out waiting for webhook notification after ${timeoutMs}ms`));
143
+ }, timeoutMs);
144
+ const check = () => {
145
+ if (this.notifications.length > targetIdx) {
146
+ clearTimeout(timeout);
147
+ resolve(this.notifications[targetIdx]);
148
+ } else this.waitResolvers.push(check);
149
+ };
150
+ check();
151
+ });
152
+ }
153
+ async expectNoNotification(timeoutMs = 500) {
154
+ const startCount = this.notifications.length;
155
+ await new Promise((r) => setTimeout(r, timeoutMs));
156
+ (0, vitest.expect)(this.notifications.length).toBe(startCount);
157
+ }
158
+ get received() {
159
+ return this.notifications;
160
+ }
161
+ };
162
+ var ServeEndpointReceiver = class {
163
+ server = null;
164
+ _url = null;
165
+ manifest = null;
166
+ async start(manifest) {
167
+ this.manifest = manifest;
168
+ return new Promise((resolve, reject) => {
169
+ this.server = (0, node_http.createServer)((req, res) => {
170
+ this.handleRequest(req, res);
171
+ });
172
+ this.server.on(`error`, reject);
173
+ this.server.listen(0, `127.0.0.1`, () => {
174
+ const addr = this.server.address();
175
+ if (typeof addr === `object` && addr) this._url = `http://127.0.0.1:${addr.port}`;
176
+ resolve(this._url);
177
+ });
178
+ });
179
+ }
180
+ async stop() {
181
+ if (!this.server) return;
182
+ return new Promise((resolve) => {
183
+ this.server.closeAllConnections();
184
+ this.server.close(() => {
185
+ this.server = null;
186
+ this._url = null;
187
+ resolve();
188
+ });
189
+ });
190
+ }
191
+ get url() {
192
+ if (!this._url) throw new Error(`ServeEndpointReceiver not started`);
193
+ return this._url;
194
+ }
195
+ handleRequest(_req, res) {
196
+ res.writeHead(200, { "content-type": `application/json` });
197
+ res.end(JSON.stringify(this.manifest));
198
+ }
199
+ };
200
+ async function electricAgentsFetch$1(baseUrl, path, opts = {}) {
201
+ return fetch(`${baseUrl}${routeControlPlanePath$1(path)}`, {
202
+ ...opts,
203
+ headers: {
204
+ "content-type": `application/json`,
205
+ ...opts.headers
206
+ }
207
+ });
208
+ }
209
+ function routeControlPlanePath$1(path) {
210
+ const pathname = path.split(`?`, 1)[0];
211
+ if (pathname.startsWith(`/_electric/`) || isEntityStreamPath$1(pathname)) return path;
212
+ return `/_electric/entities${path}`;
213
+ }
214
+ function isEntityStreamPath$1(pathname) {
215
+ const segments = pathname.split(`/`).filter(Boolean);
216
+ const lastSegment = segments.at(-1);
217
+ return segments.length >= 3 && (lastSegment === `main` || lastSegment === `error`);
218
+ }
219
+ function subscriptionEndpoint$1(baseUrl, id) {
220
+ return `${baseUrl}/v1/stream-meta/subscriptions/${encodeURIComponent(id)}`;
221
+ }
222
+ function subscriptionPattern$1(pattern) {
223
+ return pattern.replace(/^\/+/, ``);
224
+ }
225
+ var ElectricAgentsScenario = class {
226
+ baseUrl;
227
+ steps = [];
228
+ _skipInvariants = false;
229
+ constructor(baseUrl) {
230
+ this.baseUrl = baseUrl;
231
+ }
232
+ subscription(pattern, id) {
233
+ this.steps.push({
234
+ kind: `subscription`,
235
+ pattern,
236
+ id
237
+ });
238
+ return this;
239
+ }
240
+ spawn(typeName, instanceId, opts) {
241
+ this.steps.push({
242
+ kind: `spawnTyped`,
243
+ typeName,
244
+ instanceId,
245
+ args: opts?.args,
246
+ tags: opts?.tags,
247
+ parent: opts?.parent,
248
+ initialMessage: opts?.initialMessage
249
+ });
250
+ return this;
251
+ }
252
+ send(payload, opts) {
253
+ this.steps.push({
254
+ kind: `send`,
255
+ payload,
256
+ from: opts.from,
257
+ type: opts.type
258
+ });
259
+ return this;
260
+ }
261
+ sendTo(url, payload, opts) {
262
+ this.steps.push({
263
+ kind: `sendTo`,
264
+ url,
265
+ payload,
266
+ from: opts.from
267
+ });
268
+ return this;
269
+ }
270
+ kill() {
271
+ this.steps.push({ kind: `kill` });
272
+ return this;
273
+ }
274
+ killUrl(url) {
275
+ this.steps.push({
276
+ kind: `killUrl`,
277
+ url
278
+ });
279
+ return this;
280
+ }
281
+ expectWebhook(opts) {
282
+ this.steps.push({
283
+ kind: `expectWebhook`,
284
+ opts
285
+ });
286
+ return this;
287
+ }
288
+ respondDone() {
289
+ this.steps.push({ kind: `respondDone` });
290
+ return this;
291
+ }
292
+ expectEntityContext(checks) {
293
+ this.steps.push({
294
+ kind: `expectEntityContext`,
295
+ checks
296
+ });
297
+ return this;
298
+ }
299
+ expectStatus(status) {
300
+ this.steps.push({
301
+ kind: `expectStatus`,
302
+ status
303
+ });
304
+ return this;
305
+ }
306
+ expectStreamContains(messageType) {
307
+ this.steps.push({
308
+ kind: `expectStreamContains`,
309
+ messageType
310
+ });
311
+ return this;
312
+ }
313
+ readStream(stream) {
314
+ this.steps.push({
315
+ kind: `readStream`,
316
+ stream
317
+ });
318
+ return this;
319
+ }
320
+ list(filter) {
321
+ this.steps.push({
322
+ kind: `list`,
323
+ filter
324
+ });
325
+ return this;
326
+ }
327
+ expectListCount(opts) {
328
+ this.steps.push({
329
+ kind: `expectListCount`,
330
+ ...opts
331
+ });
332
+ return this;
333
+ }
334
+ expectListTotal(total) {
335
+ this.steps.push({
336
+ kind: `expectListTotal`,
337
+ total
338
+ });
339
+ return this;
340
+ }
341
+ registerType(registration) {
342
+ this.steps.push({
343
+ kind: `registerType`,
344
+ registration
345
+ });
346
+ return this;
347
+ }
348
+ expectTypeExists(name) {
349
+ this.steps.push({
350
+ kind: `expectTypeExists`,
351
+ name
352
+ });
353
+ return this;
354
+ }
355
+ inspectType(name) {
356
+ this.steps.push({
357
+ kind: `inspectType`,
358
+ name
359
+ });
360
+ return this;
361
+ }
362
+ deleteType(name) {
363
+ this.steps.push({
364
+ kind: `deleteType`,
365
+ name
366
+ });
367
+ return this;
368
+ }
369
+ expectTypeNotExists(name) {
370
+ this.steps.push({
371
+ kind: `expectTypeNotExists`,
372
+ name
373
+ });
374
+ return this;
375
+ }
376
+ amendSchemas(name, schemas) {
377
+ this.steps.push({
378
+ kind: `amendSchemas`,
379
+ name,
380
+ input_schemas: schemas.input_schemas,
381
+ output_schemas: schemas.output_schemas
382
+ });
383
+ return this;
384
+ }
385
+ listTypes() {
386
+ this.steps.push({ kind: `listTypes` });
387
+ return this;
388
+ }
389
+ registerTypeViaServe(registration) {
390
+ this.steps.push({
391
+ kind: `registerTypeViaServe`,
392
+ registration
393
+ });
394
+ return this;
395
+ }
396
+ write(payload, opts) {
397
+ this.steps.push({
398
+ kind: `write`,
399
+ payload,
400
+ eventType: opts?.type
401
+ });
402
+ return this;
403
+ }
404
+ writeStateProtocol(event) {
405
+ this.steps.push({
406
+ kind: `writeStateProtocol`,
407
+ event
408
+ });
409
+ return this;
410
+ }
411
+ expectStreamEvent(type, key, operation, valueCheck) {
412
+ this.steps.push({
413
+ kind: `expectStreamEvent`,
414
+ type,
415
+ key,
416
+ operation,
417
+ valueCheck
418
+ });
419
+ return this;
420
+ }
421
+ expectStreamEventCount(type, count) {
422
+ this.steps.push({
423
+ kind: `expectStreamEventCount`,
424
+ type,
425
+ count
426
+ });
427
+ return this;
428
+ }
429
+ setTags(tags) {
430
+ this.steps.push({
431
+ kind: `setTags`,
432
+ tags
433
+ });
434
+ return this;
435
+ }
436
+ expectTags(expected) {
437
+ this.steps.push({
438
+ kind: `expectTags`,
439
+ tags: expected
440
+ });
441
+ return this;
442
+ }
443
+ expectSpawnSchemaError(typeName, instanceId, opts) {
444
+ this.steps.push({
445
+ kind: `expectSpawnSchemaError`,
446
+ typeName,
447
+ instanceId,
448
+ args: opts?.args,
449
+ code: `SCHEMA_VALIDATION_ERROR`,
450
+ status: 422
451
+ });
452
+ return this;
453
+ }
454
+ expectSendSchemaError(payload, opts) {
455
+ this.steps.push({
456
+ kind: `expectSendSchemaError`,
457
+ payload,
458
+ messageType: opts.type ?? `default`,
459
+ code: `SCHEMA_VALIDATION_ERROR`,
460
+ status: 422
461
+ });
462
+ return this;
463
+ }
464
+ expectWriteSchemaError(payload, opts) {
465
+ this.steps.push({
466
+ kind: `expectWriteSchemaError`,
467
+ payload,
468
+ eventType: opts?.type ?? `default`,
469
+ code: `SCHEMA_VALIDATION_ERROR`,
470
+ status: 422
471
+ });
472
+ return this;
473
+ }
474
+ expectSendUnknownType(payload, opts) {
475
+ this.steps.push({
476
+ kind: `expectSendUnknownType`,
477
+ payload,
478
+ messageType: opts.type,
479
+ code: `UNKNOWN_MESSAGE_TYPE`,
480
+ status: 422
481
+ });
482
+ return this;
483
+ }
484
+ expectWriteUnknownType(payload, opts) {
485
+ this.steps.push({
486
+ kind: `expectWriteUnknownType`,
487
+ payload,
488
+ eventType: opts.type,
489
+ code: `UNKNOWN_EVENT_TYPE`,
490
+ status: 422
491
+ });
492
+ return this;
493
+ }
494
+ expectEntityPersisted() {
495
+ this.steps.push({ kind: `expectEntityPersisted` });
496
+ return this;
497
+ }
498
+ expectSpawnError(url, code, status) {
499
+ this.steps.push({
500
+ kind: `expectSpawnError`,
501
+ url,
502
+ code,
503
+ status
504
+ });
505
+ return this;
506
+ }
507
+ expectSendError(code, status) {
508
+ this.steps.push({
509
+ kind: `expectSendError`,
510
+ code,
511
+ status
512
+ });
513
+ return this;
514
+ }
515
+ custom(fn) {
516
+ this.steps.push({
517
+ kind: `custom`,
518
+ fn
519
+ });
520
+ return this;
521
+ }
522
+ wait(ms) {
523
+ this.steps.push({
524
+ kind: `wait`,
525
+ ms
526
+ });
527
+ return this;
528
+ }
529
+ skipInvariants() {
530
+ this._skipInvariants = true;
531
+ return this;
532
+ }
533
+ async run() {
534
+ const receiver = new WebhookReceiver();
535
+ await receiver.start();
536
+ const ctx = {
537
+ baseUrl: this.baseUrl,
538
+ receiver,
539
+ history: [],
540
+ subscriptions: [],
541
+ currentEntityUrl: null,
542
+ currentEntityStreams: null,
543
+ currentWriteToken: null,
544
+ notification: null,
545
+ lastListResult: null,
546
+ lastListTotal: null,
547
+ lastStreamMessages: null,
548
+ currentEntityType: null,
549
+ lastTypeResult: null,
550
+ lastTypeListResult: null,
551
+ serveReceiver: null
552
+ };
553
+ try {
554
+ for (const step of this.steps) await executeStep(ctx, step);
555
+ if (!this._skipInvariants) checkInvariants(ctx.history);
556
+ return ctx.history;
557
+ } finally {
558
+ for (const subscription of ctx.subscriptions) try {
559
+ await fetch(subscriptionEndpoint$1(ctx.baseUrl, subscription.id), { method: `DELETE` });
560
+ } catch {}
561
+ for (const n of receiver.received) try {
562
+ n.resolve({
563
+ status: 200,
564
+ body: JSON.stringify({ done: true })
565
+ });
566
+ } catch {}
567
+ await receiver.stop();
568
+ if (ctx.serveReceiver) await ctx.serveReceiver.stop();
569
+ }
570
+ }
571
+ };
572
+ async function executeStep(ctx, step) {
573
+ switch (step.kind) {
574
+ case `subscription`: {
575
+ const res = await fetch(subscriptionEndpoint$1(ctx.baseUrl, step.id), {
576
+ method: `PUT`,
577
+ headers: { "content-type": `application/json` },
578
+ body: JSON.stringify({
579
+ type: `webhook`,
580
+ pattern: subscriptionPattern$1(step.pattern),
581
+ webhook: { url: ctx.receiver.url }
582
+ })
583
+ });
584
+ (0, vitest.expect)(res.status).toBeLessThan(300);
585
+ ctx.subscriptions.push({
586
+ pattern: step.pattern,
587
+ id: step.id
588
+ });
589
+ ctx.history.push({
590
+ type: `subscription_created`,
591
+ pattern: step.pattern,
592
+ subscriptionId: step.id,
593
+ webhookUrl: ctx.receiver.url
594
+ });
595
+ break;
596
+ }
597
+ case `spawn`: throw new Error(`The old 'spawn' step is deprecated. Use 'spawnTyped' instead.`);
598
+ case `send`:
599
+ case `sendTo`: {
600
+ let entityUrl = ctx.currentEntityUrl;
601
+ if (step.kind === `sendTo`) entityUrl = step.url;
602
+ if (!entityUrl) throw new Error(`No current entity — did you spawn first?`);
603
+ const body = { payload: step.payload };
604
+ if (step.from) body.from = step.from;
605
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${entityUrl}/send`, {
606
+ method: `POST`,
607
+ body: JSON.stringify(body)
608
+ });
609
+ (0, vitest.expect)(res.status).toBe(204);
610
+ ctx.history.push({
611
+ type: `message_sent`,
612
+ entityUrl,
613
+ payload: step.payload,
614
+ from: step.from
615
+ });
616
+ break;
617
+ }
618
+ case `expectWebhook`: {
619
+ const timeoutMs = step.opts?.timeoutMs ?? 1e4;
620
+ const notification = await ctx.receiver.waitForNotification(timeoutMs);
621
+ (0, vitest.expect)(notification.parsed.consumer_id).toBeDefined();
622
+ (0, vitest.expect)(notification.parsed.epoch).toBeGreaterThan(0);
623
+ (0, vitest.expect)(notification.parsed.wake_id).toBeDefined();
624
+ ctx.notification = notification;
625
+ ctx.history.push({
626
+ type: `webhook_received`,
627
+ consumer_id: notification.parsed.consumer_id,
628
+ epoch: notification.parsed.epoch,
629
+ wake_id: notification.parsed.wake_id,
630
+ entity: notification.parsed.entity,
631
+ trigger_event: notification.parsed.trigger_event
632
+ });
633
+ break;
634
+ }
635
+ case `respondDone`: {
636
+ if (!ctx.notification) throw new Error(`No notification to respond to`);
637
+ ctx.notification.resolve({
638
+ status: 200,
639
+ body: JSON.stringify({ done: true })
640
+ });
641
+ ctx.history.push({
642
+ type: `webhook_responded`,
643
+ status: 200,
644
+ body: { done: true }
645
+ });
646
+ ctx.notification = null;
647
+ await new Promise((r) => setTimeout(r, 100));
648
+ break;
649
+ }
650
+ case `expectEntityContext`: {
651
+ if (!ctx.notification) throw new Error(`No notification — did you expectWebhook first?`);
652
+ const entity = ctx.notification.parsed.entity;
653
+ (0, vitest.expect)(entity, `Webhook payload must contain entity context`).toBeDefined();
654
+ (0, vitest.expect)(entity.url).toBeTruthy();
655
+ (0, vitest.expect)(entity.streams.main).toBeTruthy();
656
+ (0, vitest.expect)(entity.streams.error).toBeTruthy();
657
+ if (step.checks?.url) (0, vitest.expect)(entity.url).toBe(step.checks.url);
658
+ if (step.checks?.type) (0, vitest.expect)(entity.type).toBe(step.checks.type);
659
+ if (step.checks?.status) (0, vitest.expect)(entity.status).toBe(step.checks.status);
660
+ break;
661
+ }
662
+ case `expectStatus`: {
663
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
664
+ const res = await electricAgentsFetch$1(ctx.baseUrl, ctx.currentEntityUrl);
665
+ (0, vitest.expect)(res.status).toBe(200);
666
+ const entity = await res.json();
667
+ if (step.status === `running`) (0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
668
+ else (0, vitest.expect)(entity.status).toBe(step.status);
669
+ ctx.history.push({
670
+ type: `entity_status_checked`,
671
+ entityUrl: ctx.currentEntityUrl,
672
+ status: entity.status
673
+ });
674
+ break;
675
+ }
676
+ case `kill`:
677
+ case `killUrl`: {
678
+ let entityUrl = ctx.currentEntityUrl;
679
+ if (step.kind === `killUrl`) entityUrl = step.url;
680
+ if (!entityUrl) throw new Error(`No current entity — did you spawn first?`);
681
+ const res = await electricAgentsFetch$1(ctx.baseUrl, entityUrl, { method: `DELETE` });
682
+ (0, vitest.expect)(res.status).toBe(200);
683
+ ctx.history.push({
684
+ type: `entity_killed`,
685
+ entityUrl
686
+ });
687
+ break;
688
+ }
689
+ case `readStream`: {
690
+ if (!ctx.currentEntityStreams) throw new Error(`No current entity streams`);
691
+ const streamPath = step.stream === `error` ? ctx.currentEntityStreams.error : ctx.currentEntityStreams.main;
692
+ const res = await fetch(`${ctx.baseUrl}${streamPath}?offset=0000000000000000_0000000000000000`);
693
+ if (res.status === 200) {
694
+ const text = await res.text();
695
+ const messages = text ? JSON.parse(text) : [];
696
+ ctx.lastStreamMessages = Array.isArray(messages) ? messages : [messages];
697
+ ctx.history.push({
698
+ type: `stream_read`,
699
+ path: streamPath,
700
+ messageCount: ctx.lastStreamMessages.length
701
+ });
702
+ } else {
703
+ ctx.lastStreamMessages = [];
704
+ ctx.history.push({
705
+ type: `stream_read`,
706
+ path: streamPath,
707
+ messageCount: 0
708
+ });
709
+ }
710
+ break;
711
+ }
712
+ case `expectStreamContains`: {
713
+ if (!ctx.lastStreamMessages) throw new Error(`No stream messages — did you readStream first?`);
714
+ const found = ctx.lastStreamMessages.some((m) => m.type === step.messageType);
715
+ (0, vitest.expect)(found, `Stream should contain message of type '${step.messageType}'`).toBe(true);
716
+ break;
717
+ }
718
+ case `list`: {
719
+ const entities = await fetchEntitiesViaShape(ctx.baseUrl, step.filter);
720
+ let result = entities;
721
+ if (step.filter?.offset !== void 0) result = result.slice(step.filter.offset);
722
+ if (step.filter?.limit !== void 0) result = result.slice(0, step.filter.limit);
723
+ ctx.lastListResult = result;
724
+ ctx.lastListTotal = entities.length;
725
+ ctx.history.push({
726
+ type: `entity_list_fetched`,
727
+ count: result.length,
728
+ filter: step.filter
729
+ });
730
+ break;
731
+ }
732
+ case `expectListCount`: {
733
+ if (!ctx.lastListResult) throw new Error(`No list result — did you list() first?`);
734
+ if (step.exact !== void 0) (0, vitest.expect)(ctx.lastListResult.length).toBe(step.exact);
735
+ if (step.min !== void 0) (0, vitest.expect)(ctx.lastListResult.length).toBeGreaterThanOrEqual(step.min);
736
+ if (step.max !== void 0) (0, vitest.expect)(ctx.lastListResult.length).toBeLessThanOrEqual(step.max);
737
+ break;
738
+ }
739
+ case `expectSpawnError`: {
740
+ const res = await electricAgentsFetch$1(ctx.baseUrl, step.url, {
741
+ method: `PUT`,
742
+ body: JSON.stringify({})
743
+ });
744
+ (0, vitest.expect)(res.status).toBe(step.status);
745
+ const body = await res.json();
746
+ (0, vitest.expect)(body.error.code).toBe(step.code);
747
+ ctx.history.push({
748
+ type: `spawn_rejected`,
749
+ url: step.url,
750
+ status: step.status,
751
+ code: step.code
752
+ });
753
+ break;
754
+ }
755
+ case `expectSendError`: {
756
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
757
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
758
+ method: `POST`,
759
+ body: JSON.stringify({
760
+ from: `test`,
761
+ payload: { should: `fail` }
762
+ })
763
+ });
764
+ (0, vitest.expect)(res.status).toBe(step.status);
765
+ const body = await res.json();
766
+ (0, vitest.expect)(body.error.code).toBe(step.code);
767
+ ctx.history.push({
768
+ type: `send_rejected`,
769
+ entityUrl: ctx.currentEntityUrl,
770
+ status: step.status,
771
+ code: step.code
772
+ });
773
+ break;
774
+ }
775
+ case `expectListTotal`: {
776
+ if (ctx.lastListTotal === null) throw new Error(`No list total — did you list() first?`);
777
+ (0, vitest.expect)(ctx.lastListTotal).toBe(step.total);
778
+ break;
779
+ }
780
+ case `wait`: {
781
+ await new Promise((r) => setTimeout(r, step.ms));
782
+ break;
783
+ }
784
+ case `custom`: {
785
+ await step.fn(ctx);
786
+ break;
787
+ }
788
+ case `registerType`: {
789
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types`, {
790
+ method: `POST`,
791
+ body: JSON.stringify(toServerEntityTypeRegistration(step.registration))
792
+ });
793
+ (0, vitest.expect)(res.status).toBe(201);
794
+ const body = await res.json();
795
+ ctx.currentEntityType = step.registration.name;
796
+ ctx.history.push({
797
+ type: `entity_type_registered`,
798
+ name: step.registration.name,
799
+ revision: body.revision
800
+ });
801
+ break;
802
+ }
803
+ case `expectTypeExists`: {
804
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}`);
805
+ (0, vitest.expect)(res.status).toBe(200);
806
+ break;
807
+ }
808
+ case `inspectType`: {
809
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}`);
810
+ (0, vitest.expect)(res.status).toBe(200);
811
+ const body = await res.json();
812
+ ctx.lastTypeResult = body;
813
+ ctx.history.push({
814
+ type: `entity_type_inspected`,
815
+ name: step.name,
816
+ revision: body.revision
817
+ });
818
+ break;
819
+ }
820
+ case `deleteType`: {
821
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}`, { method: `DELETE` });
822
+ (0, vitest.expect)([200, 204]).toContain(res.status);
823
+ ctx.history.push({
824
+ type: `entity_type_deleted`,
825
+ name: step.name
826
+ });
827
+ break;
828
+ }
829
+ case `expectTypeNotExists`: {
830
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}`);
831
+ (0, vitest.expect)(res.status).toBe(404);
832
+ break;
833
+ }
834
+ case `amendSchemas`: {
835
+ const body = {};
836
+ if (step.input_schemas) body.inbox_schemas = step.input_schemas;
837
+ if (step.output_schemas) body.state_schemas = step.output_schemas;
838
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types/${step.name}/schemas`, {
839
+ method: `PATCH`,
840
+ body: JSON.stringify(body)
841
+ });
842
+ (0, vitest.expect)(res.status).toBe(200);
843
+ const result = await res.json();
844
+ ctx.history.push({
845
+ type: `entity_type_schemas_amended`,
846
+ name: step.name,
847
+ revision: result.revision
848
+ });
849
+ break;
850
+ }
851
+ case `listTypes`: {
852
+ ctx.lastTypeListResult = await fetchShapeRows(ctx.baseUrl, `entity_types`);
853
+ break;
854
+ }
855
+ case `registerTypeViaServe`: {
856
+ const receiver = new ServeEndpointReceiver();
857
+ await receiver.start(step.registration);
858
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/_electric/entity-types`, {
859
+ method: `POST`,
860
+ body: JSON.stringify({
861
+ name: step.registration.name,
862
+ serve_endpoint: receiver.url
863
+ })
864
+ });
865
+ (0, vitest.expect)(res.status).toBe(201);
866
+ const body = await res.json();
867
+ ctx.currentEntityType = step.registration.name;
868
+ ctx.serveReceiver = receiver;
869
+ ctx.history.push({
870
+ type: `entity_type_registered`,
871
+ name: step.registration.name,
872
+ revision: body.revision
873
+ });
874
+ break;
875
+ }
876
+ case `spawnTyped`: {
877
+ const body = {};
878
+ if (step.args) body.args = step.args;
879
+ if (step.tags) body.tags = step.tags;
880
+ if (step.parent) body.parent = step.parent;
881
+ if (step.initialMessage !== void 0) body.initialMessage = step.initialMessage;
882
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/${step.typeName}/${step.instanceId}`, {
883
+ method: `PUT`,
884
+ body: JSON.stringify(body)
885
+ });
886
+ (0, vitest.expect)(res.status).toBe(201);
887
+ const entity = await res.json();
888
+ ctx.currentEntityUrl = entity.url;
889
+ ctx.currentEntityStreams = entity.streams;
890
+ ctx.currentWriteToken = null;
891
+ ctx.history.push({
892
+ type: `entity_spawned`,
893
+ entityUrl: entity.url,
894
+ entityType: step.typeName,
895
+ status: entity.status,
896
+ streams: entity.streams,
897
+ parent: step.parent
898
+ });
899
+ break;
900
+ }
901
+ case `write`: {
902
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
903
+ const body = {
904
+ type: step.eventType ?? `default`,
905
+ key: `write-${ctx.history.length}`,
906
+ value: typeof step.payload === `object` && step.payload !== null ? step.payload : { data: step.payload },
907
+ headers: { operation: `insert` }
908
+ };
909
+ const writeHeaders = {};
910
+ if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
911
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/main`, {
912
+ method: `POST`,
913
+ headers: writeHeaders,
914
+ body: JSON.stringify(body)
915
+ });
916
+ (0, vitest.expect)([200, 204]).toContain(res.status);
917
+ ctx.history.push({
918
+ type: `entity_write`,
919
+ entityUrl: ctx.currentEntityUrl,
920
+ eventType: step.eventType,
921
+ payload: step.payload
922
+ });
923
+ break;
924
+ }
925
+ case `writeStateProtocol`: {
926
+ const entityUrl = ctx.currentEntityUrl;
927
+ if (!entityUrl) throw new Error(`No current entity for writeStateProtocol`);
928
+ const writeHeaders = {};
929
+ if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
930
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${entityUrl}/main`, {
931
+ method: `POST`,
932
+ headers: writeHeaders,
933
+ body: JSON.stringify(step.event)
934
+ });
935
+ (0, vitest.expect)([200, 204]).toContain(res.status);
936
+ ctx.history.push({
937
+ type: `entity_write`,
938
+ entityUrl,
939
+ eventType: step.event.type,
940
+ payload: step.event
941
+ });
942
+ break;
943
+ }
944
+ case `expectStreamEvent`: {
945
+ if (!ctx.lastStreamMessages) throw new Error(`readStream() must be called first`);
946
+ const match = ctx.lastStreamMessages.find((e) => e.type === step.type && e.key === step.key && e.headers?.operation === step.operation);
947
+ (0, vitest.expect)(match).toBeDefined();
948
+ if (step.valueCheck && match) step.valueCheck(match.value ?? {});
949
+ break;
950
+ }
951
+ case `expectStreamEventCount`: {
952
+ if (!ctx.lastStreamMessages) throw new Error(`readStream() must be called first`);
953
+ const count = ctx.lastStreamMessages.filter((e) => e.type === step.type).length;
954
+ (0, vitest.expect)(count).toBe(step.count);
955
+ break;
956
+ }
957
+ case `setTags`: {
958
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
959
+ const tagHeaders = {};
960
+ if (ctx.currentWriteToken) tagHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
961
+ for (const [key, value] of Object.entries(step.tags)) {
962
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/tags/${encodeURIComponent(key)}`, {
963
+ method: `POST`,
964
+ headers: tagHeaders,
965
+ body: JSON.stringify({ value })
966
+ });
967
+ (0, vitest.expect)(res.status).toBe(200);
968
+ }
969
+ ctx.history.push({
970
+ type: `tags_updated`,
971
+ entityUrl: ctx.currentEntityUrl,
972
+ tags: step.tags
973
+ });
974
+ break;
975
+ }
976
+ case `expectTags`: {
977
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
978
+ const res = await electricAgentsFetch$1(ctx.baseUrl, ctx.currentEntityUrl);
979
+ (0, vitest.expect)(res.status).toBe(200);
980
+ const entity = await res.json();
981
+ (0, vitest.expect)(entity.tags).toEqual(step.tags);
982
+ ctx.history.push({
983
+ type: `tags_checked`,
984
+ entityUrl: ctx.currentEntityUrl,
985
+ tags: step.tags
986
+ });
987
+ break;
988
+ }
989
+ case `expectSpawnSchemaError`: {
990
+ const body = {};
991
+ if (step.args) body.args = step.args;
992
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `/${step.typeName}/${step.instanceId}`, {
993
+ method: `PUT`,
994
+ body: JSON.stringify(body)
995
+ });
996
+ (0, vitest.expect)(res.status).toBe(422);
997
+ const result = await res.json();
998
+ (0, vitest.expect)(result.error.code).toBe(`SCHEMA_VALIDATION_FAILED`);
999
+ ctx.history.push({
1000
+ type: `spawn_schema_rejected`,
1001
+ typeName: step.typeName,
1002
+ instanceId: step.instanceId,
1003
+ status: 422,
1004
+ code: `SCHEMA_VALIDATION_FAILED`
1005
+ });
1006
+ break;
1007
+ }
1008
+ case `expectSendSchemaError`: {
1009
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
1010
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
1011
+ method: `POST`,
1012
+ body: JSON.stringify({
1013
+ type: step.messageType,
1014
+ payload: step.payload
1015
+ })
1016
+ });
1017
+ (0, vitest.expect)(res.status).toBe(422);
1018
+ const result = await res.json();
1019
+ (0, vitest.expect)(result.error.code).toBe(`SCHEMA_VALIDATION_FAILED`);
1020
+ ctx.history.push({
1021
+ type: `send_schema_rejected`,
1022
+ entityUrl: ctx.currentEntityUrl,
1023
+ messageType: step.messageType,
1024
+ status: 422,
1025
+ code: `SCHEMA_VALIDATION_FAILED`
1026
+ });
1027
+ break;
1028
+ }
1029
+ case `expectWriteSchemaError`: {
1030
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
1031
+ const writeHeaders = {};
1032
+ if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
1033
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/main`, {
1034
+ method: `POST`,
1035
+ headers: writeHeaders,
1036
+ body: JSON.stringify({
1037
+ type: step.eventType,
1038
+ key: `schema-test-${ctx.history.length}`,
1039
+ value: typeof step.payload === `object` && step.payload !== null ? step.payload : { data: step.payload },
1040
+ headers: { operation: `insert` }
1041
+ })
1042
+ });
1043
+ (0, vitest.expect)(res.status).toBe(422);
1044
+ const result = await res.json();
1045
+ (0, vitest.expect)(result.error.code).toBe(`SCHEMA_VALIDATION_FAILED`);
1046
+ ctx.history.push({
1047
+ type: `write_schema_rejected`,
1048
+ entityUrl: ctx.currentEntityUrl,
1049
+ eventType: step.eventType,
1050
+ status: 422,
1051
+ code: `SCHEMA_VALIDATION_FAILED`
1052
+ });
1053
+ break;
1054
+ }
1055
+ case `expectSendUnknownType`: {
1056
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
1057
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
1058
+ method: `POST`,
1059
+ body: JSON.stringify({
1060
+ type: step.messageType,
1061
+ payload: step.payload
1062
+ })
1063
+ });
1064
+ (0, vitest.expect)(res.status).toBe(422);
1065
+ const result = await res.json();
1066
+ (0, vitest.expect)(result.error.code).toBe(`UNKNOWN_MESSAGE_TYPE`);
1067
+ ctx.history.push({
1068
+ type: `send_unknown_type_rejected`,
1069
+ entityUrl: ctx.currentEntityUrl,
1070
+ messageType: step.messageType,
1071
+ status: 422,
1072
+ code: `UNKNOWN_MESSAGE_TYPE`
1073
+ });
1074
+ break;
1075
+ }
1076
+ case `expectWriteUnknownType`: {
1077
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
1078
+ const writeHeaders = {};
1079
+ if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
1080
+ const res = await electricAgentsFetch$1(ctx.baseUrl, `${ctx.currentEntityUrl}/main`, {
1081
+ method: `POST`,
1082
+ headers: writeHeaders,
1083
+ body: JSON.stringify({
1084
+ type: step.eventType,
1085
+ key: `unknown-type-test-${ctx.history.length}`,
1086
+ value: typeof step.payload === `object` && step.payload !== null ? step.payload : { data: step.payload },
1087
+ headers: { operation: `insert` }
1088
+ })
1089
+ });
1090
+ (0, vitest.expect)(res.status).toBe(422);
1091
+ const result = await res.json();
1092
+ (0, vitest.expect)(result.error.code).toBe(`UNKNOWN_EVENT_TYPE`);
1093
+ ctx.history.push({
1094
+ type: `write_unknown_type_rejected`,
1095
+ entityUrl: ctx.currentEntityUrl,
1096
+ eventType: step.eventType,
1097
+ status: 422,
1098
+ code: `UNKNOWN_EVENT_TYPE`
1099
+ });
1100
+ break;
1101
+ }
1102
+ case `expectEntityPersisted`: {
1103
+ if (!ctx.currentEntityUrl) throw new Error(`No current entity`);
1104
+ const res = await electricAgentsFetch$1(ctx.baseUrl, ctx.currentEntityUrl);
1105
+ (0, vitest.expect)(res.status).toBe(200);
1106
+ ctx.history.push({
1107
+ type: `entity_persisted_verified`,
1108
+ entityUrl: ctx.currentEntityUrl
1109
+ });
1110
+ break;
1111
+ }
1112
+ }
1113
+ }
1114
+ function checkInvariants(history) {
1115
+ checkSpawnPrecedesSend(history);
1116
+ checkSpawnPrecedesKill(history);
1117
+ checkNoSendAfterKill(history);
1118
+ checkEntityContextOnWebhook(history);
1119
+ checkStreamPathsMatchEntityUrl(history);
1120
+ checkStatusTransitionsValid(history);
1121
+ checkEntityTypeExistsBeforeSpawn(history);
1122
+ checkSchemaValidationAtGates(history);
1123
+ checkAdditiveSchemaEvolution(history);
1124
+ checkRegistryStreamConsistency(history);
1125
+ checkParentExists(history);
1126
+ checkNoSpawnAfterTypeDeleted(history);
1127
+ const spWrites = history.filter((e) => e.type === `state_protocol_write`);
1128
+ for (const w of spWrites) {
1129
+ const spw = w;
1130
+ (0, vitest.expect)([
1131
+ `run`,
1132
+ `step`,
1133
+ `text`,
1134
+ `tool_call`,
1135
+ `text_delta`
1136
+ ].includes(spw.eventType), `State Protocol write must have valid event type, got: ${spw.eventType}`).toBe(true);
1137
+ (0, vitest.expect)(spw.key, `State Protocol write must have a key`).toBeTruthy();
1138
+ (0, vitest.expect)(spw.entityUrl, `State Protocol write must reference an entity`).toBeTruthy();
1139
+ }
1140
+ }
1141
+ /**
1142
+ * Spec S8 — Precedence: an entity must be spawned before messages can be sent to it.
1143
+ * ¬send W spawn — "spawn must happen before send"
1144
+ * Soundness: Sound | Completeness: Complete (within trace)
1145
+ */
1146
+ function checkSpawnPrecedesSend(history) {
1147
+ const spawned = new Set();
1148
+ for (const event of history) {
1149
+ if (event.type === `entity_spawned`) spawned.add(event.entityUrl);
1150
+ if (event.type === `message_sent`) (0, vitest.expect)(spawned.has(event.entityUrl), `Precedence: message_sent to ${event.entityUrl} but entity was not spawned first`).toBe(true);
1151
+ }
1152
+ }
1153
+ /**
1154
+ * Spec S9 — Precedence: an entity must be spawned before it can be killed.
1155
+ * Soundness: Sound | Completeness: Complete (within trace)
1156
+ */
1157
+ function checkSpawnPrecedesKill(history) {
1158
+ const spawned = new Set();
1159
+ for (const event of history) {
1160
+ if (event.type === `entity_spawned`) spawned.add(event.entityUrl);
1161
+ if (event.type === `entity_killed`) (0, vitest.expect)(spawned.has(event.entityUrl), `Precedence: entity_killed ${event.entityUrl} but entity was not spawned first`).toBe(true);
1162
+ }
1163
+ }
1164
+ /**
1165
+ * Spec S3 — Safety: no successful send after kill.
1166
+ * Once an entity is killed, message_sent should not appear for it.
1167
+ * (send_rejected is fine — that's the expected error path.)
1168
+ * Soundness: Sound | Completeness: Complete (within trace)
1169
+ */
1170
+ function checkNoSendAfterKill(history) {
1171
+ const killed = new Set();
1172
+ for (const event of history) {
1173
+ if (event.type === `entity_killed`) killed.add(event.entityUrl);
1174
+ if (event.type === `message_sent`) (0, vitest.expect)(!killed.has(event.entityUrl), `Safety: message_sent to ${event.entityUrl} after it was killed`).toBe(true);
1175
+ }
1176
+ }
1177
+ /**
1178
+ * Spec S6 — Safety: every webhook received while Electric Agents is enabled must contain
1179
+ * entity context (entity field in the payload).
1180
+ * Soundness: Sound | Completeness: Incomplete (only checks observed webhooks)
1181
+ */
1182
+ function checkEntityContextOnWebhook(history) {
1183
+ for (const event of history) if (event.type === `webhook_received`) {
1184
+ (0, vitest.expect)(event.entity, `Safety: webhook_received must contain entity context`).toBeDefined();
1185
+ (0, vitest.expect)(event.entity.url).toBeTruthy();
1186
+ }
1187
+ }
1188
+ /**
1189
+ * Spec S5 — Structural: stream paths must match {entity.url}/main and {entity.url}/error.
1190
+ * Soundness: Sound | Completeness: Complete (within trace)
1191
+ */
1192
+ function checkStreamPathsMatchEntityUrl(history) {
1193
+ for (const event of history) if (event.type === `entity_spawned`) {
1194
+ (0, vitest.expect)(event.streams.main).toBe(`${event.entityUrl}/main`);
1195
+ (0, vitest.expect)(event.streams.error).toBe(`${event.entityUrl}/error`);
1196
+ }
1197
+ }
1198
+ /**
1199
+ * Spec S4 — Safety: entity status transitions must be valid.
1200
+ * spawning → running is valid (at spawn time)
1201
+ * running/idle → stopped is valid (at kill time)
1202
+ * stopped → running is NOT valid
1203
+ * Soundness: Sound | Completeness: Incomplete (only checks observed status reads)
1204
+ */
1205
+ function checkStatusTransitionsValid(history) {
1206
+ const lastStatus = new Map();
1207
+ for (const event of history) {
1208
+ if (event.type === `entity_spawned`) lastStatus.set(event.entityUrl, event.status);
1209
+ if (event.type === `entity_status_checked`) {
1210
+ const prev = lastStatus.get(event.entityUrl);
1211
+ if (prev === `stopped`) (0, vitest.expect)(event.status, `Safety: entity ${event.entityUrl} transitioned from stopped to ${event.status}`).toBe(`stopped`);
1212
+ lastStatus.set(event.entityUrl, event.status);
1213
+ }
1214
+ }
1215
+ }
1216
+ /**
1217
+ * Spec R1 — Structural: every entity_spawned must be preceded by an
1218
+ * entity_type_registered event for the matching entity type.
1219
+ * Soundness: Sound | Completeness: Complete (within trace)
1220
+ */
1221
+ function checkEntityTypeExistsBeforeSpawn(history) {
1222
+ const registeredTypes = new Set();
1223
+ for (const event of history) {
1224
+ if (event.type === `entity_type_registered`) registeredTypes.add(event.name);
1225
+ if (event.type === `entity_spawned` && event.entityType !== void 0) (0, vitest.expect)(registeredTypes.has(event.entityType), `Structural: entity_spawned for type '${event.entityType}' but no prior entity_type_registered event found`).toBe(true);
1226
+ }
1227
+ }
1228
+ /**
1229
+ * Spec R2 — Safety: schema rejection events must carry the correct error code.
1230
+ * All schema rejection events (spawn/send/write) must have code SCHEMA_VALIDATION_FAILED.
1231
+ * Soundness: Sound | Completeness: Complete (within trace)
1232
+ */
1233
+ function checkSchemaValidationAtGates(history) {
1234
+ for (const event of history) if (event.type === `spawn_schema_rejected` || event.type === `send_schema_rejected` || event.type === `write_schema_rejected`) (0, vitest.expect)(event.code, `Safety: ${event.type} must have code SCHEMA_VALIDATION_FAILED, got '${event.code}'`).toBe(`SCHEMA_VALIDATION_FAILED`);
1235
+ }
1236
+ /**
1237
+ * Spec R3 — Additive-only schema evolution: entity_type_schemas_amended events
1238
+ * must not remove or rename schema keys that existed in the prior registration.
1239
+ * This checker works on the step inputs stored in history by tracking which
1240
+ * amendment steps appear in sequence. Since the history events for amendments
1241
+ * only record name and revision (not the actual schema keys), this invariant
1242
+ * verifies structural ordering — each amendment event must follow a registration
1243
+ * event. Full key-level checking would require schema contents in the event.
1244
+ * Soundness: Partial | Completeness: Incomplete (key contents not in history events)
1245
+ *
1246
+ * Note: The invariant verifies that every schema amendment is preceded by a
1247
+ * registration of the same type name, consistent with additive-only evolution.
1248
+ */
1249
+ function checkAdditiveSchemaEvolution(history) {
1250
+ const registeredTypes = new Set();
1251
+ for (const event of history) {
1252
+ if (event.type === `entity_type_registered`) registeredTypes.add(event.name);
1253
+ if (event.type === `entity_type_schemas_amended`) (0, vitest.expect)(registeredTypes.has(event.name), `Additive evolution: entity_type_schemas_amended for '${event.name}' but no prior entity_type_registered event found`).toBe(true);
1254
+ }
1255
+ }
1256
+ /**
1257
+ * Spec R4 — Registry stream consistency: every entity_spawned must refer to an
1258
+ * entity that has not already been killed, and stream URLs must follow the
1259
+ * convention {entity.url}/main and {entity.url}/error.
1260
+ * Soundness: Sound | Completeness: Complete (within trace)
1261
+ */
1262
+ function checkRegistryStreamConsistency(history) {
1263
+ const killedEntities = new Set();
1264
+ for (const event of history) {
1265
+ if (event.type === `entity_killed`) killedEntities.add(event.entityUrl);
1266
+ if (event.type === `entity_spawned`) {
1267
+ (0, vitest.expect)(!killedEntities.has(event.entityUrl), `Registry consistency: entity_spawned for ${event.entityUrl} but entity was already killed`).toBe(true);
1268
+ (0, vitest.expect)(event.streams.main, `Registry consistency: entity ${event.entityUrl} streams.main must be ${event.entityUrl}/main`).toBe(`${event.entityUrl}/main`);
1269
+ (0, vitest.expect)(event.streams.error, `Registry consistency: entity ${event.entityUrl} streams.error must be ${event.entityUrl}/error`).toBe(`${event.entityUrl}/error`);
1270
+ }
1271
+ }
1272
+ }
1273
+ /**
1274
+ * Spec R5 — Parent existence: if a spawned entity declares a parent, the
1275
+ * parent must have been spawned earlier in the trace and not yet killed.
1276
+ * Soundness: Sound | Completeness: Complete (within trace)
1277
+ */
1278
+ function checkParentExists(history) {
1279
+ const spawnedAlive = new Set();
1280
+ for (const event of history) {
1281
+ if (event.type === `entity_spawned`) {
1282
+ if (event.parent) (0, vitest.expect)(spawnedAlive.has(event.parent), `Parent ${event.parent} must be spawned and alive before child ${event.entityUrl} is spawned`).toBe(true);
1283
+ spawnedAlive.add(event.entityUrl);
1284
+ }
1285
+ if (event.type === `entity_killed`) spawnedAlive.delete(event.entityUrl);
1286
+ }
1287
+ }
1288
+ /**
1289
+ * Spec R6 — No spawn after type deleted: every entity_spawned event must not
1290
+ * be preceded by an entity_type_deleted event for the same type name (unless
1291
+ * re-registered between the deletion and the spawn).
1292
+ * Soundness: Sound | Completeness: Complete (within trace)
1293
+ */
1294
+ function checkNoSpawnAfterTypeDeleted(history) {
1295
+ const deletedTypes = new Set();
1296
+ for (const event of history) {
1297
+ if (event.type === `entity_type_deleted`) deletedTypes.add(event.name);
1298
+ if (event.type === `entity_type_registered`) deletedTypes.delete(event.name);
1299
+ if (event.type === `entity_spawned` && event.entityType !== void 0) (0, vitest.expect)(!deletedTypes.has(event.entityType), `Safety: entity_spawned for type '${event.entityType}' after it was deleted without re-registration`).toBe(true);
1300
+ }
1301
+ }
1302
+ /**
1303
+ * ENABLED predicate — determines which actions can fire from the current state.
1304
+ *
1305
+ * - register_type: always (up to a cap of 3 types)
1306
+ * - delete_type: when entity types exist and no running entities use them
1307
+ * - spawn: when at least one entity type is registered (up to a cap)
1308
+ * - send: when at least one entity is running
1309
+ * - kill: when at least one entity is running
1310
+ * - check_status: when at least one entity exists
1311
+ * - list: always
1312
+ */
1313
+ function enabledElectricAgentsActions(model) {
1314
+ const enabled = [`list`];
1315
+ if (model.entityTypes.length < 3) enabled.push(`register_type`);
1316
+ const runningTypeNames = new Set(model.entities.filter((e) => e.status === `running`).map((e) => e.typeName));
1317
+ const deletableTypes = model.entityTypes.filter((t) => !runningTypeNames.has(t.name));
1318
+ if (deletableTypes.length > 0) enabled.push(`delete_type`);
1319
+ if (model.entityTypes.length > 0 && model.entities.length < 4) enabled.push(`spawn`);
1320
+ const hasRunning = model.entities.some((e) => e.status === `running`);
1321
+ if (hasRunning) enabled.push(`send`, `kill`, `check_status`);
1322
+ const hasAny = model.entities.length > 0;
1323
+ if (hasAny && !hasRunning) enabled.push(`check_status`);
1324
+ return enabled;
1325
+ }
1326
+ /**
1327
+ * Next relation — pure state transition for the model.
1328
+ * The real server execution happens separately in the property test.
1329
+ */
1330
+ function applyElectricAgentsAction(model, action, targetIdx) {
1331
+ switch (action) {
1332
+ case `register_type`: {
1333
+ const typeNum = model.entityTypes.length;
1334
+ return {
1335
+ ...model,
1336
+ entityTypes: [...model.entityTypes, {
1337
+ name: `prop-type-${typeNum}`,
1338
+ hasCreationSchema: false,
1339
+ hasInputSchemas: false,
1340
+ hasOutputSchemas: false
1341
+ }]
1342
+ };
1343
+ }
1344
+ case `delete_type`: {
1345
+ const runningTypeNames = new Set(model.entities.filter((e) => e.status === `running`).map((e) => e.typeName));
1346
+ const deletable = model.entityTypes.filter((t) => !runningTypeNames.has(t.name));
1347
+ if (deletable.length === 0) return model;
1348
+ const toDelete = deletable[0];
1349
+ return {
1350
+ ...model,
1351
+ entityTypes: model.entityTypes.filter((t) => t.name !== toDelete.name)
1352
+ };
1353
+ }
1354
+ case `spawn`: {
1355
+ if (model.entityTypes.length === 0) return model;
1356
+ const typeName = model.entityTypes[0].name;
1357
+ return {
1358
+ ...model,
1359
+ entities: [...model.entities, {
1360
+ url: `/prop-placeholder/entity-${model.nextEntityNum}`,
1361
+ typeName,
1362
+ status: `running`,
1363
+ messageCount: 0
1364
+ }],
1365
+ nextEntityNum: model.nextEntityNum + 1
1366
+ };
1367
+ }
1368
+ case `send`: {
1369
+ if (targetIdx === void 0) return model;
1370
+ const entities = [...model.entities];
1371
+ const e = entities[targetIdx];
1372
+ entities[targetIdx] = {
1373
+ ...e,
1374
+ messageCount: e.messageCount + 1
1375
+ };
1376
+ return {
1377
+ ...model,
1378
+ entities
1379
+ };
1380
+ }
1381
+ case `kill`: {
1382
+ if (targetIdx === void 0) return model;
1383
+ const entities = [...model.entities];
1384
+ const e = entities[targetIdx];
1385
+ entities[targetIdx] = {
1386
+ ...e,
1387
+ status: `stopped`
1388
+ };
1389
+ return {
1390
+ ...model,
1391
+ entities
1392
+ };
1393
+ }
1394
+ case `check_status`:
1395
+ case `list`: return model;
1396
+ }
1397
+ }
1398
+ function electricAgents(baseUrl) {
1399
+ return new ElectricAgentsScenario(baseUrl);
1400
+ }
1401
+ function checkStateProtocolInvariants(events) {
1402
+ const stateProtocolEvents = events.filter((e) => e.type && e.key && e.headers);
1403
+ if (stateProtocolEvents.length === 0) return;
1404
+ const runs = new Map();
1405
+ for (const e of stateProtocolEvents) {
1406
+ if (e.type !== `run`) continue;
1407
+ const key = e.key;
1408
+ if (!runs.has(key)) runs.set(key, []);
1409
+ runs.get(key).push(e);
1410
+ }
1411
+ for (const [key, evts] of runs) (0, vitest.expect)(evts[0].headers.operation, `E1: run ${key} must start with insert`).toBe(`insert`);
1412
+ const steps = new Map();
1413
+ for (const e of stateProtocolEvents) {
1414
+ if (e.type !== `step`) continue;
1415
+ const key = e.key;
1416
+ if (!steps.has(key)) steps.set(key, []);
1417
+ steps.get(key).push(e);
1418
+ }
1419
+ for (const [key, evts] of steps) (0, vitest.expect)(evts[0].headers.operation, `E2: step ${key} must start with insert`).toBe(`insert`);
1420
+ const texts = new Map();
1421
+ for (const e of stateProtocolEvents) {
1422
+ if (e.type !== `text`) continue;
1423
+ const key = e.key;
1424
+ if (!texts.has(key)) texts.set(key, []);
1425
+ texts.get(key).push(e);
1426
+ }
1427
+ for (const [key, evts] of texts) (0, vitest.expect)(evts[0].headers.operation, `E3: text ${key} must start with insert`).toBe(`insert`);
1428
+ const toolCalls = new Map();
1429
+ for (const e of stateProtocolEvents) {
1430
+ if (e.type !== `tool_call`) continue;
1431
+ const key = e.key;
1432
+ if (!toolCalls.has(key)) toolCalls.set(key, []);
1433
+ toolCalls.get(key).push(e);
1434
+ }
1435
+ for (const [key, evts] of toolCalls) {
1436
+ (0, vitest.expect)(evts.length).toBeGreaterThanOrEqual(3);
1437
+ (0, vitest.expect)(evts[0].headers.operation, `E4: tool_call ${key} must start with insert`).toBe(`insert`);
1438
+ (0, vitest.expect)(evts[0].value?.status, `E4: tool_call ${key} insert must have status started`).toBe(`started`);
1439
+ const statuses = evts.map((e) => e.value?.status);
1440
+ const executingIdx = statuses.indexOf(`executing`);
1441
+ (0, vitest.expect)(executingIdx, `E4: tool_call ${key} must have executing state`).toBeGreaterThan(0);
1442
+ if (executingIdx > 1) (0, vitest.expect)(statuses[1], `E4: tool_call ${key} event after started must be args_complete if not executing`).toBe(`args_complete`);
1443
+ const lastStatus = statuses[statuses.length - 1];
1444
+ (0, vitest.expect)(lastStatus === `completed` || lastStatus === `failed`, `E4: tool_call ${key} must end with completed or failed, got ${lastStatus}`).toBe(true);
1445
+ const toolNames = evts.map((e) => e.value?.tool_name).filter(Boolean);
1446
+ if (toolNames.length > 0) {
1447
+ const first = toolNames[0];
1448
+ for (const tn of toolNames) (0, vitest.expect)(tn, `E4: tool_name must be consistent for ${key}`).toBe(first);
1449
+ }
1450
+ }
1451
+ const textDeltas = stateProtocolEvents.filter((e) => e.type === `text_delta`);
1452
+ for (const d of textDeltas) {
1453
+ const key = d.key;
1454
+ (0, vitest.expect)(key, `E5: text_delta key must contain ':'`).toContain(`:`);
1455
+ const [parentKey, seqStr] = key.split(`:`);
1456
+ (0, vitest.expect)(Number.isInteger(parseInt(seqStr, 10)), `E5: text_delta seq must be integer`).toBe(true);
1457
+ const val = d.value;
1458
+ if (val) (0, vitest.expect)(val.text_id, `E5: text_delta value.text_id must match parent key`).toBe(parentKey);
1459
+ }
1460
+ for (const [key, evts] of steps) {
1461
+ for (const e of evts) {
1462
+ const val = e.value;
1463
+ (0, vitest.expect)(val?.step_number, `E6: step ${key} must have step_number`).toBeDefined();
1464
+ }
1465
+ const stepNumbers = evts.map((e) => e.value?.step_number).filter((n) => n !== void 0);
1466
+ if (stepNumbers.length > 1) {
1467
+ const first = stepNumbers[0];
1468
+ for (const n of stepNumbers) (0, vitest.expect)(n, `E6: step_number must be consistent for ${key}`).toBe(first);
1469
+ }
1470
+ }
1471
+ const deltasByParent = new Map();
1472
+ for (const d of textDeltas) {
1473
+ const key = d.key;
1474
+ const [parentKey, seqStr] = key.split(`:`);
1475
+ if (!deltasByParent.has(parentKey)) deltasByParent.set(parentKey, []);
1476
+ deltasByParent.get(parentKey).push(parseInt(seqStr, 10));
1477
+ }
1478
+ for (const [parent, seqs] of deltasByParent) for (let i = 1; i < seqs.length; i++) (0, vitest.expect)(seqs[i], `E7: text_delta seq for ${parent} must be monotonically increasing`).toBeGreaterThan(seqs[i - 1]);
1479
+ for (const [key, evts] of runs) {
1480
+ const updates = evts.filter((e) => e.headers.operation === `update`);
1481
+ for (const u of updates) {
1482
+ const val = u.value;
1483
+ if (val.status === `completed`) (0, vitest.expect)(val.finish_reason, `E8: run ${key} completion must have finish_reason`).toBeDefined();
1484
+ }
1485
+ }
1486
+ for (const [key, evts] of steps) {
1487
+ const updates = evts.filter((e) => e.headers.operation === `update`);
1488
+ for (const u of updates) {
1489
+ const val = u.value;
1490
+ if (val.status === `completed`) (0, vitest.expect)(val.finish_reason, `E8: step ${key} completion must have finish_reason`).toBeDefined();
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ //#endregion
1496
+ //#region src/cli-dsl.ts
1497
+ function subscriptionEndpoint(baseUrl, id) {
1498
+ return `${baseUrl}/v1/stream-meta/subscriptions/${encodeURIComponent(id)}`;
1499
+ }
1500
+ function subscriptionPattern(pattern) {
1501
+ return pattern.replace(/^\/+/, ``);
1502
+ }
1503
+ var CliScenario = class {
1504
+ baseUrl;
1505
+ cliBin;
1506
+ steps = [];
1507
+ constructor(baseUrl, cliBin) {
1508
+ this.baseUrl = baseUrl;
1509
+ this.cliBin = cliBin;
1510
+ }
1511
+ /**
1512
+ * Register an entity type via HTTP (setup, not a CLI test).
1513
+ */
1514
+ setupType(registration) {
1515
+ this.steps.push({
1516
+ kind: `setupType`,
1517
+ registration
1518
+ });
1519
+ return this;
1520
+ }
1521
+ /**
1522
+ * Create a webhook subscription via HTTP (setup, not a CLI test).
1523
+ */
1524
+ setupSubscription(pattern, id) {
1525
+ this.steps.push({
1526
+ kind: `setupSubscription`,
1527
+ pattern,
1528
+ id
1529
+ });
1530
+ return this;
1531
+ }
1532
+ /**
1533
+ * Verify internal server state via direct API call.
1534
+ * Provides an extra level of guarantee beyond CLI output.
1535
+ */
1536
+ verifyApi(fn) {
1537
+ this.steps.push({
1538
+ kind: `verifyApi`,
1539
+ fn
1540
+ });
1541
+ return this;
1542
+ }
1543
+ /**
1544
+ * Execute a CLI command. Args are passed directly to the binary.
1545
+ */
1546
+ exec(...args) {
1547
+ this.steps.push({
1548
+ kind: `exec`,
1549
+ args
1550
+ });
1551
+ return this;
1552
+ }
1553
+ /**
1554
+ * Assert stdout of the last exec matches a regex.
1555
+ */
1556
+ expectStdout(pattern) {
1557
+ this.steps.push({
1558
+ kind: `expectStdout`,
1559
+ pattern
1560
+ });
1561
+ return this;
1562
+ }
1563
+ /**
1564
+ * Assert stdout of the last exec does NOT match a regex.
1565
+ */
1566
+ expectStdoutNot(pattern) {
1567
+ this.steps.push({
1568
+ kind: `expectStdoutNot`,
1569
+ pattern
1570
+ });
1571
+ return this;
1572
+ }
1573
+ /**
1574
+ * Assert stdout contains exact text.
1575
+ */
1576
+ expectStdoutContains(text) {
1577
+ this.steps.push({
1578
+ kind: `expectStdoutContains`,
1579
+ text
1580
+ });
1581
+ return this;
1582
+ }
1583
+ /**
1584
+ * Assert stderr of the last exec matches a regex.
1585
+ */
1586
+ expectStderr(pattern) {
1587
+ this.steps.push({
1588
+ kind: `expectStderr`,
1589
+ pattern
1590
+ });
1591
+ return this;
1592
+ }
1593
+ /**
1594
+ * Assert exit code of the last exec.
1595
+ */
1596
+ expectExitCode(code) {
1597
+ this.steps.push({
1598
+ kind: `expectExitCode`,
1599
+ code
1600
+ });
1601
+ return this;
1602
+ }
1603
+ /**
1604
+ * Parse stdout as JSON and run a check function.
1605
+ */
1606
+ expectJson(check) {
1607
+ this.steps.push({
1608
+ kind: `expectJson`,
1609
+ check
1610
+ });
1611
+ return this;
1612
+ }
1613
+ /**
1614
+ * Custom assertion on the last exec result.
1615
+ */
1616
+ custom(fn) {
1617
+ this.steps.push({
1618
+ kind: `custom`,
1619
+ fn
1620
+ });
1621
+ return this;
1622
+ }
1623
+ /**
1624
+ * Wait for a given number of milliseconds.
1625
+ */
1626
+ wait(ms) {
1627
+ this.steps.push({
1628
+ kind: `wait`,
1629
+ ms
1630
+ });
1631
+ return this;
1632
+ }
1633
+ /**
1634
+ * Run all steps sequentially, returning the history.
1635
+ */
1636
+ async run() {
1637
+ const history = [];
1638
+ let lastResult = {
1639
+ stdout: ``,
1640
+ stderr: ``,
1641
+ exitCode: 0
1642
+ };
1643
+ const receiver = await startNoopReceiver();
1644
+ try {
1645
+ for (const step of this.steps) switch (step.kind) {
1646
+ case `setupType`: {
1647
+ const res = await fetch(`${this.baseUrl}/_electric/entity-types`, {
1648
+ method: `POST`,
1649
+ headers: { "content-type": `application/json` },
1650
+ body: JSON.stringify(step.registration)
1651
+ });
1652
+ (0, vitest.expect)(res.ok, `setupType failed: ${res.status}`).toBe(true);
1653
+ break;
1654
+ }
1655
+ case `setupSubscription`: {
1656
+ const res = await fetch(subscriptionEndpoint(this.baseUrl, step.id), {
1657
+ method: `PUT`,
1658
+ headers: { "content-type": `application/json` },
1659
+ body: JSON.stringify({
1660
+ type: `webhook`,
1661
+ pattern: subscriptionPattern(step.pattern),
1662
+ webhook: { url: receiver.url }
1663
+ })
1664
+ });
1665
+ (0, vitest.expect)(res.status, `setupSubscription failed: ${res.status}`).toBeLessThan(300);
1666
+ break;
1667
+ }
1668
+ case `verifyApi`: {
1669
+ await step.fn(this.baseUrl);
1670
+ break;
1671
+ }
1672
+ case `exec`: {
1673
+ lastResult = await execCli(this.cliBin, step.args, this.baseUrl);
1674
+ history.push({
1675
+ command: step.args,
1676
+ stdout: lastResult.stdout,
1677
+ stderr: lastResult.stderr,
1678
+ exitCode: lastResult.exitCode
1679
+ });
1680
+ break;
1681
+ }
1682
+ case `expectStdout`: {
1683
+ (0, vitest.expect)(lastResult.stdout, `stdout should match ${step.pattern}`).toMatch(step.pattern);
1684
+ break;
1685
+ }
1686
+ case `expectStdoutNot`: {
1687
+ (0, vitest.expect)(lastResult.stdout, `stdout should NOT match ${step.pattern}`).not.toMatch(step.pattern);
1688
+ break;
1689
+ }
1690
+ case `expectStdoutContains`: {
1691
+ (0, vitest.expect)(lastResult.stdout, `stdout should contain "${step.text}"`).toContain(step.text);
1692
+ break;
1693
+ }
1694
+ case `expectStderr`: {
1695
+ (0, vitest.expect)(lastResult.stderr, `stderr should match ${step.pattern}`).toMatch(step.pattern);
1696
+ break;
1697
+ }
1698
+ case `expectExitCode`: {
1699
+ if (lastResult.exitCode !== step.code) {
1700
+ const detail = lastResult.stderr || lastResult.stdout || `(no output)`;
1701
+ (0, vitest.expect)(lastResult.exitCode, `exit code should be ${step.code}, output: ${detail.slice(0, 200)}`).toBe(step.code);
1702
+ }
1703
+ break;
1704
+ }
1705
+ case `expectJson`: {
1706
+ const parsed = JSON.parse(lastResult.stdout);
1707
+ step.check(parsed);
1708
+ break;
1709
+ }
1710
+ case `custom`: {
1711
+ await step.fn(lastResult);
1712
+ break;
1713
+ }
1714
+ case `wait`: {
1715
+ await new Promise((resolve) => setTimeout(resolve, step.ms));
1716
+ break;
1717
+ }
1718
+ }
1719
+ } finally {
1720
+ await receiver.close();
1721
+ }
1722
+ return history;
1723
+ }
1724
+ };
1725
+ function execCli(bin, args, baseUrl) {
1726
+ return new Promise((resolve) => {
1727
+ (0, node_child_process.execFile)(`pnpm`, [
1728
+ `exec`,
1729
+ `tsx`,
1730
+ bin,
1731
+ ...args
1732
+ ], {
1733
+ env: {
1734
+ ...process.env,
1735
+ ELECTRIC_AGENTS_URL: baseUrl,
1736
+ ELECTRIC_AGENTS_IDENTITY: `test-user@test-host`
1737
+ },
1738
+ timeout: 3e4
1739
+ }, (error, stdout, stderr) => {
1740
+ resolve({
1741
+ stdout: stdout.toString(),
1742
+ stderr: stderr.toString(),
1743
+ exitCode: error ? typeof error.code === `number` ? error.code : 1 : 0
1744
+ });
1745
+ });
1746
+ });
1747
+ }
1748
+ function cliTest(baseUrl, cliBin) {
1749
+ return new CliScenario(baseUrl, cliBin);
1750
+ }
1751
+ function startNoopReceiver() {
1752
+ return new Promise((resolve) => {
1753
+ const server = (0, node_http.createServer)((_req, res) => {
1754
+ _req.on(`data`, () => {});
1755
+ _req.on(`end`, () => {
1756
+ res.writeHead(200, { "content-type": `application/json` });
1757
+ res.end(JSON.stringify({ ok: true }));
1758
+ });
1759
+ });
1760
+ server.listen(0, `127.0.0.1`, () => {
1761
+ const addr = server.address();
1762
+ if (!addr || typeof addr === `string`) throw new Error(`Failed to start noop receiver`);
1763
+ resolve({
1764
+ url: `http://127.0.0.1:${addr.port}`,
1765
+ close: () => new Promise((res) => {
1766
+ server.close(() => res());
1767
+ })
1768
+ });
1769
+ });
1770
+ });
1771
+ }
1772
+
1773
+ //#endregion
1774
+ //#region src/electric-agents-tests.ts
1775
+ async function electricAgentsFetch(baseUrl, path, opts = {}) {
1776
+ return fetch(`${baseUrl}${routeControlPlanePath(path)}`, {
1777
+ ...opts,
1778
+ headers: {
1779
+ "content-type": `application/json`,
1780
+ ...opts.headers
1781
+ }
1782
+ });
1783
+ }
1784
+ function routeControlPlanePath(path) {
1785
+ const pathname = path.split(`?`, 1)[0];
1786
+ if (pathname.startsWith(`/_electric/`) || isEntityStreamPath(pathname)) return path;
1787
+ return `/_electric/entities${path}`;
1788
+ }
1789
+ function isEntityStreamPath(pathname) {
1790
+ const segments = pathname.split(`/`).filter(Boolean);
1791
+ const lastSegment = segments.at(-1);
1792
+ return segments.length >= 3 && (lastSegment === `main` || lastSegment === `error`);
1793
+ }
1794
+ async function pollEntityStatus(baseUrl, entityUrl, statuses, timeoutMs = 8e3) {
1795
+ const deadline = Date.now() + timeoutMs;
1796
+ while (Date.now() < deadline) {
1797
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(entityUrl)}`);
1798
+ (0, vitest.expect)(res.status).toBe(200);
1799
+ const entity = await res.json();
1800
+ if (statuses.includes(String(entity.status))) return entity;
1801
+ await new Promise((resolve) => setTimeout(resolve, 100));
1802
+ }
1803
+ throw new Error(`Timed out waiting for ${entityUrl} to reach one of: ${statuses.join(`, `)}`);
1804
+ }
1805
+ function runElectricAgentsConformanceTests(config) {
1806
+ (0, vitest.describe)(`Electric Agents Spawn`, () => {
1807
+ (0, vitest.test)(`spawn creates entity and streams`, () => electricAgents(config.baseUrl).subscription(`/spawn-test-agent/**`, `spawn-test-sub`).registerType({
1808
+ name: `spawn-test-agent`,
1809
+ description: `Test entity type for spawn`,
1810
+ creation_schema: { type: `object` }
1811
+ }).spawn(`spawn-test-agent`, `entity-1`).expectStatus(`running`).custom(async (ctx) => {
1812
+ const mainRes = await fetch(`${ctx.baseUrl}${ctx.currentEntityStreams.main}`, { method: `HEAD` });
1813
+ (0, vitest.expect)(mainRes.status).toBe(200);
1814
+ const errorRes = await fetch(`${ctx.baseUrl}${ctx.currentEntityStreams.error}`, { method: `HEAD` });
1815
+ (0, vitest.expect)(errorRes.status).toBe(200);
1816
+ }).run());
1817
+ (0, vitest.test)(`spawn at unregistered type returns UNKNOWN_ENTITY_TYPE`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
1818
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/unregistered/entity-1`)}`, {
1819
+ method: `PUT`,
1820
+ headers: { "content-type": `application/json` },
1821
+ body: JSON.stringify({})
1822
+ });
1823
+ (0, vitest.expect)(res.status).toBe(404);
1824
+ const body = await res.json();
1825
+ (0, vitest.expect)(body.error.code).toBe(`UNKNOWN_ENTITY_TYPE`);
1826
+ }).skipInvariants().run());
1827
+ (0, vitest.test)(`spawn rejects duplicate URL`, () => electricAgents(config.baseUrl).subscription(`/dup-test-agent/**`, `dup-test-sub`).registerType({
1828
+ name: `dup-test-agent`,
1829
+ description: `Test entity type for duplicate spawn`,
1830
+ creation_schema: { type: `object` }
1831
+ }).spawn(`dup-test-agent`, `entity-1`).expectSpawnError(`/dup-test-agent/entity-1`, `DUPLICATE_URL`, 409).run());
1832
+ (0, vitest.test)(`spawn without type succeeds`, () => electricAgents(config.baseUrl).subscription(`/notype-test-agent/**`, `notype-test-sub`).registerType({
1833
+ name: `notype-test-agent`,
1834
+ description: `Test entity type for typeless spawn`,
1835
+ creation_schema: { type: `object` }
1836
+ }).spawn(`notype-test-agent`, `entity-1`).expectStatus(`running`).custom(async (ctx) => {
1837
+ const res = await electricAgentsFetch(ctx.baseUrl, ctx.currentEntityUrl);
1838
+ const entity = await res.json();
1839
+ (0, vitest.expect)(entity.type).toBe(`notype-test-agent`);
1840
+ }).run());
1841
+ });
1842
+ (0, vitest.describe)(`Electric Agents Send`, () => {
1843
+ (0, vitest.test)(`send delivers State Protocol message event`, () => electricAgents(config.baseUrl).subscription(`/send-test-worker/**`, `send-test-sub`).registerType({
1844
+ name: `send-test-worker`,
1845
+ description: `Test entity type for send`,
1846
+ creation_schema: { type: `object` }
1847
+ }).spawn(`send-test-worker`, `entity-1`).send({ task: `hello` }, { from: `user-1` }).expectWebhook().respondDone().readStream().expectStreamContains(`message_received`).custom(async (ctx) => {
1848
+ const envelope = ctx.lastStreamMessages.find((m) => m.type === `message_received`);
1849
+ (0, vitest.expect)(envelope.type).toBe(`message_received`);
1850
+ (0, vitest.expect)(envelope.key).toBeDefined();
1851
+ (0, vitest.expect)(envelope.value?.from).toBe(`user-1`);
1852
+ (0, vitest.expect)(envelope.value?.payload).toEqual({ task: `hello` });
1853
+ }).run());
1854
+ (0, vitest.test)(`send triggers webhook with entity context`, () => electricAgents(config.baseUrl).subscription(`/webhook-ctx-agent/**`, `webhook-ctx-sub`).registerType({
1855
+ name: `webhook-ctx-agent`,
1856
+ description: `Test entity type for webhook context`,
1857
+ creation_schema: { type: `object` }
1858
+ }).spawn(`webhook-ctx-agent`, `entity-1`).send({ ping: true }, { from: `test` }).expectWebhook().expectEntityContext({ type: `webhook-ctx-agent` }).respondDone().run());
1859
+ (0, vitest.test)(`send to nonexistent entity returns 404`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
1860
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/nonexistent-type/nonexistent-id/send`)}`, {
1861
+ method: `POST`,
1862
+ headers: { "content-type": `application/json` },
1863
+ body: JSON.stringify({
1864
+ from: `test`,
1865
+ payload: {}
1866
+ })
1867
+ });
1868
+ (0, vitest.expect)(res.status).toBe(404);
1869
+ }).skipInvariants().run());
1870
+ (0, vitest.test)(`multiple sends preserve order in stream`, () => {
1871
+ const id = Date.now();
1872
+ return electricAgents(config.baseUrl).subscription(`/order-test-agent-${id}/**`, `order-test-sub-${id}`).registerType({
1873
+ name: `order-test-agent-${id}`,
1874
+ description: `Test entity type for ordering`,
1875
+ creation_schema: { type: `object` }
1876
+ }).spawn(`order-test-agent-${id}`, `entity-1`).send({ seq: 1 }, { from: `test` }).expectWebhook().respondDone().send({ seq: 2 }, { from: `test` }).expectWebhook().respondDone().send({ seq: 3 }, { from: `test` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
1877
+ const msgs = ctx.lastStreamMessages.filter((m) => m.type === `message_received`);
1878
+ (0, vitest.expect)(msgs.length).toBe(3);
1879
+ for (let i = 0; i < 3; i++) (0, vitest.expect)(msgs[i].value?.payload).toEqual({ seq: i + 1 });
1880
+ }).run();
1881
+ });
1882
+ (0, vitest.test)(`send without from is rejected`, () => electricAgents(config.baseUrl).subscription(`/nofrom-test-agent/**`, `nofrom-test-sub`).registerType({
1883
+ name: `nofrom-test-agent`,
1884
+ description: `Test entity type for send without from`,
1885
+ creation_schema: { type: `object` }
1886
+ }).spawn(`nofrom-test-agent`, `entity-1`).custom(async (ctx) => {
1887
+ const res = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
1888
+ method: `POST`,
1889
+ body: JSON.stringify({ payload: { hello: true } })
1890
+ });
1891
+ (0, vitest.expect)(res.status).toBe(400);
1892
+ const body = await res.json();
1893
+ (0, vitest.expect)(body.error.code).toBe(`INVALID_REQUEST`);
1894
+ }).run());
1895
+ });
1896
+ (0, vitest.describe)(`Electric Agents List`, () => {
1897
+ (0, vitest.test)(`ps lists entities`, () => electricAgents(config.baseUrl).subscription(`/ps-test-worker/**`, `ps-test-worker-sub`).subscription(`/ps-test-agent/**`, `ps-test-agent-sub`).registerType({
1898
+ name: `ps-test-worker`,
1899
+ description: `Test worker type for ps`,
1900
+ creation_schema: { type: `object` }
1901
+ }).registerType({
1902
+ name: `ps-test-agent`,
1903
+ description: `Test agent type for ps`,
1904
+ creation_schema: { type: `object` }
1905
+ }).spawn(`ps-test-worker`, `entity-1`).spawn(`ps-test-agent`, `entity-2`).list().expectListCount({ min: 2 }).list({ type: `ps-test-agent` }).custom(async (ctx) => {
1906
+ (0, vitest.expect)(ctx.lastListResult.every((e) => e.type === `ps-test-agent`)).toBe(true);
1907
+ }).run());
1908
+ (0, vitest.test)(`list with status filter`, () => {
1909
+ return electricAgents(config.baseUrl).subscription(`/status-filter-agent/**`, `status-filter-sub`).registerType({
1910
+ name: `status-filter-agent`,
1911
+ description: `Test entity type for status filter`,
1912
+ creation_schema: { type: `object` }
1913
+ }).spawn(`status-filter-agent`, `entity-1`).spawn(`status-filter-agent`, `entity-2`).kill().list({ status: `stopped` }).custom(async (ctx) => {
1914
+ (0, vitest.expect)(ctx.lastListResult.every((e) => e.status === `stopped`)).toBe(true);
1915
+ }).list({ status: `running` }).custom(async (ctx) => {
1916
+ (0, vitest.expect)(ctx.lastListResult.every((e) => [`running`, `idle`].includes(e.status))).toBe(true);
1917
+ }).run();
1918
+ });
1919
+ (0, vitest.test)(`get nonexistent entity returns 404`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
1920
+ const res = await electricAgentsFetch(ctx.baseUrl, `/does-not-exist-type/does-not-exist`);
1921
+ (0, vitest.expect)(res.status).toBe(404);
1922
+ }).skipInvariants().run());
1923
+ });
1924
+ (0, vitest.describe)(`Electric Agents Kill`, () => {
1925
+ (0, vitest.test)(`kill stops entity and rejects further sends`, () => electricAgents(config.baseUrl).subscription(`/kill-test-agent/**`, `kill-test-sub`).registerType({
1926
+ name: `kill-test-agent`,
1927
+ description: `Test entity type for kill`,
1928
+ creation_schema: { type: `object` }
1929
+ }).spawn(`kill-test-agent`, `entity-1`).kill().expectStatus(`stopped`).expectSendError(`NOT_RUNNING`, 409).run());
1930
+ (0, vitest.test)(`stream data persists after kill`, () => electricAgents(config.baseUrl).subscription(`/kill-persist-agent/**`, `kill-persist-sub`).registerType({
1931
+ name: `kill-persist-agent`,
1932
+ description: `Test entity type for kill persistence`,
1933
+ creation_schema: { type: `object` }
1934
+ }).spawn(`kill-persist-agent`, `entity-1`).send({ before: `kill` }, { from: `test` }).expectWebhook().respondDone().kill().readStream().expectStreamContains(`message_received`).custom(async (ctx) => {
1935
+ const msgs = ctx.lastStreamMessages;
1936
+ const msgReceived = msgs.find((m) => m.type === `message_received`);
1937
+ (0, vitest.expect)(msgReceived.value?.payload).toEqual({ before: `kill` });
1938
+ const stopped = msgs.find((m) => m.type === `entity_stopped`);
1939
+ (0, vitest.expect)(stopped).toBeDefined();
1940
+ }).run());
1941
+ vitest.test.skip(`multiple entities under same subscription`, () => {
1942
+ let firstEntityUrl = null;
1943
+ let secondEntityUrl = null;
1944
+ return electricAgents(config.baseUrl).subscription(`/multi-test-worker/**`, `multi-test-sub`).registerType({
1945
+ name: `multi-test-worker`,
1946
+ description: `Test entity type for multi-entity`,
1947
+ creation_schema: { type: `object` }
1948
+ }).spawn(`multi-test-worker`, `entity-1`).custom(async (ctx) => {
1949
+ firstEntityUrl = ctx.currentEntityUrl;
1950
+ }).spawn(`multi-test-worker`, `entity-2`).custom(async (ctx) => {
1951
+ secondEntityUrl = ctx.currentEntityUrl;
1952
+ }).custom(async (ctx) => {
1953
+ const res = await electricAgentsFetch(ctx.baseUrl, firstEntityUrl, { method: `DELETE` });
1954
+ (0, vitest.expect)(res.status).toBe(200);
1955
+ ctx.history.push({
1956
+ type: `entity_killed`,
1957
+ entityUrl: firstEntityUrl
1958
+ });
1959
+ }).custom(async (ctx) => {
1960
+ const res1 = await electricAgentsFetch(ctx.baseUrl, firstEntityUrl);
1961
+ const e1 = await res1.json();
1962
+ (0, vitest.expect)(e1.status).toBe(`stopped`);
1963
+ const res2 = await electricAgentsFetch(ctx.baseUrl, secondEntityUrl);
1964
+ const e2 = await res2.json();
1965
+ (0, vitest.expect)([`running`, `idle`]).toContain(e2.status);
1966
+ }).run();
1967
+ });
1968
+ });
1969
+ (0, vitest.describe)(`Electric Agents E2E`, () => {
1970
+ (0, vitest.test)(`full lifecycle: spawn → send → webhook → read → kill`, () => electricAgents(config.baseUrl).subscription(`/e2e-test-agent/**`, `e2e-test-sub`).registerType({
1971
+ name: `e2e-test-agent`,
1972
+ description: `Test entity type for E2E lifecycle`,
1973
+ creation_schema: { type: `object` }
1974
+ }).spawn(`e2e-test-agent`, `entity-1`).expectStatus(`running`).send({ task: `do-something` }, { from: `user-1` }).expectWebhook().expectEntityContext({ type: `e2e-test-agent` }).respondDone().readStream().expectStreamContains(`message_received`).kill().custom(async (ctx) => {
1975
+ const entity = await pollEntityStatus(ctx.baseUrl, ctx.currentEntityUrl, [`stopped`]);
1976
+ (0, vitest.expect)(entity.status).toBe(`stopped`);
1977
+ }).expectSendError(`NOT_RUNNING`, 409).run());
1978
+ });
1979
+ (0, vitest.describe)(`Electric Agents Entity Type Registration`, () => {
1980
+ (0, vitest.test)(`register entity type`, () => electricAgents(config.baseUrl).subscription(`/reg-type-test/**`, `reg-type-test-sub`).registerType({
1981
+ name: `reg-full-type-${Date.now()}`,
1982
+ description: `A fully-specified entity type`,
1983
+ creation_schema: {
1984
+ type: `object`,
1985
+ properties: { name: { type: `string` } }
1986
+ },
1987
+ input_schemas: { query: {
1988
+ type: `object`,
1989
+ properties: { text: { type: `string` } },
1990
+ required: [`text`]
1991
+ } },
1992
+ output_schemas: { result: {
1993
+ type: `object`,
1994
+ properties: { answer: { type: `string` } }
1995
+ } }
1996
+ }).custom(async (ctx) => {
1997
+ const typeEvents = ctx.history.filter((h) => h.type === `entity_type_registered`);
1998
+ (0, vitest.expect)(typeEvents.length).toBeGreaterThanOrEqual(1);
1999
+ const registered = typeEvents[0];
2000
+ (0, vitest.expect)(registered.type).toBe(`entity_type_registered`);
2001
+ }).run());
2002
+ (0, vitest.test)(`list entity types`, () => {
2003
+ const suffix = Date.now();
2004
+ return electricAgents(config.baseUrl).subscription(`/list-types-test/**`, `list-types-sub`).registerType({
2005
+ name: `list-type-a-${suffix}`,
2006
+ description: `First type for listing`,
2007
+ creation_schema: { type: `object` }
2008
+ }).registerType({
2009
+ name: `list-type-b-${suffix}`,
2010
+ description: `Second type for listing`,
2011
+ creation_schema: { type: `object` }
2012
+ }).listTypes().custom(async (ctx) => {
2013
+ (0, vitest.expect)(ctx.lastTypeListResult).toBeDefined();
2014
+ const names = ctx.lastTypeListResult.map((t) => t.name);
2015
+ (0, vitest.expect)(names).toContain(`list-type-a-${suffix}`);
2016
+ (0, vitest.expect)(names).toContain(`list-type-b-${suffix}`);
2017
+ }).run();
2018
+ });
2019
+ (0, vitest.test)(`inspect entity type`, () => {
2020
+ const typeName = `inspect-type-${Date.now()}`;
2021
+ return electricAgents(config.baseUrl).subscription(`/inspect-type-test/**`, `inspect-type-sub`).registerType({
2022
+ name: typeName,
2023
+ description: `Type for inspection`,
2024
+ creation_schema: {
2025
+ type: `object`,
2026
+ properties: { x: { type: `number` } }
2027
+ },
2028
+ input_schemas: { ping: {
2029
+ type: `object`,
2030
+ properties: { msg: { type: `string` } }
2031
+ } }
2032
+ }).inspectType(typeName).custom(async (ctx) => {
2033
+ (0, vitest.expect)(ctx.lastTypeResult).toBeDefined();
2034
+ (0, vitest.expect)(ctx.lastTypeResult.name).toBe(typeName);
2035
+ (0, vitest.expect)(ctx.lastTypeResult.creation_schema).toBeDefined();
2036
+ (0, vitest.expect)(ctx.lastTypeResult.inbox_schemas).toBeDefined();
2037
+ }).run();
2038
+ });
2039
+ (0, vitest.test)(`delete entity type`, () => {
2040
+ const typeName = `delete-type-${Date.now()}`;
2041
+ return electricAgents(config.baseUrl).subscription(`/delete-type-test/**`, `delete-type-sub`).registerType({
2042
+ name: typeName,
2043
+ description: `Type to be deleted`,
2044
+ creation_schema: { type: `object` }
2045
+ }).deleteType(typeName).expectTypeNotExists(typeName).run();
2046
+ });
2047
+ (0, vitest.test)(`register upserts duplicate name`, () => {
2048
+ const typeName = `dup-type-${Date.now()}`;
2049
+ return electricAgents(config.baseUrl).subscription(`/dup-type-test/**`, `dup-type-sub`).registerType({
2050
+ name: typeName,
2051
+ description: `First registration`,
2052
+ creation_schema: { type: `object` }
2053
+ }).custom(async (ctx) => {
2054
+ const res = await fetch(`${ctx.baseUrl}/_electric/entity-types`, {
2055
+ method: `POST`,
2056
+ headers: { "content-type": `application/json` },
2057
+ body: JSON.stringify({
2058
+ name: typeName,
2059
+ description: `Duplicate registration`,
2060
+ creation_schema: { type: `object` }
2061
+ })
2062
+ });
2063
+ (0, vitest.expect)(res.status).toBe(201);
2064
+ const body = await res.json();
2065
+ (0, vitest.expect)(body.name).toBe(typeName);
2066
+ (0, vitest.expect)(body.description).toBe(`Duplicate registration`);
2067
+ (0, vitest.expect)(body.revision).toBe(2);
2068
+ }).run();
2069
+ });
2070
+ (0, vitest.test)(`register rejects missing required fields`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
2071
+ const res = await fetch(`${ctx.baseUrl}/_electric/entity-types`, {
2072
+ method: `POST`,
2073
+ headers: { "content-type": `application/json` },
2074
+ body: JSON.stringify({})
2075
+ });
2076
+ (0, vitest.expect)([400, 422]).toContain(res.status);
2077
+ }).skipInvariants().run());
2078
+ (0, vitest.test)(`register entity type without creation_schema`, () => {
2079
+ const typeName = `minimal-type-${Date.now()}`;
2080
+ return electricAgents(config.baseUrl).subscription(`/minimal-type-test/**`, `minimal-type-sub`).registerType({
2081
+ name: typeName,
2082
+ description: `A minimal entity type with no schemas`
2083
+ }).inspectType(typeName).custom(async (ctx) => {
2084
+ (0, vitest.expect)(ctx.lastTypeResult).toBeDefined();
2085
+ (0, vitest.expect)(ctx.lastTypeResult.name).toBe(typeName);
2086
+ (0, vitest.expect)(ctx.lastTypeResult.description).toBe(`A minimal entity type with no schemas`);
2087
+ }).run();
2088
+ });
2089
+ });
2090
+ (0, vitest.describe)(`Electric Agents Typed Spawn`, () => {
2091
+ (0, vitest.test)(`typed spawn validates creation_schema`, () => {
2092
+ const typeName = `typed-spawn-valid-${Date.now()}`;
2093
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `typed-spawn-sub`).registerType({
2094
+ name: typeName,
2095
+ description: `Type with creation schema`,
2096
+ creation_schema: {
2097
+ type: `object`,
2098
+ properties: { topic: { type: `string` } },
2099
+ required: [`topic`]
2100
+ }
2101
+ }).spawn(typeName, `entity-1`, { args: { topic: `CRDTs` } }).expectStatus(`running`).run();
2102
+ });
2103
+ (0, vitest.test)(`typed spawn rejects invalid args (C10)`, () => {
2104
+ const typeName = `typed-spawn-invalid-${Date.now()}`;
2105
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `typed-spawn-inv-sub`).registerType({
2106
+ name: typeName,
2107
+ description: `Type with strict creation schema`,
2108
+ creation_schema: {
2109
+ type: `object`,
2110
+ properties: { topic: { type: `string` } },
2111
+ required: [`topic`]
2112
+ }
2113
+ }).expectSpawnSchemaError(typeName, `bad-entity-1`, { args: { invalid: true } }).run();
2114
+ });
2115
+ (0, vitest.test)(`typed spawn at unregistered type returns UNKNOWN_ENTITY_TYPE (C9)`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
2116
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/nonexistent/should-fail`)}`, {
2117
+ method: `PUT`,
2118
+ headers: { "content-type": `application/json` },
2119
+ body: JSON.stringify({})
2120
+ });
2121
+ (0, vitest.expect)(res.status).toBe(404);
2122
+ const body = await res.json();
2123
+ (0, vitest.expect)(body.error.code).toBe(`UNKNOWN_ENTITY_TYPE`);
2124
+ }).skipInvariants().run());
2125
+ (0, vitest.test)(`spawn with parent`, () => {
2126
+ const typeName = `parent-spawn-${Date.now()}`;
2127
+ let parentUrl = null;
2128
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `parent-spawn-sub`).registerType({
2129
+ name: typeName,
2130
+ description: `Type for parent-child test`,
2131
+ creation_schema: { type: `object` }
2132
+ }).spawn(typeName, `parent-entity`).custom(async (ctx) => {
2133
+ parentUrl = ctx.currentEntityUrl;
2134
+ }).custom(async (ctx) => {
2135
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/${typeName}/child-entity`)}`, {
2136
+ method: `PUT`,
2137
+ headers: { "content-type": `application/json` },
2138
+ body: JSON.stringify({ parent: parentUrl })
2139
+ });
2140
+ (0, vitest.expect)(res.status).toBe(201);
2141
+ const entity = await res.json();
2142
+ ctx.currentEntityUrl = entity.url;
2143
+ ctx.currentEntityStreams = entity.streams;
2144
+ ctx.currentWriteToken = null;
2145
+ ctx.history.push({
2146
+ type: `entity_spawned`,
2147
+ entityUrl: entity.url,
2148
+ entityType: typeName,
2149
+ status: entity.status,
2150
+ streams: entity.streams,
2151
+ parent: parentUrl
2152
+ });
2153
+ }).expectStatus(`running`).run();
2154
+ });
2155
+ (0, vitest.test)(`spawn with parent rejects missing parent`, () => {
2156
+ const typeName = `orphan-spawn-${Date.now()}`;
2157
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `orphan-spawn-sub`).registerType({
2158
+ name: typeName,
2159
+ description: `Type for orphan spawn test`,
2160
+ creation_schema: { type: `object` }
2161
+ }).custom(async (ctx) => {
2162
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/${typeName}/orphan-entity`)}`, {
2163
+ method: `PUT`,
2164
+ headers: { "content-type": `application/json` },
2165
+ body: JSON.stringify({ parent: `/nonexistent/parent` })
2166
+ });
2167
+ (0, vitest.expect)([
2168
+ 400,
2169
+ 404,
2170
+ 422
2171
+ ]).toContain(res.status);
2172
+ }).run();
2173
+ });
2174
+ (0, vitest.test)(`spawn with explicit tags sets tags`, () => {
2175
+ const typeName = `tags-spawn-${Date.now()}`;
2176
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `meta-spawn-sub`).registerType({
2177
+ name: typeName,
2178
+ description: `Type for tag spawn test`
2179
+ }).spawn(typeName, `inst-1`, { tags: {
2180
+ color: `blue`,
2181
+ count: `42`
2182
+ } }).custom(async (ctx) => {
2183
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(ctx.currentEntityUrl)}`);
2184
+ const entity = await res.json();
2185
+ const tags = entity.tags;
2186
+ (0, vitest.expect)(tags.color).toBe(`blue`);
2187
+ (0, vitest.expect)(tags.count).toBe(`42`);
2188
+ }).run();
2189
+ });
2190
+ (0, vitest.test)(`spawn rejects non-string tags`, () => {
2191
+ const id = Date.now();
2192
+ const typeName = `tags-invalid-spawn-${id}`;
2193
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-invalid-spawn-sub-${id}`).registerType({
2194
+ name: typeName,
2195
+ description: `Type with string-only tags`
2196
+ }).custom(async (ctx) => {
2197
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`/${typeName}/should-fail`)}`, {
2198
+ method: `PUT`,
2199
+ headers: { "content-type": `application/json` },
2200
+ body: JSON.stringify({ tags: { wrong: 123 } })
2201
+ });
2202
+ (0, vitest.expect)(res.status).toBe(400);
2203
+ }).run();
2204
+ });
2205
+ (0, vitest.test)(`spawn without explicit tags defaults to empty tags`, () => {
2206
+ const typeName = `tags-default-${Date.now()}`;
2207
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-default-sub`).registerType({
2208
+ name: typeName,
2209
+ description: `Type with default empty tags`
2210
+ }).spawn(typeName, `entity-1`).custom(async (ctx) => {
2211
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(ctx.currentEntityUrl)}`);
2212
+ const entity = await res.json();
2213
+ (0, vitest.expect)(entity.tags).toEqual({});
2214
+ }).run();
2215
+ });
2216
+ });
2217
+ (0, vitest.describe)(`Electric Agents Schema Validation Gates`, () => {
2218
+ (0, vitest.test)(`send validates input_schemas (C11)`, () => {
2219
+ const typeName = `send-schema-valid-${Date.now()}`;
2220
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-schema-sub`).registerType({
2221
+ name: typeName,
2222
+ description: `Type with input schemas`,
2223
+ creation_schema: { type: `object` },
2224
+ input_schemas: { query: {
2225
+ type: `object`,
2226
+ properties: { text: { type: `string` } },
2227
+ required: [`text`]
2228
+ } }
2229
+ }).spawn(typeName, `entity-1`).send({ text: `hello` }, {
2230
+ from: `user`,
2231
+ type: `query`
2232
+ }).expectWebhook().respondDone().run();
2233
+ });
2234
+ (0, vitest.test)(`send rejects invalid typed message (C11)`, () => {
2235
+ const typeName = `send-schema-inv-${Date.now()}`;
2236
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-schema-inv-sub`).registerType({
2237
+ name: typeName,
2238
+ description: `Type with strict input schemas`,
2239
+ creation_schema: { type: `object` },
2240
+ input_schemas: { query: {
2241
+ type: `object`,
2242
+ properties: { text: { type: `string` } },
2243
+ required: [`text`]
2244
+ } }
2245
+ }).spawn(typeName, `entity-1`).expectSendSchemaError({ invalid: true }, {
2246
+ from: `user`,
2247
+ type: `query`
2248
+ }).run();
2249
+ });
2250
+ (0, vitest.test)(`send rejects unknown message type (C13)`, () => {
2251
+ const typeName = `send-unknown-type-${Date.now()}`;
2252
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-unknown-sub`).registerType({
2253
+ name: typeName,
2254
+ description: `Type with defined input schemas`,
2255
+ creation_schema: { type: `object` },
2256
+ input_schemas: { query: {
2257
+ type: `object`,
2258
+ properties: { text: { type: `string` } },
2259
+ required: [`text`]
2260
+ } }
2261
+ }).spawn(typeName, `entity-1`).expectSendUnknownType({ text: `hi` }, {
2262
+ from: `user`,
2263
+ type: `unknown_type`
2264
+ }).run();
2265
+ });
2266
+ (0, vitest.test)(`send without type when no input_schemas accepts any`, () => {
2267
+ const typeName = `send-no-schemas-${Date.now()}`;
2268
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-noschema-sub`).registerType({
2269
+ name: typeName,
2270
+ description: `Type without input schemas`,
2271
+ creation_schema: { type: `object` }
2272
+ }).spawn(typeName, `entity-1`).send({ anything: `goes` }, { from: `user` }).expectWebhook().respondDone().run();
2273
+ });
2274
+ (0, vitest.test)(`send with empty input_schemas rejects all`, () => {
2275
+ const typeName = `send-empty-schemas-${Date.now()}`;
2276
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `send-empty-sub`).registerType({
2277
+ name: typeName,
2278
+ description: `Type with empty input schemas`,
2279
+ creation_schema: { type: `object` },
2280
+ input_schemas: {}
2281
+ }).spawn(typeName, `entity-1`).expectSendUnknownType({ text: `anything` }, {
2282
+ from: `user`,
2283
+ type: `some_type`
2284
+ }).run();
2285
+ });
2286
+ vitest.test.skip(`write appends event to entity stream`, () => {
2287
+ const typeName = `write-append-${Date.now()}`;
2288
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-append-sub`).registerType({
2289
+ name: typeName,
2290
+ description: `Type for write test`,
2291
+ creation_schema: { type: `object` },
2292
+ output_schemas: { research_result: {
2293
+ type: `object`,
2294
+ properties: { findings: {
2295
+ type: `array`,
2296
+ items: { type: `string` }
2297
+ } }
2298
+ } }
2299
+ }).spawn(typeName, `entity-1`).write({ findings: [`test`] }, { type: `research_result` }).readStream().expectStreamContains(`research_result`).run();
2300
+ });
2301
+ vitest.test.skip(`write validates output_schemas (C12)`, () => {
2302
+ const typeName = `write-schema-inv-${Date.now()}`;
2303
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-schema-sub`).registerType({
2304
+ name: typeName,
2305
+ description: `Type with strict output schemas`,
2306
+ creation_schema: { type: `object` },
2307
+ output_schemas: { result: {
2308
+ type: `object`,
2309
+ properties: { value: { type: `number` } },
2310
+ required: [`value`]
2311
+ } }
2312
+ }).spawn(typeName, `entity-1`).expectWriteSchemaError({ wrong: `type` }, { type: `result` }).run();
2313
+ });
2314
+ vitest.test.skip(`write rejects unknown event type (C14)`, () => {
2315
+ const typeName = `write-unknown-type-${Date.now()}`;
2316
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-unknown-sub`).registerType({
2317
+ name: typeName,
2318
+ description: `Type with defined output schemas`,
2319
+ creation_schema: { type: `object` },
2320
+ output_schemas: { result: {
2321
+ type: `object`,
2322
+ properties: { value: { type: `number` } }
2323
+ } }
2324
+ }).spawn(typeName, `entity-1`).expectWriteUnknownType({ data: `test` }, { type: `unknown_event` }).run();
2325
+ });
2326
+ vitest.test.skip(`write without type when no output_schemas accepts any`, () => {
2327
+ const typeName = `write-no-schemas-${Date.now()}`;
2328
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-noschema-sub`).registerType({
2329
+ name: typeName,
2330
+ description: `Type without output schemas`,
2331
+ creation_schema: { type: `object` }
2332
+ }).spawn(typeName, `entity-1`).write({ anything: `goes` }).run();
2333
+ });
2334
+ vitest.test.skip(`write to stopped entity rejected`, () => {
2335
+ const typeName = `write-stopped-${Date.now()}`;
2336
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `write-stopped-sub`).registerType({
2337
+ name: typeName,
2338
+ description: `Type for stopped write test`,
2339
+ creation_schema: { type: `object` }
2340
+ }).spawn(typeName, `entity-1`).kill().custom(async (ctx) => {
2341
+ const writeHeaders = { "content-type": `application/json` };
2342
+ if (ctx.currentWriteToken) writeHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
2343
+ const res = await fetch(`${ctx.baseUrl}${ctx.currentEntityUrl}/main`, {
2344
+ method: `POST`,
2345
+ headers: writeHeaders,
2346
+ body: JSON.stringify({
2347
+ type: `test`,
2348
+ key: `stopped-test`,
2349
+ value: { should: `fail` },
2350
+ headers: { operation: `insert` }
2351
+ })
2352
+ });
2353
+ (0, vitest.expect)(res.status).toBe(409);
2354
+ }).run();
2355
+ });
2356
+ vitest.test.skip(`write accepts State Protocol format (type/key/value/headers)`, () => {
2357
+ const typeName = `sp-write-agent-${Date.now()}`;
2358
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `sp-write-sub`).registerType({
2359
+ name: typeName,
2360
+ description: `Test State Protocol write format`
2361
+ }).spawn(typeName, `sp-w-1`).writeStateProtocol({
2362
+ type: `text`,
2363
+ key: `msg-1`,
2364
+ value: { status: `streaming` },
2365
+ headers: { operation: `insert` }
2366
+ }).readStream().custom(async (ctx) => {
2367
+ const events = ctx.lastStreamMessages;
2368
+ const textEvent = events.find((e) => e.type === `text` && e.key === `msg-1`);
2369
+ (0, vitest.expect)(textEvent).toBeDefined();
2370
+ (0, vitest.expect)(textEvent.value.status).toBe(`streaming`);
2371
+ (0, vitest.expect)(textEvent.headers.operation).toBe(`insert`);
2372
+ }).kill().run();
2373
+ });
2374
+ vitest.test.skip(`update tags`, () => {
2375
+ const typeName = `tags-update-${Date.now()}`;
2376
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-update-sub`).registerType({
2377
+ name: typeName,
2378
+ description: `Type for tag update test`,
2379
+ creation_schema: { type: `object` }
2380
+ }).spawn(typeName, `entity-1`).setTags({
2381
+ owner: `test-user`,
2382
+ priority: `high`
2383
+ }).expectTags({
2384
+ owner: `test-user`,
2385
+ priority: `high`
2386
+ }).run();
2387
+ });
2388
+ vitest.test.skip(`tag write rejects non-string values`, () => {
2389
+ const typeName = `tags-invalid-${Date.now()}`;
2390
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-invalid-sub`).registerType({
2391
+ name: typeName,
2392
+ description: `Type with string-only tags`,
2393
+ creation_schema: { type: `object` }
2394
+ }).spawn(typeName, `entity-1`).custom(async (ctx) => {
2395
+ const tagHeaders = { "content-type": `application/json` };
2396
+ if (ctx.currentWriteToken) tagHeaders[`authorization`] = `Bearer ${ctx.currentWriteToken}`;
2397
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/tags/owner`)}`, {
2398
+ method: `POST`,
2399
+ headers: tagHeaders,
2400
+ body: JSON.stringify({ value: 123 })
2401
+ });
2402
+ (0, vitest.expect)(res.status).toBe(400);
2403
+ }).run();
2404
+ });
2405
+ vitest.test.skip(`tag delete removes a key`, () => {
2406
+ const typeName = `tags-delete-${Date.now()}`;
2407
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-delete-sub`).registerType({
2408
+ name: typeName,
2409
+ description: `Type for tag delete test`,
2410
+ creation_schema: { type: `object` }
2411
+ }).spawn(typeName, `entity-1`).setTags({
2412
+ owner: `test-user`,
2413
+ priority: `high`
2414
+ }).custom(async (ctx) => {
2415
+ const tagHeaders = {};
2416
+ if (ctx.currentWriteToken) tagHeaders.authorization = `Bearer ${ctx.currentWriteToken}`;
2417
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/tags/priority`)}`, {
2418
+ method: `DELETE`,
2419
+ headers: tagHeaders
2420
+ });
2421
+ (0, vitest.expect)(res.status).toBe(200);
2422
+ }).expectTags({ owner: `test-user` }).run();
2423
+ });
2424
+ vitest.test.skip(`tag writes merge by key`, () => {
2425
+ const typeName = `tags-merge-${Date.now()}`;
2426
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `tags-merge-sub`).registerType({
2427
+ name: typeName,
2428
+ description: `Type for tag merge test`,
2429
+ creation_schema: { type: `object` }
2430
+ }).spawn(typeName, `entity-1`).setTags({
2431
+ key1: `value1`,
2432
+ key2: `value2`
2433
+ }).setTags({
2434
+ key2: `updated`,
2435
+ key3: `value3`
2436
+ }).expectTags({
2437
+ key1: `value1`,
2438
+ key2: `updated`,
2439
+ key3: `value3`
2440
+ }).run();
2441
+ });
2442
+ });
2443
+ (0, vitest.describe)(`Electric Agents Schema Evolution`, () => {
2444
+ (0, vitest.test)(`amend schemas adds new message types`, () => {
2445
+ const typeName = `amend-add-${Date.now()}`;
2446
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `amend-add-sub`).registerType({
2447
+ name: typeName,
2448
+ description: `Type for schema amendment`,
2449
+ creation_schema: { type: `object` },
2450
+ input_schemas: { query: {
2451
+ type: `object`,
2452
+ properties: { text: { type: `string` } },
2453
+ required: [`text`]
2454
+ } }
2455
+ }).amendSchemas(typeName, { input_schemas: { command: {
2456
+ type: `object`,
2457
+ properties: { action: { type: `string` } },
2458
+ required: [`action`]
2459
+ } } }).spawn(typeName, `entity-1`).send({ action: `do-it` }, {
2460
+ from: `user`,
2461
+ type: `command`
2462
+ }).expectWebhook().respondDone().run();
2463
+ });
2464
+ (0, vitest.test)(`amend schemas rejects modifying existing keys (C16)`, () => {
2465
+ const typeName = `amend-conflict-${Date.now()}`;
2466
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `amend-conflict-sub`).registerType({
2467
+ name: typeName,
2468
+ description: `Type for schema conflict test`,
2469
+ creation_schema: { type: `object` },
2470
+ input_schemas: { query: {
2471
+ type: `object`,
2472
+ properties: { text: { type: `string` } },
2473
+ required: [`text`]
2474
+ } }
2475
+ }).custom(async (ctx) => {
2476
+ const res = await fetch(`${ctx.baseUrl}/_electric/entity-types/${typeName}/schemas`, {
2477
+ method: `PATCH`,
2478
+ headers: { "content-type": `application/json` },
2479
+ body: JSON.stringify({ inbox_schemas: { query: {
2480
+ type: `object`,
2481
+ properties: { text: { type: `number` } }
2482
+ } } })
2483
+ });
2484
+ (0, vitest.expect)(res.status).toBe(409);
2485
+ }).run();
2486
+ });
2487
+ vitest.test.skip(`schema amendments apply to existing entities`, () => {
2488
+ const typeName = `rev-pin-${Date.now()}`;
2489
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `rev-pin-sub`).registerType({
2490
+ name: typeName,
2491
+ description: `Type for revision pinning test`,
2492
+ creation_schema: { type: `object` },
2493
+ input_schemas: { query: {
2494
+ type: `object`,
2495
+ properties: { text: { type: `string` } },
2496
+ required: [`text`]
2497
+ } }
2498
+ }).spawn(typeName, `entity-1`).custom(async (ctx) => {
2499
+ const res = await fetch(`${ctx.baseUrl}/_electric/entity-types/${typeName}/schemas`, {
2500
+ method: `PATCH`,
2501
+ headers: { "content-type": `application/json` },
2502
+ body: JSON.stringify({ inbox_schemas: { new_command: {
2503
+ type: `object`,
2504
+ properties: { action: { type: `string` } }
2505
+ } } })
2506
+ });
2507
+ (0, vitest.expect)(res.status).toBe(200);
2508
+ }).custom(async (ctx) => {
2509
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/send`)}`, {
2510
+ method: `POST`,
2511
+ headers: { "content-type": `application/json` },
2512
+ body: JSON.stringify({
2513
+ type: `new_command`,
2514
+ from: `user`,
2515
+ payload: { action: `test` }
2516
+ })
2517
+ });
2518
+ (0, vitest.expect)(res.status).toBe(204);
2519
+ }).run();
2520
+ });
2521
+ });
2522
+ (0, vitest.describe)(`Electric Agents Serve Endpoint`, () => {
2523
+ (0, vitest.test)(`register type via serve endpoint`, () => {
2524
+ const typeName = `serve-reg-${Date.now()}`;
2525
+ return electricAgents(config.baseUrl).subscription(`/${typeName}/**`, `serve-reg-sub`).registerTypeViaServe({
2526
+ name: typeName,
2527
+ description: `Type registered via serve endpoint`,
2528
+ creation_schema: { type: `object` }
2529
+ }).expectTypeExists(typeName).run();
2530
+ });
2531
+ (0, vitest.test)(`serve endpoint name mismatch rejected`, () => electricAgents(config.baseUrl).custom(async (ctx) => {
2532
+ const receiver = new ServeEndpointReceiver();
2533
+ const manifest = {
2534
+ name: `foo`,
2535
+ description: `Manifest says foo`,
2536
+ creation_schema: { type: `object` }
2537
+ };
2538
+ await receiver.start(manifest);
2539
+ try {
2540
+ const res = await fetch(`${ctx.baseUrl}/_electric/entity-types`, {
2541
+ method: `POST`,
2542
+ headers: { "content-type": `application/json` },
2543
+ body: JSON.stringify({
2544
+ name: `bar`,
2545
+ serve_endpoint: receiver.url
2546
+ })
2547
+ });
2548
+ (0, vitest.expect)([
2549
+ 400,
2550
+ 409,
2551
+ 422
2552
+ ]).toContain(res.status);
2553
+ } finally {
2554
+ await receiver.stop();
2555
+ }
2556
+ }).skipInvariants().run());
2557
+ });
2558
+ (0, vitest.describe)(`Property-Based: Random Action Sequences`, () => {
2559
+ const actionArb = fast_check.oneof({
2560
+ weight: 15,
2561
+ arbitrary: fast_check.constant(`register_type`)
2562
+ }, {
2563
+ weight: 5,
2564
+ arbitrary: fast_check.constant(`delete_type`)
2565
+ }, {
2566
+ weight: 25,
2567
+ arbitrary: fast_check.constant(`spawn`)
2568
+ }, {
2569
+ weight: 20,
2570
+ arbitrary: fast_check.constant(`send`)
2571
+ }, {
2572
+ weight: 10,
2573
+ arbitrary: fast_check.constant(`kill`)
2574
+ }, {
2575
+ weight: 5,
2576
+ arbitrary: fast_check.constant(`check_status`)
2577
+ }, {
2578
+ weight: 5,
2579
+ arbitrary: fast_check.constant(`list`)
2580
+ });
2581
+ (0, vitest.test)(`random action sequences preserve safety invariants`, async () => {
2582
+ await fast_check.assert(fast_check.asyncProperty(fast_check.array(actionArb, {
2583
+ minLength: 3,
2584
+ maxLength: 12
2585
+ }), async (actions) => {
2586
+ const runId = `prop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2587
+ const baseUrl = config.baseUrl;
2588
+ const entityUrls = [];
2589
+ const registeredTypeNames = [];
2590
+ const scenario = electricAgents(baseUrl);
2591
+ let model = {
2592
+ entityTypes: [],
2593
+ entities: [],
2594
+ nextEntityNum: 0
2595
+ };
2596
+ let entityCounter = 0;
2597
+ for (const action of actions) {
2598
+ const valid = enabledElectricAgentsActions(model);
2599
+ if (!valid.includes(action)) continue;
2600
+ switch (action) {
2601
+ case `register_type`: {
2602
+ const typeNum = model.entityTypes.length;
2603
+ const typeName = `prop-type-${runId}-${typeNum}`;
2604
+ registeredTypeNames.push(typeName);
2605
+ scenario.subscription(`/${typeName}/**`, `prop-sub-${typeName}`);
2606
+ scenario.registerType({
2607
+ name: typeName,
2608
+ description: `Property-based test type ${typeNum}`,
2609
+ creation_schema: { type: `object` }
2610
+ });
2611
+ model = applyElectricAgentsAction(model, `register_type`);
2612
+ break;
2613
+ }
2614
+ case `delete_type`: {
2615
+ const runningModelTypeNames = new Set(model.entities.filter((e) => e.status === `running`).map((e) => e.typeName));
2616
+ const deletableIndices = model.entityTypes.map((t, i) => !runningModelTypeNames.has(t.name) ? i : -1).filter((i) => i >= 0);
2617
+ if (deletableIndices.length === 0) break;
2618
+ const deleteIdx = deletableIndices[0];
2619
+ scenario.deleteType(registeredTypeNames[deleteIdx]);
2620
+ registeredTypeNames.splice(deleteIdx, 1);
2621
+ model = applyElectricAgentsAction(model, `delete_type`);
2622
+ break;
2623
+ }
2624
+ case `spawn`: {
2625
+ if (registeredTypeNames.length === 0) break;
2626
+ const typeName = registeredTypeNames[0];
2627
+ const instanceId = `entity-${entityCounter++}`;
2628
+ scenario.spawn(typeName, instanceId);
2629
+ scenario.custom(async (ctx) => {
2630
+ entityUrls.push(ctx.currentEntityUrl);
2631
+ });
2632
+ model = applyElectricAgentsAction(model, `spawn`);
2633
+ break;
2634
+ }
2635
+ case `send`: {
2636
+ const runningIdxs = model.entities.map((e, i) => e.status === `running` ? i : -1).filter((i) => i >= 0);
2637
+ if (runningIdxs.length === 0) break;
2638
+ const targetIdx = runningIdxs[Math.floor(Math.random() * runningIdxs.length)];
2639
+ scenario.custom(async (ctx) => {
2640
+ const url = entityUrls[targetIdx];
2641
+ if (!url) return;
2642
+ const res = await electricAgentsFetch(ctx.baseUrl, `${url}/send`, {
2643
+ method: `POST`,
2644
+ body: JSON.stringify({
2645
+ from: `prop-test`,
2646
+ payload: {
2647
+ action: `prop-send`,
2648
+ targetIdx
2649
+ }
2650
+ })
2651
+ });
2652
+ (0, vitest.expect)(res.status).toBe(204);
2653
+ ctx.history.push({
2654
+ type: `message_sent`,
2655
+ entityUrl: url,
2656
+ payload: {
2657
+ action: `prop-send`,
2658
+ targetIdx
2659
+ },
2660
+ from: `prop-test`
2661
+ });
2662
+ });
2663
+ scenario.expectWebhook();
2664
+ scenario.respondDone();
2665
+ model = applyElectricAgentsAction(model, `send`, targetIdx);
2666
+ break;
2667
+ }
2668
+ case `kill`: {
2669
+ const runningIdxs = model.entities.map((e, i) => e.status === `running` ? i : -1).filter((i) => i >= 0);
2670
+ if (runningIdxs.length === 0) break;
2671
+ const targetIdx = runningIdxs[Math.floor(Math.random() * runningIdxs.length)];
2672
+ scenario.custom(async (ctx) => {
2673
+ const url = entityUrls[targetIdx];
2674
+ if (!url) return;
2675
+ const res = await electricAgentsFetch(ctx.baseUrl, url, { method: `DELETE` });
2676
+ (0, vitest.expect)(res.status).toBe(200);
2677
+ ctx.history.push({
2678
+ type: `entity_killed`,
2679
+ entityUrl: url
2680
+ });
2681
+ });
2682
+ model = applyElectricAgentsAction(model, `kill`, targetIdx);
2683
+ break;
2684
+ }
2685
+ case `check_status`: {
2686
+ const anyIdx = model.entities.length > 0 ? 0 : -1;
2687
+ if (anyIdx < 0) break;
2688
+ const expectedStatus = model.entities[anyIdx].status;
2689
+ scenario.custom(async (ctx) => {
2690
+ const url = entityUrls[anyIdx];
2691
+ if (!url) return;
2692
+ const res = await electricAgentsFetch(ctx.baseUrl, url);
2693
+ (0, vitest.expect)(res.status).toBe(200);
2694
+ const entity = await res.json();
2695
+ if (expectedStatus === `running`) (0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
2696
+ else (0, vitest.expect)(entity.status).toBe(expectedStatus);
2697
+ ctx.history.push({
2698
+ type: `entity_status_checked`,
2699
+ entityUrl: url,
2700
+ status: entity.status
2701
+ });
2702
+ });
2703
+ model = applyElectricAgentsAction(model, `check_status`);
2704
+ break;
2705
+ }
2706
+ case `list`: {
2707
+ scenario.list();
2708
+ model = applyElectricAgentsAction(model, `list`);
2709
+ break;
2710
+ }
2711
+ }
2712
+ }
2713
+ const history = await scenario.run();
2714
+ checkInvariants(history);
2715
+ }), {
2716
+ numRuns: 15,
2717
+ endOnFailure: true
2718
+ });
2719
+ }, 3e4);
2720
+ });
2721
+ vitest.describe.skip(`Electric Agents - StreamDB Materialization`, () => {
2722
+ (0, vitest.test)(`State Protocol events materialize with correct structure`, () => electricAgents(config.baseUrl).subscription(`/mat-test-agent/**`, `mat-test-sub`).registerType({
2723
+ name: `mat-test-agent`,
2724
+ description: `Test materialization`
2725
+ }).spawn(`mat-test-agent`, `mat-1`).writeStateProtocol({
2726
+ type: `run`,
2727
+ key: `run-0`,
2728
+ value: { status: `started` },
2729
+ headers: { operation: `insert` }
2730
+ }).writeStateProtocol({
2731
+ type: `step`,
2732
+ key: `step-0`,
2733
+ value: {
2734
+ status: `started`,
2735
+ step_number: 1
2736
+ },
2737
+ headers: {
2738
+ operation: `insert`,
2739
+ model_provider: `anthropic`,
2740
+ model_id: `claude-sonnet-4-5`
2741
+ }
2742
+ }).writeStateProtocol({
2743
+ type: `text`,
2744
+ key: `msg-0`,
2745
+ value: { status: `streaming` },
2746
+ headers: { operation: `insert` }
2747
+ }).writeStateProtocol({
2748
+ type: `text_delta`,
2749
+ key: `msg-0:0`,
2750
+ value: {
2751
+ delta: `Hello `,
2752
+ text_id: `msg-0`
2753
+ },
2754
+ headers: { operation: `insert` }
2755
+ }).writeStateProtocol({
2756
+ type: `text_delta`,
2757
+ key: `msg-0:1`,
2758
+ value: {
2759
+ delta: `world`,
2760
+ text_id: `msg-0`
2761
+ },
2762
+ headers: { operation: `insert` }
2763
+ }).writeStateProtocol({
2764
+ type: `text`,
2765
+ key: `msg-0`,
2766
+ value: { status: `completed` },
2767
+ headers: { operation: `update` }
2768
+ }).writeStateProtocol({
2769
+ type: `step`,
2770
+ key: `step-0`,
2771
+ value: {
2772
+ status: `completed`,
2773
+ step_number: 1,
2774
+ finish_reason: `stop`
2775
+ },
2776
+ headers: {
2777
+ operation: `update`,
2778
+ duration_ms: 1500,
2779
+ token_input: 10,
2780
+ token_output: 5
2781
+ }
2782
+ }).writeStateProtocol({
2783
+ type: `run`,
2784
+ key: `run-0`,
2785
+ value: {
2786
+ status: `completed`,
2787
+ finish_reason: `stop`
2788
+ },
2789
+ headers: {
2790
+ operation: `update`,
2791
+ duration_ms: 2e3
2792
+ }
2793
+ }).readStream().custom(async (ctx) => {
2794
+ const events = ctx.lastStreamMessages;
2795
+ const spEvents = events.filter((e) => e.type && e.key && e.headers);
2796
+ const runEvents = spEvents.filter((e) => e.type === `run`);
2797
+ (0, vitest.expect)(runEvents.length).toBe(2);
2798
+ (0, vitest.expect)(runEvents[0].headers.operation).toBe(`insert`);
2799
+ (0, vitest.expect)(runEvents[1].headers.operation).toBe(`update`);
2800
+ const stepEvents = spEvents.filter((e) => e.type === `step`);
2801
+ (0, vitest.expect)(stepEvents.length).toBe(2);
2802
+ (0, vitest.expect)(stepEvents[0].headers.operation).toBe(`insert`);
2803
+ (0, vitest.expect)(stepEvents[1].headers.operation).toBe(`update`);
2804
+ const textEvents = spEvents.filter((e) => e.type === `text`);
2805
+ (0, vitest.expect)(textEvents.length).toBe(2);
2806
+ (0, vitest.expect)(textEvents[0].headers.operation).toBe(`insert`);
2807
+ (0, vitest.expect)(textEvents[1].headers.operation).toBe(`update`);
2808
+ const deltas = spEvents.filter((e) => e.type === `text_delta`);
2809
+ (0, vitest.expect)(deltas.length).toBe(2);
2810
+ (0, vitest.expect)(deltas[0].key).toBe(`msg-0:0`);
2811
+ (0, vitest.expect)(deltas[1].key).toBe(`msg-0:1`);
2812
+ const completedText = textEvents.find((e) => e.headers.operation === `update`);
2813
+ (0, vitest.expect)(completedText.value.status).toBe(`completed`);
2814
+ checkStateProtocolInvariants(spEvents);
2815
+ }).kill().run());
2816
+ (0, vitest.test)(`tool call state machine follows started → executing → completed`, () => electricAgents(config.baseUrl).subscription(`/tc-mat-agent/**`, `tc-mat-sub`).registerType({
2817
+ name: `tc-mat-agent`,
2818
+ description: `Test tool call materialization`
2819
+ }).spawn(`tc-mat-agent`, `tc-mat-1`).writeStateProtocol({
2820
+ type: `run`,
2821
+ key: `run-0`,
2822
+ value: { status: `started` },
2823
+ headers: { operation: `insert` }
2824
+ }).writeStateProtocol({
2825
+ type: `step`,
2826
+ key: `step-0`,
2827
+ value: {
2828
+ status: `started`,
2829
+ step_number: 1
2830
+ },
2831
+ headers: { operation: `insert` }
2832
+ }).writeStateProtocol({
2833
+ type: `tool_call`,
2834
+ key: `tc-0`,
2835
+ value: {
2836
+ tool_name: `web_search`,
2837
+ status: `started`
2838
+ },
2839
+ headers: { operation: `insert` }
2840
+ }).writeStateProtocol({
2841
+ type: `tool_call`,
2842
+ key: `tc-0`,
2843
+ value: {
2844
+ tool_name: `web_search`,
2845
+ status: `executing`
2846
+ },
2847
+ headers: { operation: `update` }
2848
+ }).writeStateProtocol({
2849
+ type: `tool_call`,
2850
+ key: `tc-0`,
2851
+ value: {
2852
+ tool_name: `web_search`,
2853
+ result: `Search results here`,
2854
+ status: `completed`
2855
+ },
2856
+ headers: {
2857
+ operation: `update`,
2858
+ duration_ms: 500
2859
+ }
2860
+ }).readStream().custom(async (ctx) => {
2861
+ const events = ctx.lastStreamMessages;
2862
+ const tcEvents = events.filter((e) => e.type === `tool_call`);
2863
+ (0, vitest.expect)(tcEvents.length).toBe(3);
2864
+ (0, vitest.expect)(tcEvents[0].value.status).toBe(`started`);
2865
+ (0, vitest.expect)(tcEvents[0].headers.operation).toBe(`insert`);
2866
+ (0, vitest.expect)(tcEvents[1].value.status).toBe(`executing`);
2867
+ (0, vitest.expect)(tcEvents[1].headers.operation).toBe(`update`);
2868
+ (0, vitest.expect)(tcEvents[2].value.status).toBe(`completed`);
2869
+ (0, vitest.expect)(tcEvents[2].headers.operation).toBe(`update`);
2870
+ checkStateProtocolInvariants(events.filter((e) => e.type && e.key && e.headers));
2871
+ }).kill().run());
2872
+ (0, vitest.test)(`insert creates new entries, update modifies existing entries by key`, () => electricAgents(config.baseUrl).subscription(`/upsert-agent/**`, `upsert-sub`).registerType({
2873
+ name: `upsert-agent`,
2874
+ description: `Test insert/update semantics`
2875
+ }).spawn(`upsert-agent`, `upsert-1`).writeStateProtocol({
2876
+ type: `text`,
2877
+ key: `msg-0`,
2878
+ value: { status: `streaming` },
2879
+ headers: { operation: `insert` }
2880
+ }).writeStateProtocol({
2881
+ type: `text`,
2882
+ key: `msg-0`,
2883
+ value: { status: `completed` },
2884
+ headers: { operation: `update` }
2885
+ }).writeStateProtocol({
2886
+ type: `text`,
2887
+ key: `msg-1`,
2888
+ value: { status: `streaming` },
2889
+ headers: { operation: `insert` }
2890
+ }).readStream().custom(async (ctx) => {
2891
+ const events = ctx.lastStreamMessages;
2892
+ const textEvents = events.filter((e) => e.type === `text`);
2893
+ (0, vitest.expect)(textEvents.length).toBe(3);
2894
+ const keys = textEvents.map((e) => e.key);
2895
+ (0, vitest.expect)(keys).toContain(`msg-0`);
2896
+ (0, vitest.expect)(keys).toContain(`msg-1`);
2897
+ }).kill().run());
2898
+ });
2899
+ (0, vitest.describe)(`Electric Agents - Tool Tests`, () => {
2900
+ /** Extract text from the first content block of a tool result. */
2901
+ function firstText(result) {
2902
+ const block = result.content[0];
2903
+ return block?.type === `text` && block.text ? block.text : ``;
2904
+ }
2905
+ (0, vitest.test)(`bash tool captures stdout and stderr`, async () => {
2906
+ const { createBashTool } = await import(`../../agents-runtime/src/tools`);
2907
+ const tool = createBashTool(`/tmp`);
2908
+ const result = await tool.execute(`test-tc`, { command: `echo "hello" && echo "error" >&2` });
2909
+ (0, vitest.expect)(firstText(result)).toContain(`hello`);
2910
+ (0, vitest.expect)(firstText(result)).toContain(`error`);
2911
+ (0, vitest.expect)(result.details.exitCode).toBe(0);
2912
+ });
2913
+ (0, vitest.test)(`bash tool enforces timeout`, async () => {
2914
+ const { createBashTool } = await import(`../../agents-runtime/src/tools`);
2915
+ const tool = createBashTool(`/tmp`);
2916
+ const result = await tool.execute(`test-tc`, { command: `sleep 60` });
2917
+ (0, vitest.expect)(result.details.timedOut).toBe(true);
2918
+ }, 35e3);
2919
+ (0, vitest.test)(`read_file rejects paths outside working directory`, async () => {
2920
+ const { createReadFileTool } = await import(`../../agents-runtime/src/tools`);
2921
+ const tool = createReadFileTool(`/tmp/test-workdir`);
2922
+ const result = await tool.execute(`test-tc`, { path: `../../etc/passwd` });
2923
+ (0, vitest.expect)(firstText(result)).toContain(`outside the working directory`);
2924
+ });
2925
+ (0, vitest.test)(`read_file rejects binary files`, async () => {
2926
+ const { createReadFileTool } = await import(`../../agents-runtime/src/tools`);
2927
+ const fs = await import(`node:fs/promises`);
2928
+ const path = await import(`node:path`);
2929
+ const dir = `/tmp/test-binary-${Date.now()}`;
2930
+ await fs.mkdir(dir, { recursive: true });
2931
+ const binPath = path.join(dir, `test.bin`);
2932
+ await fs.writeFile(binPath, Buffer.from([
2933
+ 0,
2934
+ 1,
2935
+ 2,
2936
+ 255
2937
+ ]));
2938
+ const tool = createReadFileTool(dir);
2939
+ const result = await tool.execute(`test-tc`, { path: `test.bin` });
2940
+ (0, vitest.expect)(firstText(result)).toContain(`binary file`);
2941
+ await fs.rm(dir, { recursive: true });
2942
+ });
2943
+ (0, vitest.test)(`read_file rejects oversized files`, async () => {
2944
+ const { createReadFileTool } = await import(`../../agents-runtime/src/tools`);
2945
+ const fs = await import(`node:fs/promises`);
2946
+ const path = await import(`node:path`);
2947
+ const dir = `/tmp/test-size-${Date.now()}`;
2948
+ await fs.mkdir(dir, { recursive: true });
2949
+ const bigPath = path.join(dir, `big.txt`);
2950
+ await fs.writeFile(bigPath, `x`.repeat(600 * 1024));
2951
+ const tool = createReadFileTool(dir);
2952
+ const result = await tool.execute(`test-tc`, { path: `big.txt` });
2953
+ (0, vitest.expect)(firstText(result)).toContain(`too large`);
2954
+ await fs.rm(dir, { recursive: true });
2955
+ });
2956
+ (0, vitest.test)(`web_search tool has correct interface`, async () => {
2957
+ const { braveSearchTool } = await import(`../../agents-runtime/src/tools`);
2958
+ (0, vitest.expect)(braveSearchTool.name).toBe(`web_search`);
2959
+ (0, vitest.expect)(typeof braveSearchTool.execute).toBe(`function`);
2960
+ });
2961
+ (0, vitest.test)(`fetch_url tool has correct interface`, async () => {
2962
+ const { fetchUrlTool } = await import(`../../agents-runtime/src/tools`);
2963
+ (0, vitest.expect)(fetchUrlTool.name).toBe(`fetch_url`);
2964
+ (0, vitest.expect)(typeof fetchUrlTool.execute).toBe(`function`);
2965
+ });
2966
+ });
2967
+ (0, vitest.describe)(`Electric Agents Send — State Protocol Format`, () => {
2968
+ (0, vitest.test)(`send() writes events in State Protocol format`, () => electricAgents(config.baseUrl).subscription(`/sp-send-worker/**`, `sp-send-sub`).registerType({
2969
+ name: `sp-send-worker`,
2970
+ description: `Test entity type for SP send format`,
2971
+ creation_schema: { type: `object` }
2972
+ }).spawn(`sp-send-worker`, `entity-1`).send({ text: `hello world` }, { from: `user-1` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
2973
+ const events = ctx.lastStreamMessages;
2974
+ const msgEvent = events.find((e) => e.type === `message_received`);
2975
+ (0, vitest.expect)(msgEvent.type).toBe(`message_received`);
2976
+ (0, vitest.expect)(msgEvent.key).toBeDefined();
2977
+ (0, vitest.expect)(msgEvent.value?.from).toBe(`user-1`);
2978
+ (0, vitest.expect)(msgEvent.value?.payload).toEqual({ text: `hello world` });
2979
+ (0, vitest.expect)(msgEvent.headers).toBeDefined();
2980
+ }).run());
2981
+ (0, vitest.test)(`send() events pass State Protocol invariant checks`, () => electricAgents(config.baseUrl).subscription(`/sp-inv-worker/**`, `sp-inv-sub`).registerType({
2982
+ name: `sp-inv-worker`,
2983
+ description: `Test entity type for SP invariant checks`,
2984
+ creation_schema: { type: `object` }
2985
+ }).spawn(`sp-inv-worker`, `entity-1`).send({ text: `first` }, { from: `tester` }).expectWebhook().respondDone().send({ text: `second` }, { from: `tester` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
2986
+ const events = ctx.lastStreamMessages;
2987
+ const messageEvents = events.filter((ev) => ev.type === `message_received`);
2988
+ (0, vitest.expect)(messageEvents.length).toBe(2);
2989
+ checkStateProtocolInvariants(events);
2990
+ }).run());
2991
+ (0, vitest.test)(`multiple sends produce unique sequential keys`, () => {
2992
+ const id = Date.now();
2993
+ return electricAgents(config.baseUrl).subscription(`/sp-keys-agent-${id}/**`, `sp-keys-sub-${id}`).registerType({
2994
+ name: `sp-keys-agent-${id}`,
2995
+ description: `Test entity type for send key uniqueness`,
2996
+ creation_schema: { type: `object` }
2997
+ }).spawn(`sp-keys-agent-${id}`, `entity-1`).send({ seq: 1 }, { from: `test` }).expectWebhook().respondDone().send({ seq: 2 }, { from: `test` }).expectWebhook().respondDone().send({ seq: 3 }, { from: `test` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
2998
+ const events = ctx.lastStreamMessages.filter((e) => e.type === `message_received`);
2999
+ (0, vitest.expect)(events.length).toBe(3);
3000
+ for (let i = 0; i < 3; i++) (0, vitest.expect)(events[i].value?.payload).toEqual({ seq: i + 1 });
3001
+ }).run();
3002
+ });
3003
+ });
3004
+ (0, vitest.describe)(`Electric Agents findLastUserMessage — State Protocol`, () => {
3005
+ (0, vitest.test)(`send event is parseable by webhook handler`, () => {
3006
+ const id = Date.now();
3007
+ return electricAgents(config.baseUrl).subscription(`/flum-agent-${id}/**`, `flum-sub-${id}`).registerType({
3008
+ name: `flum-agent-${id}`,
3009
+ description: `Test send event format for webhook parsing`,
3010
+ creation_schema: { type: `object` }
3011
+ }).spawn(`flum-agent-${id}`, `entity-1`).send({ text: `test message for parsing` }, { from: `tester` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
3012
+ const events = ctx.lastStreamMessages;
3013
+ const msgEvent = events.find((e) => e.type === `message_received`);
3014
+ (0, vitest.expect)(msgEvent).toBeDefined();
3015
+ (0, vitest.expect)(msgEvent.value?.payload).toBeDefined();
3016
+ const payload = msgEvent.value.payload;
3017
+ (0, vitest.expect)(payload.text).toBe(`test message for parsing`);
3018
+ }).run();
3019
+ });
3020
+ (0, vitest.test)(`stream events after send are all State Protocol format`, () => {
3021
+ const id = Date.now();
3022
+ return electricAgents(config.baseUrl).subscription(`/flum-all-agent-${id}/**`, `flum-all-sub-${id}`).registerType({
3023
+ name: `flum-all-agent-${id}`,
3024
+ description: `Test all events are SP format`,
3025
+ creation_schema: { type: `object` }
3026
+ }).spawn(`flum-all-agent-${id}`, `entity-1`).send({ text: `verify all SP` }, { from: `tester` }).expectWebhook().respondDone().readStream().custom(async (ctx) => {
3027
+ const events = ctx.lastStreamMessages;
3028
+ (0, vitest.expect)(events.length).toBeGreaterThanOrEqual(1);
3029
+ const hasMsg = events.some((e) => e.type === `message_received`);
3030
+ (0, vitest.expect)(hasMsg).toBe(true);
3031
+ for (const ev of events) {
3032
+ (0, vitest.expect)(ev.type, `every event must have type`).toBeDefined();
3033
+ (0, vitest.expect)(ev.key, `SP events must have key`).toBeDefined();
3034
+ (0, vitest.expect)(ev.headers, `SP events must have headers`).toBeDefined();
3035
+ }
3036
+ }).run();
3037
+ });
3038
+ });
3039
+ (0, vitest.describe)(`Error Paths`, () => {
3040
+ (0, vitest.test)(`kill nonexistent entity with registered type returns 404`, async () => {
3041
+ const typeName = `kill-miss-${Date.now()}`;
3042
+ await electricAgentsFetch(config.baseUrl, `/_electric/entity-types`, {
3043
+ method: `POST`,
3044
+ body: JSON.stringify({
3045
+ name: typeName,
3046
+ description: `test`
3047
+ })
3048
+ });
3049
+ const res = await electricAgentsFetch(config.baseUrl, `/${typeName}/nonexistent-id-12345`, { method: `DELETE` });
3050
+ (0, vitest.expect)(res.status).toBe(404);
3051
+ const body = await res.json();
3052
+ (0, vitest.expect)(body.error.code).toBe(`NOT_FOUND`);
3053
+ });
3054
+ vitest.test.skip(`kill already-stopped entity is idempotent`, () => {
3055
+ const id = Date.now();
3056
+ return electricAgents(config.baseUrl).subscription(`/kill-stopped-agent-${id}/**`, `kill-stopped-sub-${id}`).registerType({
3057
+ name: `kill-stopped-agent-${id}`,
3058
+ description: `Test killing stopped entity`,
3059
+ creation_schema: { type: `object` }
3060
+ }).spawn(`kill-stopped-agent-${id}`, `entity-1`).kill().custom(async (ctx) => {
3061
+ const res = await electricAgentsFetch(ctx.baseUrl, ctx.currentEntityUrl, { method: `DELETE` });
3062
+ (0, vitest.expect)(res.status).toBe(200);
3063
+ }).run();
3064
+ });
3065
+ (0, vitest.test)(`send without payload is rejected`, async () => {
3066
+ const id = Date.now();
3067
+ await electricAgents(config.baseUrl).subscription(`/no-payload-agent-${id}/**`, `no-payload-sub-${id}`).registerType({
3068
+ name: `no-payload-agent-${id}`,
3069
+ description: `Test send without payload`,
3070
+ creation_schema: { type: `object` }
3071
+ }).spawn(`no-payload-agent-${id}`, `entity-1`).custom(async (ctx) => {
3072
+ const res = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/send`, {
3073
+ method: `POST`,
3074
+ body: JSON.stringify({ from: `tester` })
3075
+ });
3076
+ (0, vitest.expect)(res.status).toBe(400);
3077
+ const body = await res.json();
3078
+ (0, vitest.expect)(body.error.code).toBe(`INVALID_REQUEST`);
3079
+ }).run();
3080
+ });
3081
+ (0, vitest.test)(`delete nonexistent entity type returns 404`, async () => {
3082
+ const res = await electricAgentsFetch(config.baseUrl, `/_electric/entity-types/nonexistent-type-xyz`, { method: `DELETE` });
3083
+ (0, vitest.expect)(res.status).toBe(404);
3084
+ });
3085
+ (0, vitest.test)(`delete entity type goes through state stream`, () => {
3086
+ const id = Date.now();
3087
+ return electricAgents(config.baseUrl).subscription(`/del-state-agent-${id}/**`, `del-state-sub-${id}`).registerType({
3088
+ name: `del-state-agent-${id}`,
3089
+ description: `Test delete via state stream`,
3090
+ creation_schema: { type: `object` }
3091
+ }).deleteType(`del-state-agent-${id}`).custom(async (ctx) => {
3092
+ const res = await electricAgentsFetch(ctx.baseUrl, `/_electric/entity-types/del-state-agent-${id}`);
3093
+ (0, vitest.expect)(res.status).toBe(404);
3094
+ }).run();
3095
+ });
3096
+ (0, vitest.test)(`tag update on stopped entity is rejected without claim`, () => {
3097
+ const id = Date.now();
3098
+ return electricAgents(config.baseUrl).subscription(`/meta-stopped-agent-${id}/**`, `meta-stopped-sub-${id}`).registerType({
3099
+ name: `meta-stopped-agent-${id}`,
3100
+ description: `Test tag write on stopped entity`,
3101
+ creation_schema: { type: `object` }
3102
+ }).spawn(`meta-stopped-agent-${id}`, `entity-1`).kill().custom(async (ctx) => {
3103
+ const res = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/tags/key`, {
3104
+ method: `POST`,
3105
+ body: JSON.stringify({ value: `value` })
3106
+ });
3107
+ (0, vitest.expect)(res.status).toBe(401);
3108
+ }).run();
3109
+ });
3110
+ });
3111
+ (0, vitest.describe)(`Type Revision Edge Cases`, () => {
3112
+ (0, vitest.test)(`existing entities accept schemas added across multiple amendments`, () => {
3113
+ const id = Date.now();
3114
+ return electricAgents(config.baseUrl).subscription(`/rev-multi-agent-${id}/**`, `rev-multi-sub-${id}`).registerType({
3115
+ name: `rev-multi-agent-${id}`,
3116
+ description: `Test multi-revision pinning`,
3117
+ creation_schema: { type: `object` },
3118
+ input_schemas: { greet: {
3119
+ type: `object`,
3120
+ properties: { name: { type: `string` } },
3121
+ required: [`name`]
3122
+ } }
3123
+ }).spawn(`rev-multi-agent-${id}`, `entity-rev1`).custom(async (ctx) => {
3124
+ const rev1EntityUrl = ctx.currentEntityUrl;
3125
+ await electricAgentsFetch(ctx.baseUrl, `/_electric/entity-types/rev-multi-agent-${id}/schemas`, {
3126
+ method: `PATCH`,
3127
+ body: JSON.stringify({ inbox_schemas: { farewell: {
3128
+ type: `object`,
3129
+ properties: { reason: { type: `string` } },
3130
+ required: [`reason`]
3131
+ } } })
3132
+ });
3133
+ await electricAgentsFetch(ctx.baseUrl, `/_electric/entity-types/rev-multi-agent-${id}/schemas`, {
3134
+ method: `PATCH`,
3135
+ body: JSON.stringify({ inbox_schemas: { status: {
3136
+ type: `object`,
3137
+ properties: { active: { type: `boolean` } }
3138
+ } } })
3139
+ });
3140
+ const res = await electricAgentsFetch(ctx.baseUrl, `${rev1EntityUrl}/send`, {
3141
+ method: `POST`,
3142
+ body: JSON.stringify({
3143
+ from: `tester`,
3144
+ payload: { reason: `bye` },
3145
+ type: `farewell`
3146
+ })
3147
+ });
3148
+ (0, vitest.expect)(res.status).toBe(204);
3149
+ }).run();
3150
+ });
3151
+ (0, vitest.test)(`amend schemas on deleted type returns 404`, async () => {
3152
+ const id = Date.now();
3153
+ await electricAgents(config.baseUrl).subscription(`/amend-del-agent-${id}/**`, `amend-del-sub-${id}`).registerType({
3154
+ name: `amend-del-agent-${id}`,
3155
+ description: `Test amend on deleted type`,
3156
+ creation_schema: { type: `object` }
3157
+ }).deleteType(`amend-del-agent-${id}`).custom(async (ctx) => {
3158
+ const res = await electricAgentsFetch(ctx.baseUrl, `/_electric/entity-types/amend-del-agent-${id}/schemas`, {
3159
+ method: `PATCH`,
3160
+ body: JSON.stringify({ input_schemas: { msg: { type: `object` } } })
3161
+ });
3162
+ (0, vitest.expect)(res.status).toBe(404);
3163
+ }).run();
3164
+ });
3165
+ });
3166
+ (0, vitest.describe)(`Concurrent Operations`, () => {
3167
+ (0, vitest.test)(`sequential tag updates accumulate`, async () => {
3168
+ const id = Date.now();
3169
+ await electricAgents(config.baseUrl).subscription(`/seq-meta-agent-${id}/**`, `seq-meta-sub-${id}`).registerType({
3170
+ name: `seq-meta-agent-${id}`,
3171
+ description: `Test sequential tag updates`,
3172
+ creation_schema: { type: `object` }
3173
+ }).spawn(`seq-meta-agent-${id}`, `entity-1`).send({ trigger: `claim` }, { from: `test` }).expectWebhook().custom(async (ctx) => {
3174
+ const notification = ctx.notification;
3175
+ const claimRes = await fetch(notification.parsed.callback, {
3176
+ method: `POST`,
3177
+ headers: {
3178
+ "content-type": `application/json`,
3179
+ authorization: `Bearer ${notification.parsed.token}`
3180
+ },
3181
+ body: JSON.stringify({
3182
+ epoch: notification.parsed.epoch,
3183
+ wakeId: notification.parsed.wake_id
3184
+ })
3185
+ });
3186
+ (0, vitest.expect)(claimRes.status).toBe(200);
3187
+ const claim = await claimRes.json();
3188
+ (0, vitest.expect)(claim.ok).toBe(true);
3189
+ (0, vitest.expect)(claim.writeToken).toBeTruthy();
3190
+ const tagHeaders = { authorization: `Bearer ${claim.writeToken}` };
3191
+ const r1 = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/tags/key1`, {
3192
+ method: `POST`,
3193
+ headers: tagHeaders,
3194
+ body: JSON.stringify({ value: `value1` })
3195
+ });
3196
+ (0, vitest.expect)(r1.status).toBe(200);
3197
+ const r2 = await electricAgentsFetch(ctx.baseUrl, `${ctx.currentEntityUrl}/tags/key2`, {
3198
+ method: `POST`,
3199
+ headers: tagHeaders,
3200
+ body: JSON.stringify({ value: `value2` })
3201
+ });
3202
+ (0, vitest.expect)(r2.status).toBe(200);
3203
+ const getRes = await electricAgentsFetch(ctx.baseUrl, ctx.currentEntityUrl);
3204
+ const entity = await getRes.json();
3205
+ (0, vitest.expect)(entity.tags.key1).toBe(`value1`);
3206
+ (0, vitest.expect)(entity.tags.key2).toBe(`value2`);
3207
+ }).respondDone().run();
3208
+ });
3209
+ (0, vitest.test)(`concurrent spawns under same type succeed`, async () => {
3210
+ const id = Date.now();
3211
+ await electricAgents(config.baseUrl).subscription(`/conc-spawn-agent-${id}/**`, `conc-spawn-sub-${id}`).registerType({
3212
+ name: `conc-spawn-agent-${id}`,
3213
+ description: `Test concurrent spawns`,
3214
+ creation_schema: { type: `object` }
3215
+ }).custom(async (ctx) => {
3216
+ const results = await Promise.all(Array.from({ length: 5 }, (_, i) => electricAgentsFetch(ctx.baseUrl, `/conc-spawn-agent-${id}/concurrent-${i}`, {
3217
+ method: `PUT`,
3218
+ body: JSON.stringify({})
3219
+ })));
3220
+ for (const res of results) (0, vitest.expect)(res.status).toBe(201);
3221
+ for (let i = 0; i < 5; i++) {
3222
+ const res = await electricAgentsFetch(ctx.baseUrl, `/conc-spawn-agent-${id}/concurrent-${i}`);
3223
+ (0, vitest.expect)(res.status).toBe(200);
3224
+ }
3225
+ }).run();
3226
+ });
3227
+ });
3228
+ (0, vitest.describe)(`Electric Agents Auth Boundary`, () => {
3229
+ (0, vitest.test)(`write to entity stream without token is rejected`, () => {
3230
+ const id = Date.now();
3231
+ return electricAgents(config.baseUrl).subscription(`/auth-notoken-agent-${id}/**`, `auth-notoken-sub-${id}`).registerType({
3232
+ name: `auth-notoken-agent-${id}`,
3233
+ description: `Test write without token`,
3234
+ creation_schema: { type: `object` }
3235
+ }).spawn(`auth-notoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
3236
+ const res = await fetch(`${ctx.baseUrl}${ctx.currentEntityUrl}/main`, {
3237
+ method: `POST`,
3238
+ headers: { "content-type": `application/json` },
3239
+ body: JSON.stringify({
3240
+ type: `default`,
3241
+ key: `test-${Date.now()}`,
3242
+ value: { data: `should-fail` },
3243
+ headers: { operation: `insert` }
3244
+ })
3245
+ });
3246
+ (0, vitest.expect)(res.status).toBe(401);
3247
+ }).run();
3248
+ });
3249
+ (0, vitest.test)(`write to entity stream with wrong token is rejected`, () => {
3250
+ const id = Date.now();
3251
+ return electricAgents(config.baseUrl).subscription(`/auth-wrongtoken-agent-${id}/**`, `auth-wrongtoken-sub-${id}`).registerType({
3252
+ name: `auth-wrongtoken-agent-${id}`,
3253
+ description: `Test write with wrong token`,
3254
+ creation_schema: { type: `object` }
3255
+ }).spawn(`auth-wrongtoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
3256
+ const res = await fetch(`${ctx.baseUrl}${ctx.currentEntityUrl}/main`, {
3257
+ method: `POST`,
3258
+ headers: {
3259
+ "content-type": `application/json`,
3260
+ authorization: `Bearer wrong-token`
3261
+ },
3262
+ body: JSON.stringify({
3263
+ type: `default`,
3264
+ key: `test-${Date.now()}`,
3265
+ value: { data: `should-fail` },
3266
+ headers: { operation: `insert` }
3267
+ })
3268
+ });
3269
+ (0, vitest.expect)(res.status).toBe(401);
3270
+ }).run();
3271
+ });
3272
+ (0, vitest.test)(`spawn does not expose a public entity write token`, () => {
3273
+ const id = Date.now();
3274
+ return electricAgents(config.baseUrl).subscription(`/auth-goodtoken-agent-${id}/**`, `auth-goodtoken-sub-${id}`).registerType({
3275
+ name: `auth-goodtoken-agent-${id}`,
3276
+ description: `Test write with correct token`,
3277
+ creation_schema: { type: `object` }
3278
+ }).spawn(`auth-goodtoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
3279
+ (0, vitest.expect)(ctx.currentWriteToken).toBeNull();
3280
+ }).run();
3281
+ });
3282
+ (0, vitest.test)(`tag update without token is rejected`, () => {
3283
+ const id = Date.now();
3284
+ return electricAgents(config.baseUrl).subscription(`/auth-meta-notoken-agent-${id}/**`, `auth-meta-notoken-sub-${id}`).registerType({
3285
+ name: `auth-meta-notoken-agent-${id}`,
3286
+ description: `Test tag update without token`,
3287
+ creation_schema: { type: `object` }
3288
+ }).spawn(`auth-meta-notoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
3289
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/tags/key`)}`, {
3290
+ method: `POST`,
3291
+ headers: { "content-type": `application/json` },
3292
+ body: JSON.stringify({ value: `value` })
3293
+ });
3294
+ (0, vitest.expect)(res.status).toBe(401);
3295
+ }).run();
3296
+ });
3297
+ (0, vitest.test)(`spawn does not expose a public tag write token`, () => {
3298
+ const id = Date.now();
3299
+ return electricAgents(config.baseUrl).subscription(`/auth-meta-goodtoken-agent-${id}/**`, `auth-meta-goodtoken-sub-${id}`).registerType({
3300
+ name: `auth-meta-goodtoken-agent-${id}`,
3301
+ description: `Test tag update with correct token`,
3302
+ creation_schema: { type: `object` }
3303
+ }).spawn(`auth-meta-goodtoken-agent-${id}`, `entity-1`).custom(async (ctx) => {
3304
+ (0, vitest.expect)(ctx.currentWriteToken).toBeNull();
3305
+ }).run();
3306
+ });
3307
+ (0, vitest.test)(`send remains unauthenticated`, () => {
3308
+ const id = Date.now();
3309
+ return electricAgents(config.baseUrl).subscription(`/auth-send-noauth-agent-${id}/**`, `auth-send-noauth-sub-${id}`).registerType({
3310
+ name: `auth-send-noauth-agent-${id}`,
3311
+ description: `Test send without auth`,
3312
+ creation_schema: { type: `object` }
3313
+ }).spawn(`auth-send-noauth-agent-${id}`, `entity-1`).custom(async (ctx) => {
3314
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(`${ctx.currentEntityUrl}/send`)}`, {
3315
+ method: `POST`,
3316
+ headers: { "content-type": `application/json` },
3317
+ body: JSON.stringify({
3318
+ from: `tester`,
3319
+ payload: `hi`
3320
+ })
3321
+ });
3322
+ (0, vitest.expect)(res.status).toBe(204);
3323
+ }).expectWebhook().respondDone().run();
3324
+ });
3325
+ (0, vitest.test)(`GET entity does not leak write_token`, () => {
3326
+ const id = Date.now();
3327
+ return electricAgents(config.baseUrl).subscription(`/auth-noleak-agent-${id}/**`, `auth-noleak-sub-${id}`).registerType({
3328
+ name: `auth-noleak-agent-${id}`,
3329
+ description: `Test GET does not leak write_token`,
3330
+ creation_schema: { type: `object` }
3331
+ }).spawn(`auth-noleak-agent-${id}`, `entity-1`).custom(async (ctx) => {
3332
+ const res = await fetch(`${ctx.baseUrl}${routeControlPlanePath(ctx.currentEntityUrl)}`);
3333
+ (0, vitest.expect)(res.status).toBe(200);
3334
+ const entity = await res.json();
3335
+ (0, vitest.expect)(entity.write_token).toBeUndefined();
3336
+ (0, vitest.expect)(entity.subscription_id).toBeUndefined();
3337
+ }).run();
3338
+ });
3339
+ vitest.test.skip(`list entities does not leak write_token`, () => {
3340
+ const id = Date.now();
3341
+ return electricAgents(config.baseUrl).subscription(`/auth-listnoleak-agent-${id}/**`, `auth-listnoleak-sub-${id}`).registerType({
3342
+ name: `auth-listnoleak-agent-${id}`,
3343
+ description: `Test list does not leak write_token`,
3344
+ creation_schema: { type: `object` }
3345
+ }).spawn(`auth-listnoleak-agent-${id}`, `entity-1`).custom(async (ctx) => {
3346
+ const entities = await fetchShapeRows(ctx.baseUrl, `entities`);
3347
+ for (const entity of entities) {
3348
+ (0, vitest.expect)(entity).not.toHaveProperty(`write_token`);
3349
+ (0, vitest.expect)(entity).not.toHaveProperty(`subscription_id`);
3350
+ }
3351
+ }).run();
3352
+ });
3353
+ (0, vitest.test)(`write to non-entity stream requires no auth`, async () => {
3354
+ const id = Date.now();
3355
+ const streamPath = `/v1/stream/plain-stream-auth-test-${id}`;
3356
+ const createRes = await fetch(`${config.baseUrl}${streamPath}`, {
3357
+ method: `PUT`,
3358
+ headers: { "Content-Type": `text/plain` },
3359
+ body: `initial data`
3360
+ });
3361
+ (0, vitest.expect)(createRes.status).toBe(201);
3362
+ const writeRes = await fetch(`${config.baseUrl}${streamPath}`, {
3363
+ method: `POST`,
3364
+ headers: { "Content-Type": `text/plain` },
3365
+ body: ` more data`
3366
+ });
3367
+ (0, vitest.expect)([200, 204].includes(writeRes.status)).toBe(true);
3368
+ });
3369
+ });
3370
+ }
3371
+ function runCliConformanceTests(config) {
3372
+ (0, vitest.describe)(`CLI — Entity Types`, () => {
3373
+ (0, vitest.test)(`types lists registered types`, async () => {
3374
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3375
+ name: `cli-types-test`,
3376
+ description: `Type for CLI types test`
3377
+ }).exec(`types`).expectExitCode(0).expectStdout(/cli-types-test/).expectStdout(/Type for CLI types test/).run();
3378
+ });
3379
+ (0, vitest.test)(`types inspect shows type details`, async () => {
3380
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3381
+ name: `cli-inspect-type`,
3382
+ description: `Type for CLI inspect test`
3383
+ }).exec(`types`, `inspect`, `cli-inspect-type`).expectExitCode(0).expectStdout(/cli-inspect-type/).run();
3384
+ });
3385
+ (0, vitest.test)(`types inspect nonexistent type fails`, async () => {
3386
+ await cliTest(config.baseUrl, config.cliBin).exec(`types`, `inspect`, `nonexistent-type-xyz`).expectExitCode(1).expectStderr(/Error/).run();
3387
+ });
3388
+ (0, vitest.test)(`types delete removes type`, async () => {
3389
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3390
+ name: `cli-delete-type`,
3391
+ description: `Type to delete`
3392
+ }).exec(`types`, `delete`, `cli-delete-type`).expectExitCode(0).expectStdout(/Deleted/).exec(`types`).expectStdoutNot(/cli-delete-type/).run();
3393
+ });
3394
+ });
3395
+ (0, vitest.describe)(`CLI — Spawn`, () => {
3396
+ (0, vitest.test)(`spawn creates entity and reports URL`, async () => {
3397
+ const id = `cli-spawn-${Date.now()}`;
3398
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3399
+ name: `cli-spawn-type`,
3400
+ description: `Type for CLI spawn test`
3401
+ }).setupSubscription(`/cli-spawn-type/**`, `cli-spawn-sub`).exec(`spawn`, `/cli-spawn-type/${id}`).expectExitCode(0).expectStdout(/Spawned/).verifyApi(async (baseUrl) => {
3402
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(`/cli-spawn-type/${id}`)}`);
3403
+ (0, vitest.expect)(res.status).toBe(200);
3404
+ const entity = await res.json();
3405
+ (0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
3406
+ }).run();
3407
+ });
3408
+ (0, vitest.test)(`spawn with --args passes arguments`, async () => {
3409
+ const id = `cli-args-${Date.now()}`;
3410
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3411
+ name: `cli-args-type`,
3412
+ description: `Type for CLI args test`,
3413
+ creation_schema: { type: `object` }
3414
+ }).setupSubscription(`/cli-args-type/**`, `cli-args-sub`).exec(`spawn`, `/cli-args-type/${id}`, `--args`, `{"key":"value"}`).expectExitCode(0).expectStdout(/Spawned/).run();
3415
+ });
3416
+ (0, vitest.test)(`spawn unregistered type exits non-zero`, async () => {
3417
+ await cliTest(config.baseUrl, config.cliBin).exec(`spawn`, `/nonexistent-type-xyz/some-id`).expectExitCode(1).custom((last) => {
3418
+ (0, vitest.expect)(`${last.stdout}\n${last.stderr}`).toMatch(/Entity type "nonexistent-type-xyz" not found/);
3419
+ }).run();
3420
+ });
3421
+ });
3422
+ (0, vitest.describe)(`CLI — Process Listing (ps)`, () => {
3423
+ (0, vitest.test)(`ps lists spawned entities`, async () => {
3424
+ const id = `cli-ps-${Date.now()}`;
3425
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3426
+ name: `cli-ps-type`,
3427
+ description: `Type for CLI ps test`
3428
+ }).setupSubscription(`/cli-ps-type/**`, `cli-ps-sub`).exec(`spawn`, `/cli-ps-type/${id}`).expectExitCode(0).exec(`ps`).expectExitCode(0).expectStdout(/cli-ps-type/).run();
3429
+ });
3430
+ (0, vitest.test)(`ps --type filters by entity type`, async () => {
3431
+ const id = `cli-filter-${Date.now()}`;
3432
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3433
+ name: `cli-filter-type`,
3434
+ description: `Type for CLI filter test`
3435
+ }).setupSubscription(`/cli-filter-type/**`, `cli-filter-sub`).exec(`spawn`, `/cli-filter-type/${id}`).expectExitCode(0).exec(`ps`, `--type`, `cli-filter-type`).expectExitCode(0).expectStdout(/cli-filter-type/).run();
3436
+ });
3437
+ });
3438
+ (0, vitest.describe)(`CLI — Send`, () => {
3439
+ (0, vitest.test)(`send delivers message and verifies stream`, async () => {
3440
+ const id = `cli-send-${Date.now()}`;
3441
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3442
+ name: `cli-send-type`,
3443
+ description: `Type for CLI send test`
3444
+ }).setupSubscription(`/cli-send-type/**`, `cli-send-sub`).exec(`spawn`, `/cli-send-type/${id}`).expectExitCode(0).exec(`send`, `/cli-send-type/${id}`, `hello world`).expectExitCode(0).expectStdout(/Message sent/).verifyApi(async (baseUrl) => {
3445
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(`/cli-send-type/${id}`)}`);
3446
+ (0, vitest.expect)(res.status).toBe(200);
3447
+ const entity = await res.json();
3448
+ const streams = entity.streams;
3449
+ const streamRes = await fetch(`${baseUrl}${streams.main}?offset=0000000000000000_0000000000000000`);
3450
+ const events = await streamRes.json();
3451
+ (0, vitest.expect)(events.length).toBeGreaterThanOrEqual(1);
3452
+ const msgEvent = events.find((e) => e.type === `message_received`);
3453
+ (0, vitest.expect)(msgEvent).toBeDefined();
3454
+ const payload = msgEvent.value?.payload;
3455
+ (0, vitest.expect)(payload.text).toBe(`hello world`);
3456
+ }).run();
3457
+ });
3458
+ (0, vitest.test)(`send to nonexistent entity fails`, async () => {
3459
+ await cliTest(config.baseUrl, config.cliBin).exec(`send`, `/nonexistent/entity`, `hello`).expectExitCode(1).expectStderr(/Error/).run();
3460
+ });
3461
+ });
3462
+ (0, vitest.describe)(`CLI — Inspect`, () => {
3463
+ (0, vitest.test)(`inspect shows entity details`, async () => {
3464
+ const id = `cli-inspect-${Date.now()}`;
3465
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3466
+ name: `cli-inspect-etype`,
3467
+ description: `Type for CLI inspect test`
3468
+ }).setupSubscription(`/cli-inspect-etype/**`, `cli-inspect-sub`).exec(`spawn`, `/cli-inspect-etype/${id}`).expectExitCode(0).exec(`inspect`, `/cli-inspect-etype/${id}`).expectExitCode(0).expectStdout(/running|idle/).verifyApi(async (baseUrl) => {
3469
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(`/cli-inspect-etype/${id}`)}`);
3470
+ (0, vitest.expect)(res.status).toBe(200);
3471
+ const entity = await res.json();
3472
+ (0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
3473
+ }).run();
3474
+ });
3475
+ (0, vitest.test)(`inspect nonexistent entity fails`, async () => {
3476
+ await cliTest(config.baseUrl, config.cliBin).exec(`inspect`, `/nonexistent/entity`).expectExitCode(1).expectStderr(/Error/).run();
3477
+ });
3478
+ });
3479
+ (0, vitest.describe)(`CLI — Kill`, () => {
3480
+ (0, vitest.test)(`kill stops a running entity`, async () => {
3481
+ const id = `cli-kill-${Date.now()}`;
3482
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3483
+ name: `cli-kill-type`,
3484
+ description: `Type for CLI kill test`
3485
+ }).setupSubscription(`/cli-kill-type/**`, `cli-kill-sub`).exec(`spawn`, `/cli-kill-type/${id}`).expectExitCode(0).exec(`kill`, `/cli-kill-type/${id}`).expectExitCode(0).expectStdout(/Killed/).verifyApi(async (baseUrl) => {
3486
+ const entity = await pollEntityStatus(baseUrl, `/cli-kill-type/${id}`, [`stopped`]);
3487
+ (0, vitest.expect)(entity.status).toBe(`stopped`);
3488
+ }).run();
3489
+ }, 15e3);
3490
+ (0, vitest.test)(`kill nonexistent entity fails`, async () => {
3491
+ await cliTest(config.baseUrl, config.cliBin).exec(`kill`, `/nonexistent/entity`).expectExitCode(1).expectStderr(/Error/).run();
3492
+ });
3493
+ });
3494
+ (0, vitest.describe)(`CLI — Full Lifecycle`, () => {
3495
+ (0, vitest.test)(`spawn → send → inspect → kill with API verification`, async () => {
3496
+ const id = `cli-lifecycle-${Date.now()}`;
3497
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3498
+ name: `cli-lifecycle-type`,
3499
+ description: `Type for full lifecycle test`
3500
+ }).setupSubscription(`/cli-lifecycle-type/**`, `cli-lifecycle-sub`).exec(`spawn`, `/cli-lifecycle-type/${id}`).expectExitCode(0).expectStdout(/Spawned/).verifyApi(async (baseUrl) => {
3501
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(`/cli-lifecycle-type/${id}`)}`);
3502
+ (0, vitest.expect)(res.status, `entity should exist after spawn`).toBe(200);
3503
+ const entity = await res.json();
3504
+ (0, vitest.expect)([`running`, `idle`]).toContain(entity.status);
3505
+ }).exec(`send`, `/cli-lifecycle-type/${id}`, `test message`).expectExitCode(0).expectStdout(/Message sent/).exec(`inspect`, `/cli-lifecycle-type/${id}`).expectExitCode(0).expectStdout(/running|idle/).exec(`kill`, `/cli-lifecycle-type/${id}`).expectExitCode(0).expectStdout(/Killed/).verifyApi(async (baseUrl) => {
3506
+ const entity = await pollEntityStatus(baseUrl, `/cli-lifecycle-type/${id}`, [`stopped`]);
3507
+ (0, vitest.expect)(entity.status).toBe(`stopped`);
3508
+ }).exec(`ps`, `--status`, `running`).expectExitCode(0).expectStdoutNot(new RegExp(id)).run();
3509
+ }, 15e3);
3510
+ (0, vitest.test)(`send to stopped entity fails`, async () => {
3511
+ const id = `cli-stopped-${Date.now()}`;
3512
+ await cliTest(config.baseUrl, config.cliBin).setupType({
3513
+ name: `cli-stopped-type`,
3514
+ description: `Type for stopped entity test`
3515
+ }).setupSubscription(`/cli-stopped-type/**`, `cli-stopped-sub`).exec(`spawn`, `/cli-stopped-type/${id}`).expectExitCode(0).exec(`kill`, `/cli-stopped-type/${id}`).expectExitCode(0).exec(`send`, `/cli-stopped-type/${id}`, `should fail`).expectExitCode(1).expectStderr(/Error/).run();
3516
+ });
3517
+ });
3518
+ (0, vitest.describe)(`CLI — Usage Errors`, () => {
3519
+ (0, vitest.test)(`no arguments shows usage`, async () => {
3520
+ await cliTest(config.baseUrl, config.cliBin).exec().expectExitCode(1).run();
3521
+ });
3522
+ (0, vitest.test)(`spawn without arguments shows usage`, async () => {
3523
+ await cliTest(config.baseUrl, config.cliBin).exec(`spawn`).expectExitCode(1).run();
3524
+ });
3525
+ (0, vitest.test)(`send without URL shows usage`, async () => {
3526
+ await cliTest(config.baseUrl, config.cliBin).exec(`send`).expectExitCode(1).run();
3527
+ });
3528
+ });
3529
+ }
3530
+ function runMockAgentTests(config) {
3531
+ async function spawnEntity(baseUrl, typeName, instanceId) {
3532
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(`/${encodeURIComponent(typeName)}/${encodeURIComponent(instanceId)}`)}`, {
3533
+ method: `PUT`,
3534
+ headers: { "content-type": `application/json` },
3535
+ body: JSON.stringify({})
3536
+ });
3537
+ (0, vitest.expect)(res.ok, `spawn should succeed: ${res.status}`).toBe(true);
3538
+ return await res.json();
3539
+ }
3540
+ async function sendMessage(baseUrl, entityUrl, text) {
3541
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(`${entityUrl}/send`)}`, {
3542
+ method: `POST`,
3543
+ headers: { "content-type": `application/json` },
3544
+ body: JSON.stringify({
3545
+ payload: { text },
3546
+ from: `tester`
3547
+ })
3548
+ });
3549
+ (0, vitest.expect)(res.ok || res.status === 204, `send should succeed: ${res.status}`).toBe(true);
3550
+ }
3551
+ async function pollForAgentResponse(baseUrl, entityUrl, timeoutMs = 1e4) {
3552
+ const start = Date.now();
3553
+ while (Date.now() - start < timeoutMs) {
3554
+ const entityRes = await fetch(`${baseUrl}${routeControlPlanePath(entityUrl)}`);
3555
+ if (!entityRes.ok) throw new Error(`Entity ${entityUrl} not found`);
3556
+ const entity = await entityRes.json();
3557
+ const streams = entity.streams;
3558
+ const streamRes = await fetch(`${baseUrl}${streams.main}?offset=0000000000000000_0000000000000000`);
3559
+ const events = await streamRes.json();
3560
+ const hasRunComplete = events.some((e) => e.type === `run` && e.headers?.operation === `update`);
3561
+ if (hasRunComplete) return events;
3562
+ await new Promise((r) => setTimeout(r, 200));
3563
+ }
3564
+ throw new Error(`Agent did not respond within ${timeoutMs}ms`);
3565
+ }
3566
+ (0, vitest.describe)(`Mock Agent — End-to-End Pipeline`, () => {
3567
+ (0, vitest.test)(`send message → mock agent writes State Protocol response`, async () => {
3568
+ const id = `mock-e2e-${Date.now()}`;
3569
+ const entityUrl = `/chat/${id}`;
3570
+ await spawnEntity(config.baseUrl, `chat`, id);
3571
+ await sendMessage(config.baseUrl, entityUrl, `Say hello`);
3572
+ const events = await pollForAgentResponse(config.baseUrl, entityUrl);
3573
+ const spEvents = events.filter((e) => e.type && e.key && e.headers);
3574
+ (0, vitest.expect)(spEvents.some((e) => e.type === `run`)).toBe(true);
3575
+ (0, vitest.expect)(spEvents.some((e) => e.type === `step`)).toBe(true);
3576
+ (0, vitest.expect)(spEvents.some((e) => e.type === `text`)).toBe(true);
3577
+ checkStateProtocolInvariants(spEvents);
3578
+ }, 15e3);
3579
+ (0, vitest.test)(`mock agent response contains expected text content`, async () => {
3580
+ const id = `mock-text-${Date.now()}`;
3581
+ const entityUrl = `/chat/${id}`;
3582
+ await spawnEntity(config.baseUrl, `chat`, id);
3583
+ await sendMessage(config.baseUrl, entityUrl, `What is 2+2?`);
3584
+ const events = await pollForAgentResponse(config.baseUrl, entityUrl);
3585
+ const textComplete = events.find((e) => e.type === `text` && e.headers?.operation === `update`);
3586
+ (0, vitest.expect)(textComplete).toBeDefined();
3587
+ const value = textComplete.value;
3588
+ (0, vitest.expect)(value.status).toBe(`completed`);
3589
+ }, 15e3);
3590
+ (0, vitest.test)(`mock agent writes text deltas for streaming`, async () => {
3591
+ const id = `mock-deltas-${Date.now()}`;
3592
+ const entityUrl = `/chat/${id}`;
3593
+ await spawnEntity(config.baseUrl, `chat`, id);
3594
+ await sendMessage(config.baseUrl, entityUrl, `hello`);
3595
+ const events = await pollForAgentResponse(config.baseUrl, entityUrl);
3596
+ const deltas = events.filter((e) => e.type === `text_delta`);
3597
+ (0, vitest.expect)(deltas.length).toBeGreaterThan(0);
3598
+ for (const delta of deltas) {
3599
+ const val = delta.value;
3600
+ (0, vitest.expect)(val.delta).toBeDefined();
3601
+ (0, vitest.expect)(typeof val.delta).toBe(`string`);
3602
+ }
3603
+ }, 15e3);
3604
+ });
3605
+ }
3606
+ function runMockAgentCliTests(config) {
3607
+ (0, vitest.describe)(`CLI — Mock Agent End-to-End`, () => {
3608
+ (0, vitest.test)(`spawn → send → agent responds with State Protocol events`, async () => {
3609
+ const id = `cli-mock-${Date.now()}`;
3610
+ await cliTest(config.baseUrl, config.cliBin).exec(`spawn`, `/chat/${id}`).expectExitCode(0).expectStdout(/Spawned/).exec(`send`, `/chat/${id}`, `hello from CLI`).expectExitCode(0).expectStdout(/Message sent/).wait(3e3).verifyApi(async (baseUrl) => {
3611
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(`/chat/${id}`)}`);
3612
+ (0, vitest.expect)(res.status, `entity should exist`).toBe(200);
3613
+ const entity = await res.json();
3614
+ const streams = entity.streams;
3615
+ const streamRes = await fetch(`${baseUrl}${streams.main}?offset=0000000000000000_0000000000000000`);
3616
+ const events = await streamRes.json();
3617
+ (0, vitest.expect)(events.some((e) => e.type === `run`), `stream should contain run events from agent`).toBe(true);
3618
+ (0, vitest.expect)(events.some((e) => e.type === `text`), `stream should contain text events from agent`).toBe(true);
3619
+ checkStateProtocolInvariants(events.filter((e) => e.type && e.key && e.headers));
3620
+ }).run();
3621
+ }, 2e4);
3622
+ (0, vitest.test)(`inspect shows entity after mock agent processes message`, async () => {
3623
+ const id = `cli-inspect-mock-${Date.now()}`;
3624
+ await cliTest(config.baseUrl, config.cliBin).exec(`spawn`, `/chat/${id}`).expectExitCode(0).exec(`send`, `/chat/${id}`, `test message`).expectExitCode(0).wait(3e3).exec(`inspect`, `/chat/${id}`).expectExitCode(0).expectStdout(/running|idle/).verifyApi(async (baseUrl) => {
3625
+ const res = await fetch(`${baseUrl}${routeControlPlanePath(`/chat/${id}`)}`);
3626
+ (0, vitest.expect)(res.status).toBe(200);
3627
+ const entity = await res.json();
3628
+ const streams = entity.streams;
3629
+ const streamRes = await fetch(`${baseUrl}${streams.main}?offset=0000000000000000_0000000000000000`);
3630
+ const events = await streamRes.json();
3631
+ const hasRunComplete = events.some((e) => e.type === `run` && e.headers?.operation === `update`);
3632
+ (0, vitest.expect)(hasRunComplete, `mock agent should have written run completion event`).toBe(true);
3633
+ }).run();
3634
+ }, 2e4);
3635
+ });
3636
+ }
3637
+
3638
+ //#endregion
3639
+ //#region src/mock-stream.ts
3640
+ function createMockStreamFn(text) {
3641
+ return () => {
3642
+ const partial = {
3643
+ role: `assistant`,
3644
+ stopReason: `stop`,
3645
+ content: [],
3646
+ api: `anthropic-messages`,
3647
+ provider: `anthropic`,
3648
+ model: `mock`,
3649
+ usage: {
3650
+ input: 10,
3651
+ output: 5,
3652
+ cacheRead: 0,
3653
+ cacheWrite: 0,
3654
+ totalTokens: 15,
3655
+ cost: {
3656
+ input: 0,
3657
+ output: 0,
3658
+ cacheRead: 0,
3659
+ cacheWrite: 0,
3660
+ total: 0
3661
+ }
3662
+ },
3663
+ timestamp: Date.now()
3664
+ };
3665
+ partial.content.push({
3666
+ type: `text`,
3667
+ text: ``
3668
+ });
3669
+ const events = [];
3670
+ events.push({
3671
+ type: `start`,
3672
+ partial
3673
+ });
3674
+ events.push({
3675
+ type: `text_start`,
3676
+ contentIndex: 0,
3677
+ partial
3678
+ });
3679
+ const chunks = text.match(/.{1,10}/g) ?? [text];
3680
+ for (const chunk of chunks) {
3681
+ const textContent$1 = partial.content[0];
3682
+ if (textContent$1) textContent$1.text += chunk;
3683
+ events.push({
3684
+ type: `text_delta`,
3685
+ contentIndex: 0,
3686
+ delta: chunk,
3687
+ partial
3688
+ });
3689
+ }
3690
+ const textContent = partial.content[0];
3691
+ events.push({
3692
+ type: `text_end`,
3693
+ contentIndex: 0,
3694
+ content: textContent?.text ?? ``,
3695
+ partial
3696
+ });
3697
+ events.push({
3698
+ type: `done`,
3699
+ reason: `stop`,
3700
+ message: partial
3701
+ });
3702
+ let resolved = false;
3703
+ let resolveResult;
3704
+ const resultPromise = new Promise((res) => {
3705
+ resolveResult = res;
3706
+ });
3707
+ const asyncIterable = {
3708
+ [Symbol.asyncIterator]() {
3709
+ let idx = 0;
3710
+ return { async next() {
3711
+ if (idx < events.length) {
3712
+ const value = events[idx++];
3713
+ if (!resolved && value.type === `done`) {
3714
+ resolved = true;
3715
+ resolveResult(value.message);
3716
+ }
3717
+ return {
3718
+ value,
3719
+ done: false
3720
+ };
3721
+ }
3722
+ return {
3723
+ value: void 0,
3724
+ done: true
3725
+ };
3726
+ } };
3727
+ },
3728
+ result() {
3729
+ return resultPromise;
3730
+ }
3731
+ };
3732
+ return asyncIterable;
3733
+ };
3734
+ }
3735
+
3736
+ //#endregion
3737
+ exports.CliScenario = CliScenario
3738
+ exports.ElectricAgentsScenario = ElectricAgentsScenario
3739
+ exports.ServeEndpointReceiver = ServeEndpointReceiver
3740
+ exports.applyElectricAgentsAction = applyElectricAgentsAction
3741
+ exports.checkInvariants = checkInvariants
3742
+ exports.checkStateProtocolInvariants = checkStateProtocolInvariants
3743
+ exports.cliTest = cliTest
3744
+ exports.createMockStreamFn = createMockStreamFn
3745
+ exports.electricAgents = electricAgents
3746
+ exports.enabledElectricAgentsActions = enabledElectricAgentsActions
3747
+ exports.runCliConformanceTests = runCliConformanceTests
3748
+ exports.runElectricAgentsConformanceTests = runElectricAgentsConformanceTests
3749
+ exports.runMockAgentCliTests = runMockAgentCliTests
3750
+ exports.runMockAgentTests = runMockAgentTests