diff --git a/tests/features/router_detailed.feature b/tests/features/router_detailed.feature new file mode 100644 index 000000000..673f05738 --- /dev/null +++ b/tests/features/router_detailed.feature @@ -0,0 +1,141 @@ +Feature: Router Detailed Scenarios for coverage + + Background: + Given I import the agent module + + Scenario: Add an invalid route (not a dictionary) + Given a router with no agents + When I try to add a route which is not a dictionary + Then a RoutingError should be raised with message "Route must be a dictionary" + + Scenario Outline: Add a route with missing required keys + Given a router with agent "agent1" + When I try to add a route with key "" removed from a valid route + Then a RoutingError should be raised with message "Route missing required key: " + Examples: + | key_to_remove | + | from | + | to | + + Scenario Outline: Add a route with non-existent agents + Given a router with one agent named "agent1" + When I try to add a route from "" to "" + Then a RoutingError should be raised with message "" + Examples: + | source | destination | error_message | + | agent2 | agent1 | Source agent 'agent2' not found | + | agent1 | agent2 | Destination agent 'agent2' not found | + + Scenario Outline: Add a route with alias keys + Given a router with agent "agent1" + When I add a route with from key "" and to key "" + And I route the message "hello" from "input" + Then the destination should be "agent1" + Examples: + | from_key | to_key | + | source | destination | + | from | destination | + | source | to | + + Scenario: Route message with no matching route for input and a single agent + Given a router with agent "agent1" + And no routes + When I route the message "hello" from "input" + Then the destination should be "agent1" + And the message must be "hello" + + Scenario: Route message with no matching route for input and multiple agents + Given a router with agents "agent1" and "agent2" + And no routes + When I try to route the message "hello" from "input" + Then a RoutingError should be raised with message "No route found for input message" + + Scenario: Route message with no matching route for a non-input source + Given a router with agent "agent1" + And a route coming from "input" to "agent1" + When I route the message "hello" from "agent1" + Then the destination should be "output" + And the message must be "hello" + + Scenario: Route message with a condition that raises an exception + Given a router with agent "agent1" + And a route from "input" to "agent1" with a condition that will raise an exception + When I try to route the message "hello" from "input" + Then a RoutingError should be raised with a message containing "Error evaluating condition" + + Scenario: Route message with context modification that renders invalid JSON + Given a router with agent "agent1" + And a route from "input" to "agent1" with a context modification template "{'key': 'value'}" which is invalid JSON + When I try to route the message "hello" from "input" + Then a RoutingError should be raised with a message containing "Failed to decode JSON from rendered context template" + + Scenario: Route message with context modification that renders non-dict JSON + Given a router with agent "agent1" + And a route from "input" to "agent1" with a context modification template that renders to a list + When I try to route the message "hello" from "input" + Then a RoutingError should be raised with message "Context template must render to a JSON object (dict)." + + Scenario: Route message with context modification that fails to render + Given a router with agent "agent1" + And a route from "input" to "agent1" with a context modification template that will fail to render + When I try to route the message "hello" from "input" + Then a RoutingError should be raised with a message containing "Error processing context template" + + Scenario: Route message with context modification attempting to modify a protected key + Given a router with agent "agent1" + And a route must go from "input" to "agent1" with a context modification template that modifies "history" + When I try to route the message "hello" from "input" + Then a RoutingError should be raised with message "Cannot modify or delete protected context key: 'history'" + + Scenario: Route message with context modification that deletes a key + Given a router with agent "agent1" + And a route from "input" to "agent1" with a context modification template that deletes key "to_delete" + And an initial context with "to_delete" set to "some_value" + When I route the message "hello" from "input" with the initial context + Then the context should not contain the key "to_delete" + + Scenario: Route message with an unsupported transform format + Given a router with agent "agent1" + And a route from "input" to "agent1" with an unsupported transform format + When I try to route the message "hello" from "input" + Then a RoutingError should be raised with a message containing "Unsupported transform format" + + Scenario: Route message with a transform that fails to render + Given a router with agent "agent1" + And a route from "input" to "agent1" with a transform template that will fail to render + When I try to route the message "hello" from "input" + Then a RoutingError should be raised with a message containing "Error applying template transformation" + + Scenario: Route message with transform as a dictionary + Given a router with agent "agent1" + And a route from "input" to "agent1" with a transform as a dictionary + When I route the message "hello" from "input" + Then the message must be "transformed: hello" + + Scenario: Process message with a destination agent not in router + Given a router with agent "agent1" + And a special route from "input" to "non_existent_agent" + When I try to process the message "hello" with the router that fails + Then a RoutingError should be raised with message "Destination agent 'non_existent_agent' not found in router." + + Scenario: Process message where agent process raises an exception + Given a router with an agent "failing_agent" that always fails + And a route coming from "input" to "failing_agent" + When I try to process the message "hello" with the router that fails + Then a RoutingError should be raised with a message containing "Error processing message with agent 'failing_agent'" + + Scenario: Process message that exceeds maximum routing steps + Given a router with agents "agent1" and "agent2" + And a route coming from "input" to "agent1" + And a route coming from "agent1" to "agent2" + And a route coming from "agent2" to "agent1" + When I try to process the message "hello" with the router that fails + Then a RoutingError should be raised with message "Maximum routing steps exceeded" + + Scenario: Process message correctly populates initial context + Given a router with agent "agent1" + And a route coming from "input" to "agent1" + And a route coming from "agent1" to "output" + When I process the message "hello" with the router + Then agent "agent1" should have received a context containing "initial_message" with value "hello" + And agent "agent1" should have received a context containing "history" as an empty list diff --git a/tests/features/steps/router_detailed_steps.py b/tests/features/steps/router_detailed_steps.py new file mode 100644 index 000000000..563f9d23d --- /dev/null +++ b/tests/features/steps/router_detailed_steps.py @@ -0,0 +1,349 @@ +import asyncio +import copy +import json +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from unittest.mock import MagicMock + +from behave import given +from behave import then +from behave import when + +from cleveragents.agents.base import Agent +from cleveragents.core.exceptions import RoutingError +from cleveragents.core.exceptions import TemplateError +from cleveragents.routing.router import Router +from cleveragents.templates.renderer import TemplateRenderer + + +# Mock Agent class for testing +class MockAgent(Agent): + def __init__( + self, + name: str, + config: Dict[str, Any] = None, + template_renderer=None, + should_fail=False, + ): + super().__init__(name, config or {}, template_renderer) + self.should_fail = should_fail + self.received_messages = [] + + async def process( + self, message: str, context: Optional[Dict[str, Any]] = None + ) -> str: + if self.should_fail: + raise Exception("Agent processing failed") + self.received_messages.append( + {"message": message, "context": copy.deepcopy(context)} + ) + # Return a different message to test message propagation + return f"processed by {self.name}" + + def get_capabilities(self) -> List[str]: + return ["mock"] + + +# Helper to set up a router +def setup_router(context, agent_names: List[str]): + context.template_renderer = MagicMock(spec=TemplateRenderer) + context.agents = { + name: MockAgent(name, template_renderer=context.template_renderer) + for name in agent_names + } + context.router = Router("test_router", context.agents, context.template_renderer) + + +def _route_message_step(context, message, source, use_initial_context=False): + context.error = None + context.result = None + + initial_context = ( + context.initial_context + if use_initial_context and hasattr(context, "initial_context") + else {} + ) + + async def run(): + try: + context.result = await context.router.route_message( + message, source=source, context=initial_context + ) + # The context might have been modified, so we store it for assertions + context.final_context = initial_context + except RoutingError as e: + context.error = e + + asyncio.run(run()) + + +@given("a router with no agents") +def step_impl(context): + setup_router(context, []) + + +@when("I try to add a route which is not a dictionary") +def step_impl(context): + context.error = None + try: + context.router.add_route("not a dict") + except RoutingError as e: + context.error = e + + +@then('a RoutingError should be raised with message "{message}"') +def step_impl(context, message): + assert context.error is not None, "RoutingError was not raised" + assert isinstance(context.error, RoutingError) + assert str(context.error) == message + + +@given('a router with agent "{agent_name}"') +def step_impl(context, agent_name): + setup_router(context, [agent_name]) + + +@when('I try to add a route with key "{key_to_remove}" removed from a valid route') +def step_impl(context, key_to_remove): + route = {"from": "input", "to": "agent1"} + del route[key_to_remove] + context.error = None + try: + context.router.add_route(route) + except RoutingError as e: + context.error = e + + +@given('a router with one agent named "{agent_name}"') +def step_impl(context, agent_name): + setup_router(context, [agent_name]) + + +@when('I try to add a route from "{source}" to "{destination}"') +def step_impl(context, source, destination): + route = {"from": source, "to": destination} + context.error = None + try: + context.router.add_route(route) + except RoutingError as e: + context.error = e + + +@when('I add a route with from key "{from_key}" and to key "{to_key}"') +def step_impl(context, from_key, to_key): + route = {from_key: "input", to_key: "agent1"} + context.router.add_route(route) + + +@given("no routes") +def step_impl(context): + context.router.routes = [] + + +@when('I route the message "{message}" from "{source}"') +def step_impl_route(context, message, source): + _route_message_step(context, message, source) + + +@when('I try to route the message "{message}" from "{source}"') +def step_impl_try_route(context, message, source): + _route_message_step(context, message, source) + + +@then('the destination should be "{destination}"') +def step_impl(context, destination): + assert context.result["destination"] == destination + + +@then('the message must be "{message}"') +def step_impl(context, message): + assert context.result["message"] == message + + +@given('a router with agents "{agent1}" and "{agent2}"') +def step_impl(context, agent1, agent2): + setup_router(context, [agent1, agent2]) + + +@given('a route coming from "{source}" to "{destination}"') +def step_impl(context, source, destination): + context.router.add_route({"from": source, "to": destination}) + + +@given( + 'a route from "{source}" to "{destination}" with a condition that will raise an exception' +) +def step_impl(context, source, destination): + mock_evaluator = MagicMock() + mock_evaluator.evaluate.side_effect = Exception("mock condition error") + context.router.condition_evaluator = mock_evaluator + context.router.add_route( + {"from": source, "to": destination, "condition": "anything"} + ) + + +@then('a RoutingError should be raised with a message containing "{text}"') +def step_impl(context, text): + assert context.error is not None, "RoutingError was not raised" + assert isinstance(context.error, RoutingError) + assert text in str(context.error) + + +@given( + 'a route from "{source}" to "{destination}" with a context modification template "{template}" which is invalid JSON' +) +def step_impl(context, source, destination, template): + context.template_renderer.render_string.return_value = template + route = {"from": source, "to": destination, "context": "some template string"} + context.router.add_route(route) + + +@given( + 'a route from "{source}" to "{destination}" with a context modification template that renders to a list' +) +def step_impl(context, source, destination): + context.template_renderer.render_string.return_value = '["item1", "item2"]' + route = {"from": source, "to": destination, "context": "some template string"} + context.router.add_route(route) + + +@given( + 'a route from "{source}" to "{destination}" with a context modification template that will fail to render' +) +def step_impl(context, source, destination): + context.template_renderer.render_string.side_effect = TemplateError( + "mock render error" + ) + route = {"from": source, "to": destination, "context": "failing template"} + context.router.add_route(route) + + +@given( + 'a route must go from "{source}" to "{destination}" with a context modification template that modifies "{protected_key}"' +) +def step_impl(context, source, destination, protected_key): + context.template_renderer.render_string.return_value = json.dumps( + {protected_key: "new value"} + ) + route = {"from": source, "to": destination, "context": "modifying template"} + context.router.add_route(route) + + +@given( + 'a route from "{source}" to "{destination}" with a context modification template that deletes key "{key_to_delete}"' +) +def step_impl(context, source, destination, key_to_delete): + context.template_renderer.render_string.return_value = json.dumps( + {key_to_delete: None} + ) + route = {"from": source, "to": destination, "context": "deleting template"} + context.router.add_route(route) + + +@given('an initial context with "{key}" set to "{value}"') +def step_impl(context, key, value): + context.initial_context = {key: value} + + +@when('I route the message "{message}" from "{source}" with the initial context') +def step_impl(context, message, source): + _route_message_step(context, message, source, use_initial_context=True) + + +@when('I try to process the message "{message}" with the router that fails') +def step_impl_try_process_fail(context, message): + context.error = None + context.response = None + try: + + async def run(): + context.response = await context.router.process_message(message) + + asyncio.run(run()) + except RoutingError as e: + context.error = e + + +@then('the context should not contain the key "{key}"') +def step_impl(context, key): + assert key not in context.final_context + + +@given( + 'a route from "{source}" to "{destination}" with an unsupported transform format' +) +def step_impl(context, source, destination): + route = {"from": source, "to": destination, "transform": 123} + context.router.add_route(route) + + +@given( + 'a route from "{source}" to "{destination}" with a transform template that will fail to render' +) +def step_impl(context, source, destination): + context.template_renderer.render_string.side_effect = TemplateError( + "mock render error" + ) + route = {"from": source, "to": destination, "transform": "failing template"} + context.router.add_route(route) + + +@given('a route from "{source}" to "{destination}" with a transform as a dictionary') +def step_impl(context, source, destination): + context.template_renderer.render_string.return_value = "transformed: hello" + route = { + "from": source, + "to": destination, + "transform": {"template": "transformed: {message}"}, + } + context.router.add_route(route) + + +@when('I process the message "{message}" with the router') +def step_impl(context, message): + context.error = None + context.response = None + + async def run(): + try: + context.response = await context.router.process_message(message) + except (RoutingError, Exception) as e: + context.error = e + + asyncio.run(run()) + + +@given('a router with an agent "{agent_name}" that always fails') +def step_impl(context, agent_name): + context.template_renderer = MagicMock(spec=TemplateRenderer) + context.agents = {agent_name: MockAgent(agent_name, should_fail=True)} + context.router = Router("test_router", context.agents, context.template_renderer) + + +@then( + 'agent "{agent_name}" should have received a context containing "{key}" with value "{value}"' +) +def step_impl(context, agent_name, key, value): + agent = context.agents[agent_name] + assert len(agent.received_messages) > 0 + received_context = agent.received_messages[0]["context"] + assert key in received_context + assert received_context[key] == value + + +@then( + 'agent "{agent_name}" should have received a context containing "{key}" as an empty list' +) +def step_impl(context, agent_name, key): + agent = context.agents[agent_name] + assert len(agent.received_messages) > 0 + received_context = agent.received_messages[0]["context"] + assert key in received_context + assert received_context[key] == [] + + +@given('a special route from "{source}" to "{destination}"') +def step_impl(context, source, destination): + context.router.routes.append({"from": source, "to": destination})