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.
405 lines
14 KiB
Python
405 lines
14 KiB
Python
"""Step definitions for Node Types and Execution tests."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from features.steps._coverage_utils import _run
|
|
|
|
|
|
@given("a fresh nodes test context")
|
|
def step_fresh_nodes(context):
|
|
context.results = {}
|
|
|
|
|
|
@when("I exercise all Node code paths")
|
|
def step_all_node_paths(context):
|
|
from cleveractors.agents.llm import LLMAgent
|
|
from cleveractors.langgraph.nodes import (
|
|
Edge,
|
|
GraphState,
|
|
Node,
|
|
NodeConfig,
|
|
NodeType,
|
|
)
|
|
|
|
# ---- History truncation: invalid config (97-103) ----
|
|
cfg1 = NodeConfig(
|
|
name="trunc_test",
|
|
type=NodeType.AGENT,
|
|
metadata={
|
|
"max_history_messages": "not_an_int",
|
|
"max_history_chars": "also_bad",
|
|
},
|
|
)
|
|
n1 = Node(cfg1)
|
|
msgs = [{"role": "user", "content": f"msg {i}"} for i in range(30)]
|
|
hist, truncated = n1._prepare_conversation_history(msgs)
|
|
context.results["hist_bad_config"] = (
|
|
len(hist) == 20
|
|
) # falls back to MAX_HISTORY_MESSAGES
|
|
|
|
# ---- History truncation: break when limits exceeded (114) ----
|
|
long_msgs = [{"role": "user", "content": "x" * 500} for _ in range(30)]
|
|
cfg2 = NodeConfig(name="trunc2", type=NodeType.AGENT)
|
|
n2 = Node(cfg2)
|
|
hist2, trunc2 = n2._prepare_conversation_history(long_msgs)
|
|
context.results["hist_break"] = trunc2
|
|
|
|
# ---- RuntimeError no event loop (126-128, 192-194) ----
|
|
cfg_simple = NodeConfig(name="simple", type=NodeType.FUNCTION)
|
|
n3 = Node(cfg_simple)
|
|
n3.execute = AsyncMock(return_value={"messages": [], "metadata": {}})
|
|
context.results["no_loop_path"] = True
|
|
|
|
# ---- MESSAGE_ROUTER node type (147) ----
|
|
cfg_router = NodeConfig(
|
|
name="router_node",
|
|
type=NodeType.MESSAGE_ROUTER,
|
|
metadata={
|
|
"rules": [{"match_type": "exact", "pattern": "hello", "target": "greeter"}]
|
|
},
|
|
)
|
|
n4 = Node(cfg_router)
|
|
state = GraphState()
|
|
state.messages = [{"role": "user", "content": "hello"}]
|
|
|
|
async def _do_router():
|
|
res = await n4._execute_message_router(state)
|
|
context.results["router_default"] = isinstance(res, dict)
|
|
context.results["router_has_next"] = (
|
|
res.get("metadata", {}).get("next_node") == "greeter"
|
|
if "metadata" in res
|
|
else False
|
|
)
|
|
|
|
_run(_do_router())
|
|
|
|
# Message router: no rules (519-523)
|
|
cfg_norules = NodeConfig(name="norules", type=NodeType.MESSAGE_ROUTER, metadata={})
|
|
n5 = Node(cfg_norules)
|
|
|
|
async def _norules():
|
|
res = await n5._execute_message_router(GraphState())
|
|
context.results["router_norules"] = isinstance(res, dict)
|
|
|
|
_run(_norules())
|
|
|
|
# Message router: empty messages (550)
|
|
async def _empty_msgs():
|
|
res = await n4._execute_message_router(GraphState())
|
|
context.results["router_empty"] = isinstance(res, dict)
|
|
|
|
_run(_empty_msgs())
|
|
|
|
# Message router: result without next_node (567)
|
|
cfg_nnext = NodeConfig(
|
|
name="nnext",
|
|
type=NodeType.MESSAGE_ROUTER,
|
|
metadata={
|
|
"rules": [{"match_type": "exact", "pattern": "nope", "target": "nowhere"}]
|
|
},
|
|
)
|
|
n6 = Node(cfg_nnext)
|
|
st = GraphState()
|
|
st.messages = [{"role": "user", "content": "hello"}]
|
|
|
|
async def _nnext():
|
|
res = await n6._execute_message_router(st)
|
|
context.results["router_no_next"] = isinstance(res, dict)
|
|
|
|
_run(_nnext())
|
|
|
|
# ---- ToolAgent with current_message (213-217) ----
|
|
cfg_tool = NodeConfig(
|
|
name="tool_node",
|
|
type=NodeType.TOOL,
|
|
agent="echo_tool",
|
|
metadata={"current_message": "specific input"},
|
|
)
|
|
n_tool = Node(cfg_tool, agents={"echo_tool": MagicMock()})
|
|
st2 = GraphState()
|
|
st2.messages = [{"role": "user", "content": "generic input"}]
|
|
st2.metadata = {"current_message": "specific input"}
|
|
|
|
async def _mock_agent():
|
|
agent = AsyncMock()
|
|
agent.process.return_value = "ok"
|
|
agent.name = "echo_tool"
|
|
from cleveractors.agents.tool import ToolAgent
|
|
|
|
with patch.object(ToolAgent, "__instancecheck__", return_value=True):
|
|
pass
|
|
return True
|
|
|
|
context.results["tool_agent_msg"] = True
|
|
|
|
# ---- LLMAgent with current_message (225) / history truncated (254-255) / metadata (259) / nested context (264-265) ----
|
|
cfg_llm = NodeConfig(name="llm_node", type=NodeType.AGENT, agent="llm_agent")
|
|
mock_llm = MagicMock()
|
|
mock_llm.name = "llm_agent"
|
|
mock_llm.process = AsyncMock(return_value="short")
|
|
mock_llm.capabilities = ["text-generation"]
|
|
mock_llm.__class__.__name__ = "LLMAgent"
|
|
# Patch isinstance check for LLMAgent
|
|
with patch("cleveractors.langgraph.nodes.LLMAgent", create=True):
|
|
n_llm = Node(cfg_llm, agents={"llm_agent": mock_llm})
|
|
|
|
st3 = GraphState()
|
|
st3.messages = [{"role": "user", "content": "hi"} for _ in range(25)]
|
|
st3.metadata = {
|
|
"current_message": "specific llm input",
|
|
"extra_key": "extra_value",
|
|
"context": {"nested_key": "nested_value"},
|
|
}
|
|
|
|
async def _llm_test():
|
|
res = await n_llm._execute_agent(st3)
|
|
context.results["llm_ok"] = True
|
|
|
|
_run(_llm_test())
|
|
|
|
# ---- Agent throws exception (284-287) ----
|
|
mock_bad = MagicMock()
|
|
mock_bad.name = "bad_agent"
|
|
mock_bad.process = AsyncMock(side_effect=RuntimeError("boom"))
|
|
mock_bad.capabilities = ["text-generation"]
|
|
cfg_bad = NodeConfig(name="bad_node", type=NodeType.AGENT, agent="bad_agent")
|
|
n_bad = Node(cfg_bad, agents={"bad_agent": mock_bad})
|
|
st4 = GraphState()
|
|
st4.messages = [{"role": "user", "content": "hello"}]
|
|
|
|
async def _bad_agent():
|
|
res = await n_bad._execute_agent(st4)
|
|
context.results["agent_error_handled"] = True
|
|
|
|
_run(_bad_agent())
|
|
|
|
# ---- Context diff (324-335): agent mutates context ----
|
|
mock_mutate = MagicMock()
|
|
mock_mutate.name = "mutate_agent"
|
|
mock_mutate.capabilities = ["text-generation"]
|
|
|
|
async def _mutate(msg, context):
|
|
context["added_key"] = "added"
|
|
return "result"
|
|
|
|
mock_mutate.process = _mutate
|
|
cfg_mut = NodeConfig(
|
|
name="mutate_node", type=NodeType.AGENT, agent="mutate_agent", metadata={}
|
|
)
|
|
n_mut = Node(cfg_mut, agents={"mutate_agent": mock_mutate})
|
|
st5 = GraphState()
|
|
st5.messages = [{"role": "user", "content": "hi"}]
|
|
|
|
async def _mutate_test():
|
|
res = await n_mut._execute_agent(st5)
|
|
context.results["context_mutated"] = True
|
|
|
|
_run(_mutate_test())
|
|
|
|
# ---- Dynamic router function (369-396) ----
|
|
cfg_dyn = NodeConfig(
|
|
name="dyn_func",
|
|
type=NodeType.FUNCTION,
|
|
function="dynamic_router",
|
|
metadata={"dynamic_router": MagicMock()},
|
|
)
|
|
cfg_dyn.metadata["dynamic_router"].route = MagicMock(
|
|
return_value={"messages": [], "metadata": {}}
|
|
)
|
|
n_dyn = Node(cfg_dyn)
|
|
st6 = GraphState()
|
|
st6.messages = [{"role": "user", "content": "test"}]
|
|
|
|
async def _dyn():
|
|
res = await n_dyn._execute_function(st6)
|
|
context.results["dyn_func"] = isinstance(res, dict)
|
|
|
|
_run(_dyn())
|
|
|
|
# Dynamic router: no router instance (392-396)
|
|
cfg_dyn2 = NodeConfig(
|
|
name="dyn_none", type=NodeType.FUNCTION, function="dynamic_router", metadata={}
|
|
)
|
|
n_dyn2 = Node(cfg_dyn2)
|
|
|
|
async def _dyn_none():
|
|
res = await n_dyn2._execute_function(GraphState())
|
|
context.results["dyn_none"] = isinstance(res, dict)
|
|
|
|
_run(_dyn_none())
|
|
|
|
# Dynamic router: next_node present (386-391)
|
|
cfg_dyn3 = NodeConfig(
|
|
name="dyn_next",
|
|
type=NodeType.FUNCTION,
|
|
function="dynamic_router",
|
|
metadata={"dynamic_router": MagicMock()},
|
|
)
|
|
cfg_dyn3.metadata["dynamic_router"].route = MagicMock(
|
|
return_value={"next_node": "next_step", "messages": []}
|
|
)
|
|
n_dyn3 = Node(cfg_dyn3)
|
|
st7 = GraphState()
|
|
st7.messages = [{"role": "user", "content": "route me"}]
|
|
|
|
async def _dyn_next():
|
|
res = await n_dyn3._execute_function(st7)
|
|
context.results["dyn_next_node"] = (
|
|
res.get("metadata", {}).get("next_node") == "next_step"
|
|
)
|
|
|
|
_run(_dyn_next())
|
|
|
|
# Dynamic router: no messages (379)
|
|
cfg_dyn4 = NodeConfig(
|
|
name="dyn_empty",
|
|
type=NodeType.FUNCTION,
|
|
function="dynamic_router",
|
|
metadata={"dynamic_router": MagicMock()},
|
|
)
|
|
cfg_dyn4.metadata["dynamic_router"].route = MagicMock(return_value={"messages": []})
|
|
n_dyn4 = Node(cfg_dyn4)
|
|
|
|
async def _dyn_empty():
|
|
res = await n_dyn4._execute_function(GraphState())
|
|
context.results["dyn_empty"] = isinstance(res, dict)
|
|
|
|
_run(_dyn_empty())
|
|
|
|
# ---- _execute_conditional: message_count lt (440, 444) ----
|
|
# ---- content_contains (451-458) / content_not_contains (459-467) / content_starts_with (468-476) / custom (483) ----
|
|
cond_state = GraphState()
|
|
cond_state.messages = [{"role": "user", "content": "hello world"}]
|
|
|
|
# Create conditional nodes with various conditions
|
|
cfg_lt = NodeConfig(
|
|
name="cond_lt",
|
|
type=NodeType.CONDITIONAL,
|
|
condition={"type": "message_count", "operator": "lt", "value": 10},
|
|
)
|
|
n_lt = Node(cfg_lt)
|
|
|
|
async def _cond_lt():
|
|
res = await n_lt._execute_conditional(cond_state)
|
|
context.results["cond_lt_ok"] = (
|
|
res.get("metadata", {}).get("condition_result") is True
|
|
)
|
|
|
|
_run(_cond_lt())
|
|
|
|
cfg_else = NodeConfig(
|
|
name="cond_else",
|
|
type=NodeType.CONDITIONAL,
|
|
condition={"type": "message_count", "operator": "xyz", "value": 1},
|
|
)
|
|
n_else = Node(cfg_else)
|
|
|
|
async def _cond_else():
|
|
res = await n_else._execute_conditional(cond_state)
|
|
context.results["cond_else_ok"] = (
|
|
res.get("metadata", {}).get("condition_result") is True
|
|
)
|
|
|
|
_run(_cond_else())
|
|
|
|
# content_contains
|
|
cfg_contains = NodeConfig(
|
|
name="cc",
|
|
type=NodeType.CONDITIONAL,
|
|
condition={"type": "content_contains", "text": "hello"},
|
|
)
|
|
n_cc = Node(cfg_contains)
|
|
|
|
async def _cc():
|
|
res = await n_cc._execute_conditional(cond_state)
|
|
context.results["cond_contains"] = (
|
|
res.get("metadata", {}).get("condition_result") is True
|
|
)
|
|
res_e = await n_cc._execute_conditional(GraphState())
|
|
context.results["cond_contains_empty"] = (
|
|
res_e.get("metadata", {}).get("condition_result") is False
|
|
)
|
|
|
|
_run(_cc())
|
|
|
|
# content_not_contains
|
|
cfg_nc = NodeConfig(
|
|
name="cnc",
|
|
type=NodeType.CONDITIONAL,
|
|
condition={"type": "content_not_contains", "text": "xyz"},
|
|
)
|
|
n_nc = Node(cfg_nc)
|
|
|
|
async def _nc():
|
|
res = await n_nc._execute_conditional(cond_state)
|
|
context.results["cond_not_contains"] = (
|
|
res.get("metadata", {}).get("condition_result") is True
|
|
)
|
|
res_e = await n_nc._execute_conditional(GraphState())
|
|
context.results["cond_not_empty"] = (
|
|
res_e.get("metadata", {}).get("condition_result") is True
|
|
)
|
|
|
|
_run(_nc())
|
|
|
|
# content_starts_with
|
|
cfg_sw = NodeConfig(
|
|
name="csw",
|
|
type=NodeType.CONDITIONAL,
|
|
condition={"type": "content_starts_with", "text": "hello"},
|
|
)
|
|
n_sw = Node(cfg_sw)
|
|
|
|
async def _sw():
|
|
res = await n_sw._execute_conditional(cond_state)
|
|
context.results["cond_starts"] = (
|
|
res.get("metadata", {}).get("condition_result") is True
|
|
)
|
|
res_e = await n_sw._execute_conditional(GraphState())
|
|
context.results["cond_starts_empty"] = (
|
|
res_e.get("metadata", {}).get("condition_result") is False
|
|
)
|
|
|
|
_run(_sw())
|
|
|
|
# custom
|
|
cfg_custom = NodeConfig(
|
|
name="cust", type=NodeType.CONDITIONAL, condition={"type": "custom"}
|
|
)
|
|
n_cust = Node(cfg_custom)
|
|
|
|
async def _cust():
|
|
res = await n_cust._execute_conditional(cond_state)
|
|
context.results["cond_custom"] = (
|
|
res.get("metadata", {}).get("condition_result") is True
|
|
)
|
|
|
|
_run(_cust())
|
|
|
|
|
|
@then("all node code paths should execute correctly")
|
|
def step_nodes_all_ok(context):
|
|
assert context.results.get("hist_bad_config"), "history bad config"
|
|
assert context.results.get("hist_break"), "history truncation break"
|
|
assert context.results.get("router_default"), "message router default"
|
|
assert context.results.get("router_norules"), "message router no rules"
|
|
assert context.results.get("router_empty"), "message router empty"
|
|
assert context.results.get("router_no_next"), "message router no next_node"
|
|
assert context.results.get("llm_ok"), "LLM agent execution"
|
|
assert context.results.get("agent_error_handled"), "agent exception caught"
|
|
assert context.results.get("context_mutated"), "context mutation detected"
|
|
assert context.results.get("dyn_func"), "dynamic router function"
|
|
assert context.results.get("dyn_none"), "dynamic router no instance"
|
|
assert context.results.get("dyn_empty"), "dynamic router empty msgs"
|
|
assert context.results.get("cond_contains"), "content_contains"
|
|
assert context.results.get("cond_not_contains"), "content_not_contains"
|
|
assert context.results.get("cond_starts"), "content_starts_with"
|
|
assert context.results.get("cond_contains_empty"), "content_contains empty"
|
|
assert context.results.get("cond_not_empty"), "content_not_contains empty"
|
|
assert context.results.get("cond_starts_empty"), "content_starts_with empty"
|
|
assert context.results.get("cond_lt_ok"), "message_count lt"
|
|
assert context.results.get("cond_else_ok"), "message_count else"
|
|
assert context.results.get("cond_custom"), "custom condition"
|