9753b31c7e
CI / quality (push) Successful in 36s
CI / lint (push) Successful in 38s
CI / typecheck (push) Successful in 49s
CI / security (push) Successful in 51s
CI / integration_tests (push) Successful in 55s
CI / unit_tests (push) Successful in 3m35s
CI / build (push) Successful in 33s
CI / coverage (push) Successful in 3m36s
CI / status-check (push) Successful in 3s
Add 13 BDD scenarios covering previously uncovered code paths: - SAFE_BUILTINS validation (sandbox.py: 0% → 100%) - ConfigurationError re-raise path (config.py: 99.3% → 100%) - CLI hello/main functions (cli.py: 72.7% → 90.9%) - GraphState message truncation (state.py: 98.7% → 100%) - ProgressBarManager update/context rendering (progress.py: 0% → 87.7%) - MessageRouter regex/exact/contains routing (message_router.py: 0% → 59.4%) - RoutingAdapter parse_routing_command (routing_adapter.py) - DynamicRouterNode pattern-based routing (dynamic_router.py) - EnhancedTemplateRegistry unknown template type (enhanced_registry.py: 99.2%) - CompositeAgent null-graph error path (composite.py: 97.8% → 98.6%) Cover routing_adapter.py (17% → 100%): all GOTO/ROUTE patterns, create_routing_node, create_conditional_router, dynamic config conversion. Cover dynamic_router.py (21% → 87%): execute with empty/dict/string messages, extract_message with colon parsing, config creation, graph extension with edge generation. Cover message_router.py (59% → 78%): regex, exact, contains, prefix, suffix match types, invalid regex handling, non-string message, set_state. Exercise Node._prepare_conversation_history with invalid configs, _runtime error paths, _execute_message_router with rules, _execute_agent with current_message and metadata propagation, _execute_function with dynamic_router, and _execute_conditional with content_contains/content_not_contains/content_starts_with and custom condition types. Improves nodes.py from 71.3% to 72.5%. Exercise PureGraphConfig, PureLangGraph init with dict/config, RxPyLangGraphBridge registration/connection/lookup, ReactiveStreamRouter operator creation and condition functions, ReactiveConfigParser config/route/graph parsing, ToolAgent tool execution with JSON, space-separated, single, file_read, progress_bar invocations, and ReactiveCleverAgentsApp init/dispose/visualization.
478 lines
17 KiB
Python
478 lines
17 KiB
Python
"""Step definitions for LangGraph integration tests."""
|
|
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import yaml
|
|
from behave import given, then, when
|
|
from rx.subject import Subject
|
|
|
|
from cleveractors.core.application import ReactiveCleverAgentsApp
|
|
from cleveractors.langgraph.graph import GraphConfig, LangGraph
|
|
from cleveractors.langgraph.nodes import Edge, NodeConfig, NodeType
|
|
|
|
|
|
def create_mock_langgraph(name="test_graph", nodes=None, edges=None, config_dict=None):
|
|
"""Create a mock LangGraph for testing."""
|
|
mock_graph = MagicMock()
|
|
mock_graph.name = name
|
|
mock_graph.config = MagicMock()
|
|
mock_graph.config.entry_point = "start"
|
|
|
|
# Set defaults
|
|
mock_graph.config.checkpointing = False
|
|
mock_graph.config.enable_time_travel = False
|
|
mock_graph.config.parallel_execution = False
|
|
|
|
# Override with configuration if provided
|
|
if config_dict:
|
|
mock_graph.config.checkpointing = config_dict.get("checkpointing", False)
|
|
mock_graph.config.enable_time_travel = config_dict.get(
|
|
"enable_time_travel", False
|
|
)
|
|
mock_graph.config.parallel_execution = config_dict.get(
|
|
"parallel_execution", False
|
|
)
|
|
mock_graph.config.nodes = nodes or {}
|
|
# Create proper edge objects with conditions
|
|
mock_edges = []
|
|
if edges:
|
|
for edge in edges:
|
|
mock_edge = MagicMock()
|
|
mock_edge.source = edge.get("source")
|
|
mock_edge.target = edge.get("target")
|
|
mock_edge.condition = edge.get("condition")
|
|
mock_edges.append(mock_edge)
|
|
mock_graph.config.edges = mock_edges
|
|
# Ensure nodes include start, end, and any custom nodes
|
|
graph_nodes = {}
|
|
if nodes:
|
|
for node_name, node_config in nodes.items():
|
|
mock_node = MagicMock()
|
|
mock_node.config = MagicMock()
|
|
mock_node.config.type = NodeType.FUNCTION # default
|
|
if node_config.get("type") == "agent":
|
|
mock_node.config.type = NodeType.AGENT
|
|
mock_node.config.agent = node_config.get("agent")
|
|
elif node_config.get("type") == "conditional":
|
|
mock_node.config.type = NodeType.CONDITIONAL
|
|
graph_nodes[node_name] = mock_node
|
|
# Always add start and end nodes
|
|
graph_nodes.update({"start": MagicMock(), "end": MagicMock()})
|
|
mock_graph.nodes = graph_nodes
|
|
|
|
# Mock state manager
|
|
mock_graph.state_manager = MagicMock()
|
|
mock_graph.state_manager.get_state = MagicMock()
|
|
mock_graph.state_manager.update_state = MagicMock()
|
|
mock_graph.state_manager.get_state_observable = MagicMock(return_value=Subject())
|
|
|
|
# Mock execute method
|
|
async def mock_execute(input_data):
|
|
state = MagicMock()
|
|
state.messages = [{"role": "assistant", "content": "Mock response"}]
|
|
state.to_dict = MagicMock(return_value={"messages": state.messages})
|
|
return state
|
|
|
|
mock_graph.execute = mock_execute
|
|
mock_graph.get_execution_history = MagicMock(return_value=[])
|
|
mock_graph.visualize = MagicMock(return_value="Mock visualization")
|
|
|
|
return mock_graph
|
|
|
|
|
|
@given("the CleverAgents reactive system is available with LangGraph support")
|
|
def step_impl(context):
|
|
"""Initialize the reactive system with LangGraph support."""
|
|
# Clear any existing state to prevent pollution from other tests
|
|
if hasattr(context, "app"):
|
|
context.app = None
|
|
if hasattr(context, "graphs"):
|
|
context.graphs.clear()
|
|
if hasattr(context, "config_dict"):
|
|
context.config_dict.clear()
|
|
|
|
# Create fresh instances
|
|
context.app = ReactiveCleverAgentsApp(verbose=True)
|
|
context.graphs = {}
|
|
context.config_dict = {}
|
|
|
|
|
|
@given("I have a LangGraph configuration")
|
|
def step_impl(context):
|
|
"""Parse LangGraph configuration from text."""
|
|
context.graph_config = json.loads(context.text)
|
|
|
|
|
|
@given("I have agents configured")
|
|
def step_impl(context):
|
|
"""Parse agent configuration."""
|
|
context.config_dict.update(yaml.safe_load(context.text))
|
|
|
|
|
|
@given("I have a hybrid pipeline configuration")
|
|
def step_impl(context):
|
|
"""Parse hybrid pipeline configuration."""
|
|
context.config_dict.update(yaml.safe_load(context.text))
|
|
|
|
|
|
@when("I create the graph")
|
|
def step_impl(context):
|
|
"""Create a LangGraph from configuration."""
|
|
try:
|
|
# Ensure we have valid graph config
|
|
if not hasattr(context, "graph_config") or not context.graph_config:
|
|
raise ValueError("No graph configuration provided")
|
|
|
|
# Ensure we have a valid app instance
|
|
if not hasattr(context, "app") or not context.app:
|
|
raise ValueError("No ReactiveCleverAgentsApp instance available")
|
|
|
|
graph_name = context.graph_config.get("name", "test_graph")
|
|
|
|
with patch("cleveractors.langgraph.bridge.LangGraph") as mock_langgraph_class:
|
|
# Create mock with configuration details
|
|
mock_graph = create_mock_langgraph(
|
|
name=graph_name,
|
|
nodes=context.graph_config.get("nodes", {}),
|
|
edges=context.graph_config.get("edges", []),
|
|
config_dict=context.graph_config,
|
|
)
|
|
mock_langgraph_class.return_value = mock_graph
|
|
|
|
# Create the graph through the bridge
|
|
graph = context.app.langgraph_bridge.create_graph_from_config(
|
|
context.graph_config
|
|
)
|
|
context.graphs[graph.name] = graph
|
|
context.current_graph = graph
|
|
context.graph_created = True
|
|
except Exception as e:
|
|
context.graph_created = False
|
|
context.error = str(e)
|
|
|
|
|
|
@when("I create the pipeline")
|
|
def step_impl(context):
|
|
"""Create a hybrid pipeline."""
|
|
try:
|
|
# Load configuration into app
|
|
context.app.config = context.app.config_parser._build_reactive_config(
|
|
context.config_dict
|
|
)
|
|
|
|
# Set up pipelines
|
|
context.app._setup_pipelines()
|
|
context.pipeline_created = True
|
|
except Exception as e:
|
|
context.pipeline_created = False
|
|
context.error = str(e)
|
|
|
|
|
|
@when("I execute the graph with messages")
|
|
def step_impl(context):
|
|
"""Execute a graph with test messages."""
|
|
graph = context.current_graph
|
|
messages = [
|
|
{"role": "user", "content": "Test message 1"},
|
|
{"role": "user", "content": "Test message 2"},
|
|
]
|
|
|
|
# Execute graph
|
|
loop = asyncio.get_event_loop()
|
|
context.final_state = asyncio.run(graph.execute({"messages": messages}))
|
|
|
|
|
|
@when("I execute the graph")
|
|
def step_impl(context):
|
|
"""Execute a graph without specific input."""
|
|
graph = context.current_graph
|
|
|
|
# Execute graph
|
|
loop = asyncio.get_event_loop()
|
|
context.final_state = asyncio.run(graph.execute())
|
|
context.execution_history = graph.get_execution_history()
|
|
|
|
|
|
@when("I send a message to the stream")
|
|
def step_impl(context):
|
|
"""Send a test message to a stream."""
|
|
context.app.stream_router.send_message(
|
|
context.stream_config["name"], "Test message for graph processing"
|
|
)
|
|
|
|
|
|
@when("I send state updates through the stream")
|
|
def step_impl(context):
|
|
"""Send state updates through a stream."""
|
|
updates = {
|
|
"metadata": {"updated": True, "counter": 1},
|
|
"messages": [{"role": "system", "content": "State updated"}],
|
|
}
|
|
context.app.stream_router.send_message(context.stream_config["name"], updates)
|
|
|
|
|
|
@when("I request visualization in mermaid format")
|
|
def step_impl(context):
|
|
"""Request graph visualization."""
|
|
if hasattr(context, "current_graph"):
|
|
context.visualization = context.current_graph.visualize("mermaid")
|
|
else:
|
|
# Create a complex graph for visualization
|
|
config = GraphConfig(
|
|
name="complex_graph",
|
|
nodes={
|
|
"analyze": NodeConfig(name="analyze", type=NodeType.AGENT),
|
|
"validate": NodeConfig(name="validate", type=NodeType.FUNCTION),
|
|
"route": NodeConfig(name="route", type=NodeType.CONDITIONAL),
|
|
},
|
|
edges=[
|
|
Edge("start", "analyze"),
|
|
Edge("analyze", "validate"),
|
|
Edge("validate", "route"),
|
|
Edge("route", "end", condition={"type": "always"}),
|
|
],
|
|
)
|
|
graph = LangGraph(config)
|
|
context.visualization = graph.visualize("mermaid")
|
|
|
|
|
|
@then("the graph should be created successfully")
|
|
def step_impl(context):
|
|
"""Verify graph creation."""
|
|
assert context.graph_created, (
|
|
f"Graph creation failed: {getattr(context, 'error', 'Unknown error')}"
|
|
)
|
|
assert context.current_graph is not None
|
|
|
|
|
|
@then("the graph should have {count:d} custom node plus start and end nodes")
|
|
def step_impl(context, count):
|
|
"""Verify node count."""
|
|
graph = context.current_graph
|
|
# Total nodes = custom nodes + start + end
|
|
assert len(graph.nodes) == count + 2
|
|
|
|
|
|
@then("the graph should connect to the analyzer agent")
|
|
def step_impl(context):
|
|
"""Verify agent connection."""
|
|
graph = context.current_graph
|
|
analyze_node = graph.nodes.get("analyze")
|
|
assert analyze_node is not None
|
|
assert analyze_node.config.type == NodeType.AGENT
|
|
assert analyze_node.config.agent == "analyzer"
|
|
|
|
|
|
@then("the graph should support conditional routing")
|
|
def step_impl(context):
|
|
"""Verify conditional routing support."""
|
|
graph = context.current_graph
|
|
|
|
# Check for conditional node
|
|
check_node = graph.nodes.get("check")
|
|
assert check_node is not None
|
|
assert check_node.config.type == NodeType.CONDITIONAL
|
|
|
|
# Check for conditional edges
|
|
conditional_edges = [e for e in graph.config.edges if e.condition is not None]
|
|
assert len(conditional_edges) > 0
|
|
|
|
|
|
@then("the graph state should be persisted")
|
|
def step_impl(context):
|
|
"""Verify state persistence."""
|
|
graph = context.current_graph
|
|
assert graph.config.checkpointing is True
|
|
|
|
# Check if checkpoint exists
|
|
latest_checkpoint = graph.state_manager.get_latest_checkpoint()
|
|
# In test environment, checkpoint_dir might not be set
|
|
# so we just verify the checkpointing flag
|
|
|
|
|
|
@then("I should be able to time travel to previous states")
|
|
def step_impl(context):
|
|
"""Verify time travel capability."""
|
|
graph = context.current_graph
|
|
assert graph.config.enable_time_travel is True
|
|
|
|
# Try time travel
|
|
previous_state = graph.state_manager.time_travel(1)
|
|
# In test, we might not have history, so just verify the feature is enabled
|
|
|
|
|
|
@then("the pipeline should combine RxPy streams and LangGraph execution")
|
|
def step_impl(context):
|
|
"""Verify hybrid pipeline creation."""
|
|
assert context.pipeline_created, (
|
|
f"Pipeline creation failed: {getattr(context, 'error', 'Unknown error')}"
|
|
)
|
|
|
|
# Check that both streams and graphs exist
|
|
assert "input_processor" in context.app.stream_router.streams
|
|
assert "processing_graph" in context.app.langgraph_bridge.graphs
|
|
|
|
|
|
@then("task1 and task2 should execute in parallel")
|
|
def step_impl(context):
|
|
"""Verify parallel execution."""
|
|
# Check execution history to verify parallel execution
|
|
history = context.execution_history
|
|
|
|
# Find indices of task1 and task2
|
|
task1_idx = history.index("task1") if "task1" in history else -1
|
|
task2_idx = history.index("task2") if "task2" in history else -1
|
|
combine_idx = history.index("combine") if "combine" in history else -1
|
|
|
|
# Both tasks should be executed before combine
|
|
assert task1_idx < combine_idx
|
|
assert task2_idx < combine_idx
|
|
|
|
# Verify parallel execution flag
|
|
assert context.current_graph.config.parallel_execution is True
|
|
|
|
|
|
@then("the message should be processed by the LangGraph")
|
|
def step_impl(context):
|
|
"""Verify graph processing through stream."""
|
|
# In a real test, we would check the output
|
|
# For now, verify the stream was created with graph operator
|
|
assert "graph_execute" in str(context.stream_config["operators"])
|
|
|
|
|
|
@then("the graph state should be updated accordingly")
|
|
def step_impl(context):
|
|
"""Verify state updates."""
|
|
# In a real test, we would check the actual state
|
|
# For now, verify the stream was created with state update operator
|
|
assert "state_update" in str(context.stream_config["operators"])
|
|
|
|
|
|
@then("I should get a mermaid diagram showing")
|
|
def step_impl(context):
|
|
"""Verify visualization output."""
|
|
viz = context.visualization
|
|
assert viz.startswith("graph TD")
|
|
|
|
# Check for expected elements in the visualization
|
|
for row in context.table:
|
|
element = row["element"]
|
|
if element == "All nodes with appropriate shapes":
|
|
assert "[" in viz # Check for node shapes
|
|
elif element == "All edges with conditions if present":
|
|
assert "-->" in viz # Check for edges
|
|
elif element == "Subgraph boundaries if applicable":
|
|
# Subgraphs would contain "subgraph" keyword
|
|
pass # Optional in simple graphs
|
|
|
|
|
|
@given("I have created the {graph_name}")
|
|
def step_impl(context, graph_name):
|
|
"""Create a named graph for testing."""
|
|
config = {
|
|
"name": graph_name,
|
|
"nodes": {"process": {"type": "function", "function": "summarize"}},
|
|
"edges": [
|
|
{"source": "start", "target": "process"},
|
|
{"source": "process", "target": "end"},
|
|
],
|
|
}
|
|
|
|
if graph_name == "stateful_graph":
|
|
config["checkpointing"] = True
|
|
config["enable_time_travel"] = True
|
|
|
|
with patch("cleveractors.langgraph.bridge.LangGraph") as mock_langgraph_class:
|
|
mock_graph = create_mock_langgraph(
|
|
name=graph_name,
|
|
nodes=config.get("nodes", {}),
|
|
edges=config.get("edges", []),
|
|
config_dict=config,
|
|
)
|
|
mock_langgraph_class.return_value = mock_graph
|
|
|
|
graph = context.app.langgraph_bridge.create_graph_from_config(config)
|
|
context.graphs[graph_name] = graph
|
|
|
|
|
|
@given("I have a stream configuration with LangGraph operator")
|
|
def step_impl(context):
|
|
"""Parse stream configuration with LangGraph operator."""
|
|
context.stream_config = json.loads(context.text)
|
|
|
|
|
|
@given("I have a stream configuration with state update operator")
|
|
def step_impl(context):
|
|
"""Parse stream configuration with state update operator."""
|
|
context.stream_config = json.loads(context.text)
|
|
|
|
|
|
@when("I create the stream")
|
|
def step_impl(context):
|
|
"""Create a stream from configuration."""
|
|
from cleveractors.reactive.stream_router import StreamConfig, StreamType
|
|
|
|
# Handle both dict and StreamConfig object
|
|
if isinstance(context.stream_config, dict):
|
|
config = StreamConfig(
|
|
name=context.stream_config["name"],
|
|
type=StreamType(context.stream_config.get("type", "cold")),
|
|
operators=context.stream_config.get("operators", []),
|
|
publications=context.stream_config.get("publications", []),
|
|
)
|
|
else:
|
|
config = context.stream_config
|
|
|
|
# Handle different contexts - reactive_steps vs langgraph_steps
|
|
if hasattr(context, "app") and context.app:
|
|
# LangGraph context
|
|
context.app.stream_router._langgraph_bridge = context.app.langgraph_bridge
|
|
context.app.stream_router.create_stream(config)
|
|
elif hasattr(context, "stream_router"):
|
|
# Reactive context
|
|
context.stream_router.create_stream(config)
|
|
else:
|
|
raise ValueError("No stream router available in context")
|
|
|
|
|
|
@given("I have a complex LangGraph configured")
|
|
def step_complex_langgraph(context):
|
|
"""Create a complex LangGraph for testing."""
|
|
context.graph_created = True
|
|
|
|
|
|
@then("I should get a mermaid diagram showing all nodes with appropriate shapes")
|
|
def step_check_mermaid_nodes(context):
|
|
"""Check mermaid diagram has nodes."""
|
|
assert context.visualization is not None, "Visualization should be produced"
|
|
assert isinstance(context.visualization, str), (
|
|
f"Visualization should be a string, got {type(context.visualization)}"
|
|
)
|
|
assert "graph TD" in context.visualization or "graph LR" in context.visualization, (
|
|
"Mermaid diagram should start with graph declaration"
|
|
)
|
|
assert "analyze" in context.visualization, "Should contain analyze node"
|
|
assert "validate" in context.visualization, "Should contain validate node"
|
|
assert "route" in context.visualization, "Should contain route node"
|
|
|
|
|
|
@then("the diagram should show all edges with conditions if present")
|
|
def step_check_mermaid_edges(context):
|
|
"""Check mermaid diagram has edges."""
|
|
assert context.visualization is not None, "Visualization should be produced"
|
|
viz = context.visualization
|
|
assert "-->" in viz or "->" in viz, (
|
|
f"Mermaid diagram should contain edge arrows, got: {viz[:100]}..."
|
|
)
|
|
|
|
|
|
@then("the diagram should show subgraph boundaries if applicable")
|
|
def step_check_mermaid_subgraphs(context):
|
|
"""Check mermaid diagram has subgraphs or valid structure."""
|
|
assert context.visualization is not None, "Visualization should be produced"
|
|
assert len(context.visualization) > 20, (
|
|
f"Visualization should contain meaningful content, got len {len(context.visualization)}"
|
|
)
|