soprano-sdk 0.2.5__tar.gz → 0.2.6__tar.gz

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.
Files changed (101) hide show
  1. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/PKG-INFO +1 -1
  2. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/pyproject.toml +1 -1
  3. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/authenticators/mfa.py +11 -3
  4. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/nodes/call_function.py +6 -0
  5. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_mfa_scenarios.py +384 -0
  6. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/.github/workflows/test_build_and_publish.yaml +0 -0
  7. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/.gitignore +0 -0
  8. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/.python-version +0 -0
  9. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/CLAUDE.md +0 -0
  10. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/LICENSE +0 -0
  11. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/README.md +0 -0
  12. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/concert_booking/__init__.py +0 -0
  13. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/concert_booking/booking_helpers.py +0 -0
  14. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/concert_booking/concert_ticket_booking.yaml +0 -0
  15. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/framework_example.yaml +0 -0
  16. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/greeting_functions.py +0 -0
  17. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/greeting_workflow.yaml +0 -0
  18. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/main.py +0 -0
  19. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/persistence/README.md +0 -0
  20. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/persistence/conversation_based.py +0 -0
  21. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/persistence/entity_based.py +0 -0
  22. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/persistence/mongodb_demo.py +0 -0
  23. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/return_functions.py +0 -0
  24. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/return_workflow.yaml +0 -0
  25. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/structured_output_example.yaml +0 -0
  26. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/supervisors/README.md +0 -0
  27. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/supervisors/crewai_supervisor_ui.py +0 -0
  28. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/supervisors/langgraph_supervisor_ui.py +0 -0
  29. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/supervisors/tools/__init__.py +0 -0
  30. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/supervisors/tools/crewai_tools.py +0 -0
  31. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/supervisors/tools/langgraph_tools.py +0 -0
  32. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/supervisors/workflow_tools.py +0 -0
  33. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/tools/__init__.py +0 -0
  34. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/tools/address.py +0 -0
  35. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/examples/validator.py +0 -0
  36. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/legacy/langgraph_demo.py +0 -0
  37. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/legacy/langgraph_selfloop_demo.py +0 -0
  38. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/legacy/langgraph_v.py +0 -0
  39. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/legacy/main.py +0 -0
  40. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/legacy/return_fsm.excalidraw +0 -0
  41. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/legacy/return_state_machine.png +0 -0
  42. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/legacy/ui.py +0 -0
  43. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/scripts/visualize_workflow.py +0 -0
  44. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/scripts/workflow_demo.py +0 -0
  45. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/scripts/workflow_demo_ui.py +0 -0
  46. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/__init__.py +0 -0
  47. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/agents/__init__.py +0 -0
  48. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/agents/adaptor.py +0 -0
  49. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/agents/factory.py +0 -0
  50. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/agents/structured_output.py +0 -0
  51. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/authenticators/__init__.py +0 -0
  52. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/core/__init__.py +0 -0
  53. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/core/constants.py +0 -0
  54. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/core/engine.py +0 -0
  55. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/core/rollback_strategies.py +0 -0
  56. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/core/state.py +0 -0
  57. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/engine.py +0 -0
  58. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/nodes/__init__.py +0 -0
  59. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/nodes/async_function.py +0 -0
  60. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/nodes/base.py +0 -0
  61. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/nodes/collect_input.py +0 -0
  62. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/nodes/factory.py +0 -0
  63. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/routing/__init__.py +0 -0
  64. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/routing/router.py +0 -0
  65. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/tools.py +0 -0
  66. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/utils/__init__.py +0 -0
  67. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/utils/function.py +0 -0
  68. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/utils/logger.py +0 -0
  69. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/utils/template.py +0 -0
  70. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/utils/tool.py +0 -0
  71. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/utils/tracing.py +0 -0
  72. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/validation/__init__.py +0 -0
  73. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/validation/schema.py +0 -0
  74. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/soprano_sdk/validation/validator.py +0 -0
  75. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/debug_jinja2.py +0 -0
  76. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_agent_factory.py +0 -0
  77. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_async_function.py +0 -0
  78. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_collect_input_refactor.py +0 -0
  79. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_external_values.py +0 -0
  80. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_inputs_validation.py +0 -0
  81. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_jinja2_path.py +0 -0
  82. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_jinja2_standalone.py +0 -0
  83. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_persistence.py +0 -0
  84. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_structured_output.py +0 -0
  85. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/tests/test_transition_routing.py +0 -0
  86. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/todo.md +0 -0
  87. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/uv.lock +0 -0
  88. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/.eslintrc.cjs +0 -0
  89. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/.gitignore +0 -0
  90. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/README.md +0 -0
  91. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/index.html +0 -0
  92. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/package-lock.json +0 -0
  93. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/package.json +0 -0
  94. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/src/App.jsx +0 -0
  95. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/src/CustomNode.jsx +0 -0
  96. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/src/StepDetailsModal.jsx +0 -0
  97. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/src/WorkflowGraph.jsx +0 -0
  98. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/src/WorkflowInfoPanel.jsx +0 -0
  99. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/src/assets/react.svg +0 -0
  100. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/src/main.jsx +0 -0
  101. {soprano_sdk-0.2.5 → soprano_sdk-0.2.6}/workflow-visualizer/vite.config.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soprano-sdk
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: YAML-driven workflow engine with AI agent integration for building conversational SOPs
5
5
  Author: Arvind Thangamani
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "soprano-sdk"
7
- version = "0.2.5"
7
+ version = "0.2.6"
8
8
  description = "YAML-driven workflow engine with AI agent integration for building conversational SOPs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -10,6 +10,7 @@ class MFAChallenge(TypedDict):
10
10
  class MFAState(TypedDict):
11
11
  challengeType: Literal['OTP', 'dob']
12
12
  post_payload: dict[str, str]
13
+ post_headers: NotRequired[dict[str, str]]
13
14
  otpValue: NotRequired[str]
14
15
  status: Literal['IN_PROGRESS', 'COMPLETED', 'ERRORED', 'FAILED'] | None
15
16
  message: str
@@ -35,6 +36,10 @@ def enforce_mfa_if_required(state: dict, mfa_config: Optional[MFAConfig] = None)
35
36
  _mfa : MFAState = state['_mfa']
36
37
  if _mfa['status'] == 'COMPLETED':
37
38
  return True
39
+
40
+ # Use custom headers if provided, otherwise empty dict
41
+ headers = _mfa.get('post_headers', {})
42
+
38
43
  generate_token_response = requests.post(
39
44
  build_path(
40
45
  base_url=mfa_config.generate_token_base_url,
@@ -42,7 +47,7 @@ def enforce_mfa_if_required(state: dict, mfa_config: Optional[MFAConfig] = None)
42
47
  ),
43
48
  json=_mfa['post_payload'],
44
49
  timeout=mfa_config.api_timeout,
45
- headers={"Authorization": f"Bearer {state['bearer_token']}"}
50
+ headers=headers
46
51
  )
47
52
  _, error = get_response(generate_token_response)
48
53
 
@@ -65,6 +70,9 @@ def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dic
65
70
  if not state[input_field_name]:
66
71
  return False
67
72
 
73
+ # Use custom headers if provided, otherwise empty dict
74
+ headers = _mfa.get('post_headers', {})
75
+
68
76
  post_payload = _mfa['post_payload']
69
77
  challenge_field_name = f"{_mfa['challengeType'].lower()}Challenge"
70
78
  post_payload.update({challenge_field_name: {"value": state[input_field_name]}})
@@ -75,7 +83,7 @@ def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dic
75
83
  ),
76
84
  json=post_payload,
77
85
  timeout=mfa_config.api_timeout,
78
- headers={"Authorization": f"Bearer {state['bearer_token']}"}
86
+ headers=headers
79
87
  )
80
88
  _mfa['retry_count'] += 1
81
89
  response, error = get_response(validate_token_response)
@@ -95,7 +103,7 @@ def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dic
95
103
  ),
96
104
  json=post_payload,
97
105
  timeout=mfa_config.api_timeout,
98
- headers={"Authorization": f"Bearer {state['bearer_token']}"}
106
+ headers=headers
99
107
  )
100
108
  if authorize.status_code == 204:
101
109
  _mfa['status'] = 'COMPLETED'
@@ -39,11 +39,17 @@ class CallFunctionStrategy(ActionStrategy):
39
39
  if 'mfa' in self.step_config:
40
40
  state['_mfa'] = state.get('_mfa', {})
41
41
  state['_mfa']['post_payload'] = dict(transactionId=str(uuid.uuid4()))
42
+ state['_mfa']['post_headers'] = {}
42
43
  state['_mfa_config'] = self.engine_context.mfa_config
43
44
  template_loader = self.engine_context.get_config_value("template_loader", Environment())
44
45
  for k, v in self.step_config['mfa']['payload'].items():
45
46
  state['_mfa']['post_payload'][k] = compile_values(template_loader, state, v)
46
47
 
48
+ # Process headers if provided
49
+ if 'headers' in self.step_config['mfa']:
50
+ for k, v in self.step_config['mfa']['headers'].items():
51
+ state['_mfa']['post_headers'][k] = compile_values(template_loader, state, v)
52
+
47
53
  def execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
48
54
  from ..utils.tracing import trace_node_execution
49
55
 
@@ -416,3 +416,387 @@ def test_mfa_comprehensive_flow():
416
416
 
417
417
  print("\n✅ TEST PASSED: Comprehensive flow validation successful")
418
418
  print("=" * 80)
419
+
420
+
421
+ def test_mfa_default_max_attempts():
422
+ """
423
+ Test Case 8: Verify default max_attempts value
424
+
425
+ Validates that:
426
+ - MFA collector nodes use default max_attempts value of 3 when not specified
427
+ """
428
+ print("\n" + "=" * 80)
429
+ print("TEST 8: MFA Default Max Attempts")
430
+ print("=" * 80)
431
+
432
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
433
+ graph, engine = load_workflow(yaml_path)
434
+
435
+ print("\nScenario: MFA config does not specify max_attempts")
436
+ print("Expected: Default value of 3 should be used")
437
+
438
+ # Get MFA validate nodes
439
+ mfa_validate_nodes = [s for s in engine.list_steps() if '_mfa_validate' in s]
440
+ print(f"\nMFA validate nodes: {mfa_validate_nodes}")
441
+
442
+ for node_id in mfa_validate_nodes:
443
+ node_info = engine.get_step_info(node_id)
444
+ max_attempts = node_info.get('max_attempts', 'NOT_SET')
445
+ print(f"\n{node_id}")
446
+ print(f" max_attempts: {max_attempts}")
447
+
448
+ assert max_attempts == 3, \
449
+ f"Expected default max_attempts=3, got {max_attempts}"
450
+ print(f" ✅ Correctly uses default max_attempts=3")
451
+
452
+ print("\n✅ TEST PASSED: Default max_attempts value verified")
453
+ print("=" * 80)
454
+
455
+
456
+ def test_mfa_custom_max_attempts():
457
+ """
458
+ Test Case 9: Verify custom max_attempts configuration
459
+
460
+ Validates that:
461
+ - Custom max_attempts value from MFA config is applied to collector node
462
+ """
463
+ print("\n" + "=" * 80)
464
+ print("TEST 9: MFA Custom Max Attempts")
465
+ print("=" * 80)
466
+
467
+ # Create a temporary YAML with custom max_attempts
468
+ import tempfile
469
+ import shutil
470
+
471
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
472
+
473
+ with open(yaml_path, 'r') as f:
474
+ yaml_content = f.read()
475
+
476
+ # Add max_attempts to first MFA config
477
+ modified_yaml = yaml_content.replace(
478
+ """ mfa:
479
+ model: gpt-4o-mini
480
+ type: REST
481
+ payload:
482
+ transactionType: CONCERT_TICKET_PAYMENT""",
483
+ """ mfa:
484
+ model: gpt-4o-mini
485
+ type: REST
486
+ max_attempts: 5
487
+ payload:
488
+ transactionType: CONCERT_TICKET_PAYMENT"""
489
+ )
490
+
491
+ # Write to temporary file
492
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
493
+ temp_yaml_path = f.name
494
+ f.write(modified_yaml)
495
+
496
+ try:
497
+ graph, engine = load_workflow(temp_yaml_path)
498
+
499
+ print("\nScenario: MFA config specifies max_attempts: 5")
500
+
501
+ # Find the process_payment MFA validate node
502
+ process_payment_validate = 'process_payment_mfa_validate'
503
+ assert process_payment_validate in engine.list_steps(), \
504
+ f"Expected {process_payment_validate} node to exist"
505
+
506
+ node_info = engine.get_step_info(process_payment_validate)
507
+ max_attempts = node_info.get('max_attempts')
508
+
509
+ print(f"\nNode: {process_payment_validate}")
510
+ print(f" max_attempts: {max_attempts}")
511
+
512
+ assert max_attempts == 5, \
513
+ f"Expected custom max_attempts=5, got {max_attempts}"
514
+ print(f" ✅ Correctly uses custom max_attempts=5")
515
+
516
+ print("\n✅ TEST PASSED: Custom max_attempts value applied correctly")
517
+ print("=" * 80)
518
+ finally:
519
+ # Clean up temporary file
520
+ os.unlink(temp_yaml_path)
521
+
522
+
523
+ def test_mfa_custom_error_message():
524
+ """
525
+ Test Case 10: Verify custom on_max_attempts_reached message
526
+
527
+ Validates that:
528
+ - Custom error message from MFA config is applied to collector node
529
+ """
530
+ print("\n" + "=" * 80)
531
+ print("TEST 10: MFA Custom Error Message")
532
+ print("=" * 80)
533
+
534
+ # Create a temporary YAML with custom error message
535
+ import tempfile
536
+
537
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
538
+
539
+ with open(yaml_path, 'r') as f:
540
+ yaml_content = f.read()
541
+
542
+ custom_error = "You have exceeded the maximum MFA attempts for payment verification. Your transaction has been blocked for security. Please contact support at 1-800-TICKETS."
543
+
544
+ # Add on_max_attempts_reached to first MFA config
545
+ modified_yaml = yaml_content.replace(
546
+ """ mfa:
547
+ model: gpt-4o-mini
548
+ type: REST
549
+ payload:
550
+ transactionType: CONCERT_TICKET_PAYMENT""",
551
+ f""" mfa:
552
+ model: gpt-4o-mini
553
+ type: REST
554
+ on_max_attempts_reached: "{custom_error}"
555
+ payload:
556
+ transactionType: CONCERT_TICKET_PAYMENT"""
557
+ )
558
+
559
+ # Write to temporary file
560
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
561
+ temp_yaml_path = f.name
562
+ f.write(modified_yaml)
563
+
564
+ try:
565
+ graph, engine = load_workflow(temp_yaml_path)
566
+
567
+ print("\nScenario: MFA config specifies custom on_max_attempts_reached message")
568
+
569
+ # Find the process_payment MFA validate node
570
+ process_payment_validate = 'process_payment_mfa_validate'
571
+ assert process_payment_validate in engine.list_steps(), \
572
+ f"Expected {process_payment_validate} node to exist"
573
+
574
+ node_info = engine.get_step_info(process_payment_validate)
575
+ error_message = node_info.get('on_max_attempts_reached')
576
+
577
+ print(f"\nNode: {process_payment_validate}")
578
+ print(f" on_max_attempts_reached: {error_message}")
579
+
580
+ assert error_message == custom_error, \
581
+ f"Expected custom error message, got {error_message}"
582
+ print(f" ✅ Correctly uses custom error message")
583
+
584
+ print("\n✅ TEST PASSED: Custom error message applied correctly")
585
+ print("=" * 80)
586
+ finally:
587
+ # Clean up temporary file
588
+ os.unlink(temp_yaml_path)
589
+
590
+
591
+ def test_mfa_custom_max_attempts_and_error():
592
+ """
593
+ Test Case 11: Verify both custom max_attempts and on_max_attempts_reached together
594
+
595
+ Validates that:
596
+ - Both custom max_attempts and error message can be configured together
597
+ - Both values are correctly applied to the MFA collector node
598
+ """
599
+ print("\n" + "=" * 80)
600
+ print("TEST 11: MFA Custom Max Attempts and Error Message Together")
601
+ print("=" * 80)
602
+
603
+ # Create a temporary YAML with both custom values
604
+ import tempfile
605
+
606
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
607
+
608
+ with open(yaml_path, 'r') as f:
609
+ yaml_content = f.read()
610
+
611
+ custom_error = "Maximum verification attempts exceeded. Account locked."
612
+ custom_max_attempts = 2
613
+
614
+ # Add both fields to MFA config
615
+ modified_yaml = yaml_content.replace(
616
+ """ mfa:
617
+ model: gpt-4o-mini
618
+ type: REST
619
+ payload:
620
+ transactionType: CONCERT_TICKET_PAYMENT""",
621
+ f""" mfa:
622
+ model: gpt-4o-mini
623
+ type: REST
624
+ max_attempts: {custom_max_attempts}
625
+ on_max_attempts_reached: "{custom_error}"
626
+ payload:
627
+ transactionType: CONCERT_TICKET_PAYMENT"""
628
+ )
629
+
630
+ # Write to temporary file
631
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
632
+ temp_yaml_path = f.name
633
+ f.write(modified_yaml)
634
+
635
+ try:
636
+ graph, engine = load_workflow(temp_yaml_path)
637
+
638
+ print(f"\nScenario: MFA config specifies max_attempts: {custom_max_attempts} and custom error")
639
+
640
+ # Find the process_payment MFA validate node
641
+ process_payment_validate = 'process_payment_mfa_validate'
642
+ assert process_payment_validate in engine.list_steps(), \
643
+ f"Expected {process_payment_validate} node to exist"
644
+
645
+ node_info = engine.get_step_info(process_payment_validate)
646
+ max_attempts = node_info.get('max_attempts')
647
+ error_message = node_info.get('on_max_attempts_reached')
648
+
649
+ print(f"\nNode: {process_payment_validate}")
650
+ print(f" max_attempts: {max_attempts}")
651
+ print(f" on_max_attempts_reached: {error_message}")
652
+
653
+ assert max_attempts == custom_max_attempts, \
654
+ f"Expected max_attempts={custom_max_attempts}, got {max_attempts}"
655
+ print(f" ✅ Correctly uses custom max_attempts={custom_max_attempts}")
656
+
657
+ assert error_message == custom_error, \
658
+ f"Expected custom error message, got {error_message}"
659
+ print(f" ✅ Correctly uses custom error message")
660
+
661
+ print("\n✅ TEST PASSED: Both custom values applied correctly")
662
+ print("=" * 80)
663
+ finally:
664
+ # Clean up temporary file
665
+ os.unlink(temp_yaml_path)
666
+
667
+
668
+ def test_mfa_custom_headers_with_jinja():
669
+ """
670
+ Test Case 12: Verify custom headers with Jinja template rendering
671
+
672
+ Validates that:
673
+ - Custom headers can be specified in MFA config
674
+ - Jinja templates in header values are correctly rendered with state data
675
+ - Headers are stored in the MFA state as post_headers
676
+ """
677
+ print("\n" + "=" * 80)
678
+ print("TEST 12: MFA Custom Headers with Jinja Template Rendering")
679
+ print("=" * 80)
680
+
681
+ # Create a temporary YAML with custom headers
682
+ import tempfile
683
+
684
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
685
+
686
+ with open(yaml_path, 'r') as f:
687
+ yaml_content = f.read()
688
+
689
+ # Add headers to first MFA config
690
+ modified_yaml = yaml_content.replace(
691
+ """ mfa:
692
+ model: gpt-4o-mini
693
+ type: REST
694
+ payload:
695
+ transactionType: CONCERT_TICKET_PAYMENT
696
+ businessKey:
697
+ customerName: "{{customer_name}}"
698
+ concertName: "{{concert_name}}"
699
+ seatPreference: "{{seat_preference}}"
700
+ ticketQuantity: "{{ticket_quantity}}"
701
+ transitions:""",
702
+ """ mfa:
703
+ model: gpt-4o-mini
704
+ type: REST
705
+ payload:
706
+ transactionType: CONCERT_TICKET_PAYMENT
707
+ businessKey:
708
+ customerName: "{{customer_name}}"
709
+ concertName: "{{concert_name}}"
710
+ seatPreference: "{{seat_preference}}"
711
+ ticketQuantity: "{{ticket_quantity}}"
712
+ headers:
713
+ Authorization: "Bearer {{bearer_token}}"
714
+ X-Customer-Name: "{{customer_name}}"
715
+ X-Concert-Name: "{{concert_name}}"
716
+ X-Request-Id: "payment-{{booking_reference}}"
717
+ transitions:"""
718
+ )
719
+
720
+ # Write to temporary file
721
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
722
+ temp_yaml_path = f.name
723
+ f.write(modified_yaml)
724
+
725
+ try:
726
+ graph, engine = load_workflow(temp_yaml_path)
727
+
728
+ print("\nScenario: MFA config includes custom headers with Jinja templates")
729
+
730
+ # Initialize state with test data
731
+ state = {
732
+ 'bearer_token': 'test-token-12345',
733
+ 'customer_name': 'John Doe',
734
+ 'concert_name': 'Rock Concert 2026',
735
+ 'booking_reference': 'BK-001',
736
+ 'seat_preference': 'VIP',
737
+ 'ticket_quantity': 2
738
+ }
739
+
740
+ # Find the process_payment step
741
+ process_payment_step = 'process_payment'
742
+ assert process_payment_step in engine.list_steps(), \
743
+ f"Expected {process_payment_step} step to exist"
744
+
745
+ step_info = engine.get_step_info(process_payment_step)
746
+
747
+ # Check the MFA start node - this is where the MFA config is stored
748
+ process_payment_mfa_start = 'process_payment_mfa_start'
749
+ assert process_payment_mfa_start in engine.list_steps(), \
750
+ f"Expected {process_payment_mfa_start} node to exist"
751
+
752
+ mfa_start_info = engine.get_step_info(process_payment_mfa_start)
753
+
754
+ # Verify headers are defined in MFA config
755
+ assert 'mfa' in mfa_start_info, "MFA start node should have MFA configuration"
756
+ assert 'headers' in mfa_start_info['mfa'], "MFA config should have headers"
757
+
758
+ headers_config = mfa_start_info['mfa']['headers']
759
+ print(f"\nHeaders defined in MFA config:")
760
+ for key, value in headers_config.items():
761
+ print(f" {key}: {value}")
762
+
763
+ # Verify header templates
764
+ assert headers_config['Authorization'] == "Bearer {{bearer_token}}", \
765
+ "Authorization header should have bearer_token template"
766
+ assert headers_config['X-Customer-Name'] == "{{customer_name}}", \
767
+ "X-Customer-Name header should have customer_name template"
768
+ assert headers_config['X-Concert-Name'] == "{{concert_name}}", \
769
+ "X-Concert-Name header should have concert_name template"
770
+ assert headers_config['X-Request-Id'] == "payment-{{booking_reference}}", \
771
+ "X-Request-Id header should have booking_reference template"
772
+
773
+ print("\n✅ Headers configuration verified")
774
+
775
+ # Test that headers would be rendered correctly (simulation)
776
+ from jinja2 import Environment
777
+ template_loader = Environment()
778
+
779
+ expected_rendered_headers = {
780
+ 'Authorization': 'Bearer test-token-12345',
781
+ 'X-Customer-Name': 'John Doe',
782
+ 'X-Concert-Name': 'Rock Concert 2026',
783
+ 'X-Request-Id': 'payment-BK-001'
784
+ }
785
+
786
+ print("\nExpected rendered headers:")
787
+ for key, value in expected_rendered_headers.items():
788
+ print(f" {key}: {value}")
789
+
790
+ # Verify template rendering logic
791
+ for key, template_str in headers_config.items():
792
+ rendered = template_loader.from_string(template_str).render(state)
793
+ expected = expected_rendered_headers[key]
794
+ assert rendered == expected, \
795
+ f"Header {key} rendering mismatch: expected '{expected}', got '{rendered}'"
796
+ print(f" ✅ {key} renders correctly")
797
+
798
+ print("\n✅ TEST PASSED: Custom headers with Jinja templates work correctly")
799
+ print("=" * 80)
800
+ finally:
801
+ # Clean up temporary file
802
+ os.unlink(temp_yaml_path)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes