elsabro 7.0.1 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -229,6 +229,463 @@ describe('serializeContext / deserializeContext', () => {
229
229
  });
230
230
  });
231
231
 
232
+ // ---------- Failure mode flow definitions ----------
233
+
234
+ const flowWithOnError = {
235
+ id: 'onerror_flow',
236
+ version: '1.0.0',
237
+ config: {},
238
+ nodes: [
239
+ { id: 'start', type: 'entry', runtime_status: 'implemented', next: 'risky_agent' },
240
+ {
241
+ id: 'risky_agent',
242
+ type: 'agent',
243
+ runtime_status: 'implemented',
244
+ agent: 'test-agent',
245
+ inputs: {},
246
+ onError: 'error_exit',
247
+ next: 'success_exit'
248
+ },
249
+ {
250
+ id: 'success_exit',
251
+ type: 'exit',
252
+ runtime_status: 'implemented',
253
+ status: 'success',
254
+ outputs: { result: 'ok' }
255
+ },
256
+ {
257
+ id: 'error_exit',
258
+ type: 'exit',
259
+ runtime_status: 'implemented',
260
+ status: 'cancelled',
261
+ outputs: { result: 'handled_error' }
262
+ }
263
+ ]
264
+ };
265
+
266
+ const flowWithoutOnError = {
267
+ id: 'no_onerror_flow',
268
+ version: '1.0.0',
269
+ config: {},
270
+ nodes: [
271
+ { id: 'start', type: 'entry', runtime_status: 'implemented', next: 'risky_agent' },
272
+ {
273
+ id: 'risky_agent',
274
+ type: 'agent',
275
+ runtime_status: 'implemented',
276
+ agent: 'test-agent',
277
+ inputs: {},
278
+ next: 'end'
279
+ },
280
+ { id: 'end', type: 'exit', runtime_status: 'implemented', status: 'success', outputs: {} }
281
+ ]
282
+ };
283
+
284
+ const flowWithLoop = {
285
+ id: 'loop_flow',
286
+ version: '1.0.0',
287
+ config: {},
288
+ nodes: [
289
+ { id: 'start', type: 'entry', runtime_status: 'implemented', next: 'loop_node' },
290
+ {
291
+ id: 'loop_node',
292
+ type: 'condition',
293
+ runtime_status: 'implemented',
294
+ condition: '{{inputs.always_true}}',
295
+ true: 'loop_node',
296
+ false: 'end'
297
+ },
298
+ { id: 'end', type: 'exit', runtime_status: 'implemented', status: 'success', outputs: {} }
299
+ ]
300
+ };
301
+
302
+ const flowWithParallel = {
303
+ id: 'parallel_flow',
304
+ version: '1.0.0',
305
+ config: {},
306
+ nodes: [
307
+ { id: 'start', type: 'entry', runtime_status: 'implemented', next: 'par' },
308
+ {
309
+ id: 'par',
310
+ type: 'parallel',
311
+ runtime_status: 'implemented',
312
+ branches: [
313
+ { id: 'b1', agent: 'agent-a', inputs: {} },
314
+ { id: 'b2', agent: 'agent-b', inputs: {} }
315
+ ],
316
+ timeout: 5000,
317
+ next: 'end'
318
+ },
319
+ { id: 'end', type: 'exit', runtime_status: 'implemented', status: 'success', outputs: {} }
320
+ ]
321
+ };
322
+
323
+ const flowWithParallelOnError = {
324
+ id: 'parallel_onerror_flow',
325
+ version: '1.0.0',
326
+ config: {},
327
+ nodes: [
328
+ { id: 'start', type: 'entry', runtime_status: 'implemented', next: 'par' },
329
+ {
330
+ id: 'par',
331
+ type: 'parallel',
332
+ runtime_status: 'implemented',
333
+ branches: [
334
+ { id: 'b1', agent: 'agent-a', inputs: {} }
335
+ ],
336
+ onError: 'error_exit',
337
+ next: 'end'
338
+ },
339
+ { id: 'end', type: 'exit', runtime_status: 'implemented', status: 'success', outputs: {} },
340
+ {
341
+ id: 'error_exit',
342
+ type: 'exit',
343
+ runtime_status: 'implemented',
344
+ status: 'cancelled',
345
+ outputs: { result: 'parallel_error_handled' }
346
+ }
347
+ ]
348
+ };
349
+
350
+ const flowWithMaxIterations = {
351
+ id: 'maxiter_flow',
352
+ version: '1.0.0',
353
+ config: {},
354
+ nodes: [
355
+ { id: 'start', type: 'entry', runtime_status: 'implemented', next: 'par' },
356
+ {
357
+ id: 'par',
358
+ type: 'parallel',
359
+ runtime_status: 'implemented',
360
+ branches: [{ id: 'b1', agent: 'agent-a', inputs: {} }],
361
+ maxIterations: 1,
362
+ next: 'end'
363
+ },
364
+ { id: 'end', type: 'exit', runtime_status: 'implemented', status: 'success', outputs: {} }
365
+ ]
366
+ };
367
+
368
+ const flowWithMaxIterationsHandler = {
369
+ id: 'maxiter_handler_flow',
370
+ version: '1.0.0',
371
+ config: {},
372
+ nodes: [
373
+ { id: 'start', type: 'entry', runtime_status: 'implemented', next: 'par' },
374
+ {
375
+ id: 'par',
376
+ type: 'parallel',
377
+ runtime_status: 'implemented',
378
+ branches: [{ id: 'b1', agent: 'agent-a', inputs: {} }],
379
+ maxIterations: 1,
380
+ onMaxIterations: 'limit_exit',
381
+ next: 'end'
382
+ },
383
+ { id: 'end', type: 'exit', runtime_status: 'implemented', status: 'success', outputs: {} },
384
+ {
385
+ id: 'limit_exit',
386
+ type: 'exit',
387
+ runtime_status: 'implemented',
388
+ status: 'cancelled',
389
+ outputs: { result: 'max_iterations_reached' }
390
+ }
391
+ ]
392
+ };
393
+
394
+ // Note: flowWithBadRef not needed — loadFlow validates references at load time.
395
+ // The runner's node-not-found path (L66-69) is tested via graph mutation after load.
396
+
397
+ // ---------- Failure mode tests ----------
398
+
399
+ describe('Failure modes — onError routing', () => {
400
+ it('routes to onError node when executor throws', async () => {
401
+ const engine = new FlowEngine();
402
+ engine.loadFlow(flowWithOnError);
403
+
404
+ const result = await engine.run({}, {
405
+ onAgent: async () => { throw new Error('agent exploded'); }
406
+ });
407
+
408
+ assert.equal(result.success, false);
409
+ assert.equal(result.outputs.result, 'handled_error');
410
+ assert.ok(result.nodesVisited.includes('error_exit'));
411
+ assert.ok(!result.nodesVisited.includes('success_exit'));
412
+ });
413
+
414
+ it('stores error message in outputs when using onError', async () => {
415
+ const engine = new FlowEngine();
416
+ engine.loadFlow(flowWithOnError);
417
+
418
+ const result = await engine.run({}, {
419
+ onAgent: async () => { throw new Error('kaboom'); }
420
+ });
421
+
422
+ // The risky_agent node should have error info in its outputs
423
+ assert.equal(result.context.nodes.risky_agent.outputs.error, 'kaboom');
424
+ });
425
+
426
+ it('propagates error when node has no onError', async () => {
427
+ const engine = new FlowEngine();
428
+ engine.loadFlow(flowWithoutOnError);
429
+
430
+ await assert.rejects(
431
+ engine.run({}, {
432
+ onAgent: async () => { throw new Error('unhandled boom'); }
433
+ }),
434
+ (err) => {
435
+ assert.equal(err.message, 'unhandled boom');
436
+ return true;
437
+ }
438
+ );
439
+ });
440
+
441
+ it('saves checkpoint before onError redirect', async () => {
442
+ const engine = new FlowEngine();
443
+ engine.loadFlow(flowWithOnError);
444
+
445
+ const checkpoints = [];
446
+ await engine.run({}, {
447
+ onAgent: async () => { throw new Error('checkpoint test'); },
448
+ onCheckpoint: async (data) => checkpoints.push(data)
449
+ });
450
+
451
+ // Find checkpoint for risky_agent → should point to error_exit
452
+ const agentCheckpoint = checkpoints.find(cp => cp.currentNode === 'risky_agent');
453
+ assert.ok(agentCheckpoint, 'checkpoint for risky_agent should exist');
454
+ assert.equal(agentCheckpoint.nextNode, 'error_exit');
455
+ });
456
+ });
457
+
458
+ describe('Failure modes — maxNodes loop guard', () => {
459
+ it('throws RunnerError when exceeding 200 node traversals', async () => {
460
+ const engine = new FlowEngine();
461
+ engine.loadFlow(flowWithLoop);
462
+
463
+ await assert.rejects(
464
+ engine.run({ always_true: true }, {}),
465
+ (err) => {
466
+ assert.equal(err.name, 'RunnerError');
467
+ assert.ok(err.message.includes('200'));
468
+ assert.ok(err.message.includes('infinite loop'));
469
+ return true;
470
+ }
471
+ );
472
+ });
473
+
474
+ it('RunnerError includes current node id', async () => {
475
+ const engine = new FlowEngine();
476
+ engine.loadFlow(flowWithLoop);
477
+
478
+ await assert.rejects(
479
+ engine.run({ always_true: true }, {}),
480
+ (err) => {
481
+ assert.equal(err.nodeId, 'loop_node');
482
+ return true;
483
+ }
484
+ );
485
+ });
486
+
487
+ it('checkpoints are saved during loop before guard triggers', async () => {
488
+ const engine = new FlowEngine();
489
+ engine.loadFlow(flowWithLoop);
490
+
491
+ const checkpoints = [];
492
+ await assert.rejects(
493
+ engine.run({ always_true: true }, {
494
+ onCheckpoint: async (data) => checkpoints.push(data)
495
+ })
496
+ );
497
+
498
+ // entry (1) + 199 loop iterations before guard fires on 201st attempt = 200 checkpoints
499
+ assert.ok(checkpoints.length >= 200, `expected >= 200 checkpoints, got ${checkpoints.length}`);
500
+ });
501
+ });
502
+
503
+ describe('Failure modes — parallel callback failures', () => {
504
+ it('parallel node with onError routes there when onParallel rejects', async () => {
505
+ const engine = new FlowEngine();
506
+ engine.loadFlow(flowWithParallelOnError);
507
+
508
+ const result = await engine.run({}, {
509
+ onParallel: async () => { throw new Error('parallel failed'); }
510
+ });
511
+
512
+ assert.equal(result.success, false);
513
+ assert.equal(result.outputs.result, 'parallel_error_handled');
514
+ assert.ok(result.nodesVisited.includes('error_exit'));
515
+ });
516
+
517
+ it('parallel node without onError propagates callback rejection', async () => {
518
+ const engine = new FlowEngine();
519
+ engine.loadFlow(flowWithParallel);
520
+
521
+ await assert.rejects(
522
+ engine.run({}, {
523
+ onParallel: async () => { throw new Error('unhandled parallel'); }
524
+ }),
525
+ (err) => {
526
+ assert.equal(err.message, 'unhandled parallel');
527
+ return true;
528
+ }
529
+ );
530
+ });
531
+
532
+ it('timeout value is passed to onParallel callback', async () => {
533
+ const engine = new FlowEngine();
534
+ engine.loadFlow(flowWithParallel);
535
+
536
+ let capturedArgs = null;
537
+ await engine.run({}, {
538
+ onParallel: async (args) => {
539
+ capturedArgs = args;
540
+ return [{ id: 'b1', result: 'ok' }, { id: 'b2', result: 'ok' }];
541
+ }
542
+ });
543
+
544
+ assert.ok(capturedArgs, 'onParallel should have been called');
545
+ assert.equal(capturedArgs.timeout, 5000);
546
+ assert.equal(capturedArgs.nodeId, 'par');
547
+ assert.equal(capturedArgs.joinType, 'all');
548
+ });
549
+
550
+ it('parallel node handles missing onParallel gracefully', async () => {
551
+ const engine = new FlowEngine();
552
+ engine.loadFlow(flowWithParallel);
553
+
554
+ // No onParallel callback provided
555
+ const result = await engine.run({}, {});
556
+
557
+ assert.equal(result.success, true);
558
+ // Branches should be marked as skipped
559
+ const parOutputs = result.context.nodes.par.outputs;
560
+ assert.ok(parOutputs.branches, 'branches should exist in outputs');
561
+ assert.equal(parOutputs.branches.b1.skipped, true);
562
+ assert.equal(parOutputs.branches.b2.skipped, true);
563
+ });
564
+ });
565
+
566
+ describe('Failure modes — context serialization', () => {
567
+ it('serializeContext handles circular references gracefully', () => {
568
+ const ctx = {
569
+ inputs: { task: 'test' },
570
+ nodes: {},
571
+ steps: {},
572
+ state: {},
573
+ _iterations: {}
574
+ };
575
+ // Create a circular reference
576
+ ctx.state.self = ctx.state;
577
+
578
+ const serialized = serializeContext(ctx);
579
+
580
+ // state should be null due to circular ref
581
+ assert.equal(serialized.state, null);
582
+ // Other keys should still be fine
583
+ assert.deepEqual(serialized.inputs, { task: 'test' });
584
+ assert.deepEqual(serialized.nodes, {});
585
+ });
586
+
587
+ it('serializeContext preserves all standard keys', () => {
588
+ const ctx = {
589
+ inputs: { a: 1 },
590
+ nodes: { n1: { outputs: { x: 2 } } },
591
+ steps: { s1: { output: 'data' } },
592
+ state: { flag: true },
593
+ _iterations: { n1: 3 }
594
+ };
595
+
596
+ const serialized = serializeContext(ctx);
597
+
598
+ assert.deepEqual(serialized.inputs, ctx.inputs);
599
+ assert.deepEqual(serialized.nodes, ctx.nodes);
600
+ assert.deepEqual(serialized.steps, ctx.steps);
601
+ assert.deepEqual(serialized.state, ctx.state);
602
+ assert.deepEqual(serialized._iterations, ctx._iterations);
603
+ });
604
+
605
+ it('serializeContext ignores unknown keys', () => {
606
+ const ctx = {
607
+ inputs: {},
608
+ nodes: {},
609
+ steps: {},
610
+ state: {},
611
+ _iterations: {},
612
+ extraField: 'should be ignored',
613
+ _internal: { secret: true }
614
+ };
615
+
616
+ const serialized = serializeContext(ctx);
617
+
618
+ assert.equal(serialized.extraField, undefined);
619
+ assert.equal(serialized._internal, undefined);
620
+ assert.ok('inputs' in serialized);
621
+ });
622
+ });
623
+
624
+ describe('Failure modes — maxIterations fallback', () => {
625
+ it('parallel node falls through to next when maxIterations reached without handler', async () => {
626
+ const engine = new FlowEngine();
627
+ engine.loadFlow(flowWithMaxIterations);
628
+
629
+ const result = await engine.run({}, {
630
+ onParallel: async () => [{ id: 'b1', result: 'done' }]
631
+ });
632
+
633
+ // Should reach 'end' via node.next even though maxIterations=1 was hit
634
+ assert.equal(result.success, true);
635
+ assert.ok(result.nodesVisited.includes('end'));
636
+ assert.ok(!result.nodesVisited.includes('limit_exit'));
637
+ });
638
+
639
+ it('parallel node routes to onMaxIterations when handler defined', async () => {
640
+ const engine = new FlowEngine();
641
+ engine.loadFlow(flowWithMaxIterationsHandler);
642
+
643
+ const result = await engine.run({}, {
644
+ onParallel: async () => [{ id: 'b1', result: 'done' }]
645
+ });
646
+
647
+ // Should route to limit_exit via onMaxIterations
648
+ assert.equal(result.success, false); // limit_exit is cancelled status
649
+ assert.equal(result.outputs.result, 'max_iterations_reached');
650
+ assert.ok(result.nodesVisited.includes('limit_exit'));
651
+ });
652
+ });
653
+
654
+ describe('Failure modes — runner edge cases', () => {
655
+ it('RunnerError thrown for node not found in graph at runtime', async () => {
656
+ const engine = new FlowEngine();
657
+ engine.loadFlow(miniFlow);
658
+
659
+ // Mutate graph after loading: point entry to a non-existent node
660
+ const startNode = engine.graph.nodes.get('start');
661
+ startNode.next = 'ghost_node';
662
+
663
+ await assert.rejects(
664
+ engine.run({ task: 'test', go: true }, {}),
665
+ (err) => {
666
+ assert.equal(err.name, 'RunnerError');
667
+ assert.ok(err.message.includes('ghost_node'));
668
+ assert.ok(err.message.includes('not found'));
669
+ return true;
670
+ }
671
+ );
672
+ });
673
+
674
+ it('RunnerError thrown when no flow loaded', async () => {
675
+ const engine = new FlowEngine();
676
+ // Do NOT call loadFlow
677
+
678
+ await assert.rejects(
679
+ engine.run({}, {}),
680
+ (err) => {
681
+ assert.equal(err.name, 'RunnerError');
682
+ assert.ok(err.message.includes('loadFlow'));
683
+ return true;
684
+ }
685
+ );
686
+ });
687
+ });
688
+
232
689
  describe('CheckpointManager', () => {
233
690
  const tmpDir = path.join(os.tmpdir(), `elsabro-test-cp-${Date.now()}`);
234
691