"""Step definitions for Runtime Executor API BDD tests. Given steps (environment, config, credentials, limits, pricing) are defined in ``runtime_config_steps.py`` (extracted to stay under the 500-line file limit, CONTRIBUTING.md General Principles). """ from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from behave import given, then, when from behave.api.async_step import async_run_until_complete from cleveractors.core.exceptions import ConfigurationError, ExecutionError from cleveractors.result import ActorResult, NodeUsage from cleveractors.runtime import ( Executor, create_executor, ) @given("the LLM agent is configured to fail") def step_llm_fail(context: Any) -> None: context._llm_should_fail = True @when("I call create_executor") def step_call_create_executor(context: Any) -> None: context.test_executor = create_executor( config_dict=context.config_dict, credentials=context.credentials, limits=context.limits if hasattr(context, "limits") else None, pricing=context.pricing if hasattr(context, "pricing") else None, ) @when("I call create_executor with None limits and pricing") def step_call_create_executor_none(context: Any) -> None: context.test_executor = create_executor( config_dict=context.config_dict, credentials=context.credentials, limits=None, pricing=None, ) @when("I execute the actor with message {msg}") @async_run_until_complete async def step_execute_actor(context: Any, msg: str) -> None: msg = msg.strip('"') executor = create_executor( config_dict=context.config_dict, credentials=context.credentials, limits=context.limits if hasattr(context, "limits") and context.limits else {}, pricing=context.pricing if hasattr(context, "pricing") and context.pricing else {}, ) context.test_executor = executor with ( patch("cleveractors.runtime_dispatch.TemplateRenderer") as mock_renderer, patch("cleveractors.agents.llm.LLMAgent") as mock_llm, patch("cleveractors.runtime_dispatch.AgentFactory") as mock_factory, patch("cleveractors.runtime_dispatch.PureLangGraph") as mock_pure_graph, ): mock_renderer_instance = MagicMock() mock_renderer.return_value = mock_renderer_instance if getattr(context, "_llm_should_fail", False): mock_llm_instance = MagicMock() mock_llm_instance.process_message = AsyncMock( side_effect=Exception("LLM API error") ) mock_llm_instance.cleanup = AsyncMock() mock_llm_instance._last_token_usage = (0, 0) else: mock_llm_instance = MagicMock() mock_llm_instance.process_message = AsyncMock( return_value="Mock LLM response" ) mock_llm_instance.cleanup = AsyncMock() # Simulate real token usage from LLMAgent._last_token_usage (AC2) mock_llm_instance._last_token_usage = (100, 50) mock_llm.return_value = mock_llm_instance mock_graph_instance = MagicMock() # PureLangGraph.execute() returns a 3-tuple (AC4): (response, state, node_usages) mock_graph_instance.execute = AsyncMock( return_value=( "Mock graph response", {}, [("entry", "openai", "gpt-3.5-turbo", 100, 50)], ) ) mock_pure_graph.return_value = mock_graph_instance mock_factory_instance = MagicMock() mock_factory_instance.create_agent = MagicMock(return_value=mock_llm_instance) mock_factory.return_value = mock_factory_instance try: context.test_result = await executor.execute(msg) context.test_error = None except Exception as e: context.test_error = e context.test_result = None @then("an Executor instance should be returned") def step_assert_executor(context: Any) -> None: assert context.test_executor is not None assert isinstance(context.test_executor, Executor) @then("the executor config should match the config dict") def step_assert_config(context: Any) -> None: assert context.test_executor.config == context.config_dict @then("the executor credentials should match the credentials dict") def step_assert_credentials(context: Any) -> None: assert context.test_executor.credentials == context.credentials @then("the executor limits should match the limits dict") def step_assert_limits(context: Any) -> None: assert context.test_executor.limits == context.limits @then("the executor pricing should match the pricing dict") def step_assert_pricing(context: Any) -> None: assert context.test_executor.pricing == context.pricing @then("the executor limits should be an empty dictionary") def step_assert_limits_empty(context: Any) -> None: assert context.test_executor.limits == {} @then("the executor pricing should be an empty dictionary") def step_assert_pricing_empty(context: Any) -> None: assert context.test_executor.pricing == {} @then("the execution should return an ActorResult") def step_assert_actor_result(context: Any) -> None: assert context.test_result is not None assert isinstance(context.test_result, ActorResult) @then("the ActorResult response should be non-empty") def step_assert_response(context: Any) -> None: assert context.test_result.response is not None assert len(context.test_result.response) > 0 @then("the ActorResult should have token usage tracked") def step_assert_tokens(context: Any) -> None: assert context.test_result.prompt_tokens == 100, ( f"Expected prompt_tokens == 100, got {context.test_result.prompt_tokens}" ) assert context.test_result.completion_tokens == 50, ( f"Expected completion_tokens == 50, got {context.test_result.completion_tokens}" ) @then("the ActorResult should have at least one node usage entry") def step_assert_nodes(context: Any) -> None: assert len(context.test_result.nodes) >= 1 @then("the ActorResult should have zero prompt tokens for tool agents") def step_assert_zero_tokens(context: Any) -> None: assert context.test_result.prompt_tokens == 0 assert context.test_result.completion_tokens == 0 @then("the node usage IDs should be prefixed with the default actor name") def step_assert_node_prefix(context: Any) -> None: for node in context.test_result.nodes: assert node.node_id.startswith("default.") @then("a ConfigurationError should be raised for runtime") def step_assert_config_error(context: Any) -> None: assert context.test_error is not None assert isinstance(context.test_error, ConfigurationError) @then("the error message should mention the unknown actor type") def step_assert_error_unknown_type(context: Any) -> None: assert "unknown_type" in str(context.test_error).lower() @then( "an ExecutionError should be raised for runtime with the original cause suppressed" ) def step_assert_execution_error_cause_suppressed(context: Any) -> None: assert context.test_error is not None assert isinstance(context.test_error, ExecutionError) assert context.test_error.__cause__ is None, ( f"Expected ExecutionError with suppressed cause, " f"but got __cause__={context.test_error.__cause__!r}" ) @then("a ConfigurationError should be raised for runtime about no actors") def step_assert_no_actors_error(context: Any) -> None: assert context.test_error is not None assert isinstance(context.test_error, ConfigurationError) assert "no actors" in str(context.test_error).lower(), ( f"Expected error to mention 'no actors', got: {context.test_error}" ) @when("I import from cleaveractors.runtime_types") def step_import_runtime_types(context): from cleveractors.runtime_types import ActorResult, NodeUsage context.rt_actor_result = ActorResult context.rt_node_usage = NodeUsage @then("ActorResult and NodeUsage should be importable") def step_assert_rt_imports(context): assert context.rt_actor_result is not None assert context.rt_node_usage is not None