forked from HAL9000/cleveragents-core
a808c395f9
Add 53 new .feature files and corresponding step definition files targeting uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts in 7 pre-existing step files by disambiguating step text. New tests cover: ACP clients/facade, actor CLI/config, application container, ACMS service/strategies, async worker, automation profile CLI, autonomy guardrail, bridge, change model, config CLI/service, context service, cross-plan correction, database models, decision service, decomposition clustering/service, discovery handler, langchain chat provider, langgraph nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/ preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI, provider registry, reactive application/route, repositories, resolver handler, resource registry service, resume model, retry patterns, sandbox protocol, server CLI, skill CLI/service, skills registry, subplan execution/service, system CLI, UKO loader, UoW, and YAML template engine. Closes #645
227 lines
7.8 KiB
Python
227 lines
7.8 KiB
Python
"""Step definitions targeting uncovered lines in cleveragents.langgraph.nodes."""
|
|
|
|
import asyncio
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.langgraph.nodes import Edge, Node, NodeConfig, NodeType, ToolAgent
|
|
from cleveragents.langgraph.state import GraphState
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_state(messages=None, metadata=None):
|
|
return GraphState(messages=messages or [], metadata=metadata or {})
|
|
|
|
|
|
def _run(coro):
|
|
"""Run a coroutine in a fresh event loop."""
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
class _RecordingToolAgent(ToolAgent):
|
|
"""ToolAgent subclass that records inputs."""
|
|
|
|
def __init__(self, name: str, response: str):
|
|
super().__init__(name)
|
|
self.response = response
|
|
self.received_inputs: list = []
|
|
|
|
async def process_message(self, message, context=None):
|
|
self.received_inputs.append(message)
|
|
return self.response
|
|
|
|
def get_capabilities(self):
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 130 - Unknown node type -> result = {}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a node whose type is patched to an unrecognised value")
|
|
def step_patch_node_type(context):
|
|
config = NodeConfig(name="unknown", type=NodeType.END)
|
|
node = Node(config)
|
|
# Patch the type to something that won't match any branch
|
|
node.type = "UNRECOGNISED"
|
|
context.node = node
|
|
context.state = _make_state()
|
|
|
|
|
|
@when("I execute the unknown-type node")
|
|
def step_execute_unknown_type(context):
|
|
context.result = _run(context.node.execute(context.state))
|
|
|
|
|
|
@then("the result should contain current_node and nothing else")
|
|
def step_assert_unknown_type_result(context):
|
|
assert context.result == {"current_node": "unknown"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 176 - ToolAgent with empty current_message -> fallback to last message
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ToolAgent node with an empty current_message and messages in state")
|
|
def step_toolagent_empty_current_msg(context):
|
|
tool_agent = _RecordingToolAgent("tool_fb", response="tooled")
|
|
config = NodeConfig(name="tool_fb_node", type=NodeType.AGENT, agent="tool_fb")
|
|
context.node = Node(config, agents={"tool_fb": tool_agent})
|
|
context.state = _make_state(
|
|
messages=[
|
|
{"role": "user", "content": "hello from user"},
|
|
{"role": "assistant", "content": "last_content"},
|
|
],
|
|
metadata={"current_message": ""}, # falsy string
|
|
)
|
|
|
|
|
|
@when("I execute the ToolAgent fallback node")
|
|
def step_execute_toolagent_fallback(context):
|
|
context.result = _run(context.node.execute(context.state))
|
|
|
|
|
|
@then("the ToolAgent should receive the last message content as input")
|
|
def step_assert_toolagent_fallback_input(context):
|
|
agent = context.node.agents["tool_fb"]
|
|
# current_message is "" (falsy), so it should fall back to last message content
|
|
assert agent.received_inputs[-1] == "last_content"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 270 - _execute_function with non-callable object
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a function node whose registered object is not callable")
|
|
def step_non_callable_function(context):
|
|
config = NodeConfig(name="fn_nc", type=NodeType.FUNCTION, function="not_a_fn")
|
|
# Register a non-callable object under the function name
|
|
context.node = Node(config, agents={"not_a_fn": 42})
|
|
context.state = _make_state()
|
|
|
|
|
|
@when("I execute the non-callable function node")
|
|
def step_execute_non_callable_fn(context):
|
|
context.result = _run(context.node.execute(context.state))
|
|
|
|
|
|
@then("the result should report a not-callable error")
|
|
def step_assert_non_callable_error(context):
|
|
assert context.result.get("failed_node") == "fn_nc"
|
|
assert "not callable" in context.result.get("error", "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 319 - message router rule with target=None -> continue
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a message router with a rule that has no target")
|
|
def step_router_null_target(context):
|
|
rules = [
|
|
{"condition": {"equals": "anything"}, "target": None},
|
|
]
|
|
config = NodeConfig(
|
|
name="router_null", type=NodeType.MESSAGE_ROUTER, metadata={"rules": rules}
|
|
)
|
|
context.node = Node(config)
|
|
context.state = _make_state(metadata={"current_message": "anything"})
|
|
|
|
|
|
@when("I execute the null-target router")
|
|
def step_execute_null_target_router(context):
|
|
context.result = _run(context.node.execute(context.state))
|
|
|
|
|
|
@then("the router should return routed_to as None")
|
|
def step_assert_null_target_router(context):
|
|
metadata = context.result.get("metadata", {})
|
|
assert metadata.get("routed_to") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 321-322 - message router rule with condition=None -> unconditional match
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a message router with an unconditional rule targeting "{target}"')
|
|
def step_router_unconditional(context, target):
|
|
rules = [
|
|
{"condition": None, "target": target},
|
|
]
|
|
config = NodeConfig(
|
|
name="router_uncond", type=NodeType.MESSAGE_ROUTER, metadata={"rules": rules}
|
|
)
|
|
context.node = Node(config)
|
|
context.state = _make_state(metadata={"current_message": "irrelevant"})
|
|
|
|
|
|
@when("I execute the unconditional router")
|
|
def step_execute_unconditional_router(context):
|
|
context.result = _run(context.node.execute(context.state))
|
|
|
|
|
|
@then('the router should route to "{target}"')
|
|
def step_assert_router_target(context, target):
|
|
metadata = context.result.get("metadata", {})
|
|
assert metadata.get("routed_to") == target
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 319 + 321-322 combined - null-target skipped, then unconditional hit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given(
|
|
'a message router with a null-target rule followed by an unconditional rule to "{target}"'
|
|
)
|
|
def step_router_mixed_rules(context, target):
|
|
rules = [
|
|
{"condition": {"equals": "x"}, "target": None}, # line 319: continue
|
|
{"condition": None, "target": target}, # lines 321-322: match
|
|
]
|
|
config = NodeConfig(
|
|
name="router_mixed", type=NodeType.MESSAGE_ROUTER, metadata={"rules": rules}
|
|
)
|
|
context.node = Node(config)
|
|
context.state = _make_state(metadata={"current_message": "x"})
|
|
|
|
|
|
@when("I execute the mixed-rules router")
|
|
def step_execute_mixed_router(context):
|
|
context.result = _run(context.node.execute(context.state))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 337 - evaluate_edge_condition returns True when no condition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an edge with no condition")
|
|
def step_edge_no_condition(context):
|
|
context.edge = Edge(source="a", target="b", condition=None)
|
|
context.node = Node(NodeConfig(name="edge_node", type=NodeType.END))
|
|
context.state = _make_state(messages=[{"role": "user", "content": "test"}])
|
|
|
|
|
|
@when("I evaluate the unconditioned edge")
|
|
def step_evaluate_unconditioned_edge(context):
|
|
context.edge_result = context.node.evaluate_edge_condition(
|
|
context.edge, context.state
|
|
)
|
|
|
|
|
|
@then("the edge evaluation should return true")
|
|
def step_assert_unconditioned_edge(context):
|
|
assert context.edge_result is True
|