Files
temp/features/steps/stream_router_remaining_coverage_steps.py
CoreRasurae f2f7aa5dc9 feat(core): add v3 lifecycle models, automation levels, subplan support, and security hardening
Implement multiple Stage A/B/E/SEC milestones for the v3 lifecycle system:
- Stage A5.3+A5.4: Add LifecycleActionModel and LifecyclePlanModel SQLAlchemy
  models with to_domain()/from_domain() conversion methods
- Stage A5.6: Implement ActionRepository with full CRUD, namespace/state
  queries, referential integrity checks, and retry decorator
- Stage E1: Add subplan domain models (ExecutionMode, SubplanMergeStrategy,
  SubplanConfig, SubplanStatus, SubplanAttempt, SubplanFailureHandler) with
  computed properties on Plan (is_subplan, is_root_plan, depth, has_subplans)
- Stage A6: Add AutomationLevel enum (MANUAL, REVIEW_BEFORE_APPLY,
  FULL_AUTOMATION), settings integration, PlanLifecycleService auto-progression,
  pause/resume, and CLI commands (--automation-level, set-automation-level)
- Stage SEC1: Remove eval()/exec() from stream_router.py, replace with named
  operation and transform registries; code blocks and unregistered transforms
  now raise StreamRoutingError
- Add langchain-anthropic dependency
- Update BDD tests for security changes and relax ADR directory requirement
2026-02-12 20:19:42 +00:00

682 lines
25 KiB
Python

"""Step definitions for stream_router remaining coverage tests.
Targets the uncovered lines and partial branches identified in
build/coverage.xml for src/cleveragents/reactive/stream_router.py.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.core.exceptions import StreamRoutingError
from cleveragents.reactive.stream_router import (
ReactiveStreamRouter,
SimpleLLMAgent,
SimpleToolAgent,
StreamConfig,
StreamMessage,
)
# ---------------------------------------------------------------------------
# SimpleToolAgent.register_operation - invalid name (lines 121-122)
# ---------------------------------------------------------------------------
@given("a fresh SimpleToolAgent registry")
def step_given_fresh_tool_agent_registry(context: Any) -> None:
context.error = None
@when('I register an operation named "bad-name!" with a lambda')
def step_when_register_invalid_operation(context: Any) -> None:
try:
SimpleToolAgent.register_operation("bad-name!", lambda c, m, x: c)
except ValueError as exc:
context.error = exc
@then("a ValueError should be raised about operation name format")
def step_then_value_error_operation_name(context: Any) -> None:
assert context.error is not None, "Expected ValueError but none was raised"
assert isinstance(context.error, ValueError)
assert "alphanumeric" in str(context.error).lower()
# ---------------------------------------------------------------------------
# SimpleToolAgent.process - tool is not a dict (lines 138-139)
# ---------------------------------------------------------------------------
@given("a SimpleToolAgent whose tools list contains a non-dict entry")
def step_given_tool_agent_non_dict_tool(context: Any) -> None:
# tools list where the first element is a string, not a dict
context.tool_agent = SimpleToolAgent(tools=["not_a_dict"])
@when('I process content "{content}" through that SimpleToolAgent')
def step_when_process_through_tool_agent(context: Any, content: str) -> None:
context.tool_result = context.tool_agent.process(content)
@then('the SimpleToolAgent should return "{expected}" unchanged')
def step_then_tool_agent_returns_unchanged(context: Any, expected: str) -> None:
assert context.tool_result == expected, (
f"Expected '{expected}', got '{context.tool_result}'"
)
# ---------------------------------------------------------------------------
# SimpleToolAgent.process - unknown operation name (lines 146-151)
# ---------------------------------------------------------------------------
@given('a SimpleToolAgent with an unknown operation "nonexistent_op"')
def step_given_tool_agent_unknown_op(context: Any) -> None:
context.tool_agent = SimpleToolAgent(tools=[{"operation": "nonexistent_op"}])
# ---------------------------------------------------------------------------
# SimpleToolAgent.process - operation raises exception (lines 154-155)
# ---------------------------------------------------------------------------
@given("a SimpleToolAgent with an operation that raises an exception")
def step_given_tool_agent_raising_op(context: Any) -> None:
def _raise(content: Any, meta: Any, ctx: Any) -> None:
raise RuntimeError("deliberate test explosion")
SimpleToolAgent.register_operation("exploding_op", _raise)
context.tool_agent = SimpleToolAgent(tools=[{"operation": "exploding_op"}])
@then("the SimpleToolAgent should return an empty string")
def step_then_tool_agent_returns_empty(context: Any) -> None:
assert context.tool_result == "", (
f"Expected empty string, got '{context.tool_result}'"
)
# ---------------------------------------------------------------------------
# ReactiveStreamRouter.register_transform - invalid name (lines 264-265)
# ---------------------------------------------------------------------------
@given("a fresh ReactiveStreamRouter registry")
def step_given_fresh_router_registry(context: Any) -> None:
context.error = None
@when('I register a transform named "bad@name" with a lambda')
def step_when_register_invalid_transform(context: Any) -> None:
try:
ReactiveStreamRouter.register_transform("bad@name", lambda x: x)
except ValueError as exc:
context.error = exc
@then("a ValueError should be raised about transform name format")
def step_then_value_error_transform_name(context: Any) -> None:
assert context.error is not None, "Expected ValueError but none was raised"
assert isinstance(context.error, ValueError)
assert "alphanumeric" in str(context.error).lower()
# ---------------------------------------------------------------------------
# _create_operator transform - no fn or type (lines 430, 433)
# ---------------------------------------------------------------------------
@given("a reactive stream router for coverage")
def step_given_router_for_coverage(context: Any) -> None:
context.router = ReactiveStreamRouter()
context.error = None
context.result = None
context.results = []
@given('a stream named "{name}" exists on the router')
def step_given_stream_exists(context: Any, name: str) -> None:
context.router.create_stream(StreamConfig(name=name))
@when("I create a transform operator with neither fn nor type")
def step_when_create_transform_no_fn_no_type(context: Any) -> None:
try:
context.router._create_operator(
{"type": "transform", "params": {"something_else": True}}
)
except StreamRoutingError as exc:
context.error = exc
@then("a StreamRoutingError should be raised about missing transform params")
def step_then_routing_error_transform_params(context: Any) -> None:
assert context.error is not None, "Expected StreamRoutingError but none raised"
assert isinstance(context.error, StreamRoutingError)
assert "fn" in str(context.error) or "type" in str(context.error)
# ---------------------------------------------------------------------------
# SimpleLLMAgent._resolve_llm - no optional kwargs (branches 203, 205, 207)
# ---------------------------------------------------------------------------
@given("a SimpleLLMAgent with no temperature or max_tokens or max_retries")
def step_given_llm_agent_no_optional_kwargs(context: Any) -> None:
# Only provider and model set; temperature/max_tokens/max_retries absent
context.llm_agent = SimpleLLMAgent(
name="test-llm",
config={"provider": "mock", "model": "mock-model"},
)
@given("a stub provider registry is active")
def step_given_stub_provider_registry_active(context: Any) -> None:
mock_llm = MagicMock()
mock_llm.invoke.return_value = MagicMock(content="stub response")
mock_registry = MagicMock()
mock_registry.create_llm.return_value = mock_llm
context._registry_patch = patch(
"cleveragents.reactive.stream_router.get_provider_registry",
return_value=mock_registry,
)
context._registry_patch.start()
context._mock_registry = mock_registry
context._mock_llm = mock_llm
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(context._registry_patch.stop)
@when("I resolve the LLM on that agent")
def step_when_resolve_llm(context: Any) -> None:
context.llm_agent._resolve_llm()
@then("the LLM should be resolved without optional kwargs")
def step_then_llm_resolved_no_optional(context: Any) -> None:
call_kwargs = context._mock_registry.create_llm.call_args
# The kwargs passed to create_llm should NOT include temperature, max_tokens, max_retries
kw = call_kwargs.kwargs if hasattr(call_kwargs, "kwargs") else call_kwargs[1]
assert "temperature" not in kw, f"temperature should not be in kwargs: {kw}"
assert "max_tokens" not in kw, f"max_tokens should not be in kwargs: {kw}"
assert "max_retries" not in kw, f"max_retries should not be in kwargs: {kw}"
# ---------------------------------------------------------------------------
# SimpleLLMAgent.process - empty system prompt (branch 230)
# ---------------------------------------------------------------------------
@given("a SimpleLLMAgent with an empty system prompt")
def step_given_llm_agent_empty_system_prompt(context: Any) -> None:
context.llm_agent = SimpleLLMAgent(
name="test-llm",
config={"provider": "mock", "model": "mock-model", "system_prompt": ""},
)
@when('I process content "test input" through that SimpleLLMAgent')
def step_when_process_through_llm_agent(context: Any) -> None:
context.llm_result = context.llm_agent.process("test input")
@then("the LLM should receive only a HumanMessage")
def step_then_llm_received_only_human(context: Any) -> None:
from langchain_core.messages import HumanMessage
call_args = context._mock_llm.invoke.call_args
messages = call_args[0][0]
# Should have exactly 1 message (HumanMessage), no SystemMessage
assert len(messages) == 1, f"Expected 1 message, got {len(messages)}: {messages}"
assert isinstance(messages[0], HumanMessage), (
f"Expected HumanMessage, got {type(messages[0])}"
)
# ---------------------------------------------------------------------------
# LangGraph bridge - bridge has factory method (branch 378)
# ---------------------------------------------------------------------------
@given("a LangGraph bridge stub with a graph_execute factory is registered")
def step_given_langgraph_bridge_with_factory(context: Any) -> None:
bridge = MagicMock()
# The router looks for _operator_graph_execute on the bridge
bridge._operator_graph_execute.return_value = lambda x: x
context.router._langgraph_bridge = bridge
context._bridge = bridge
@when("I create a graph_execute operator via the bridge")
def step_when_create_graph_execute_via_bridge(context: Any) -> None:
try:
context.result = context.router._create_operator(
{"type": "graph_execute", "params": {"graph": "test"}}
)
except StreamRoutingError as exc:
context.error = exc
@then("the bridge factory method should produce a valid operator")
def step_then_bridge_factory_produces_operator(context: Any) -> None:
assert context.error is None, f"Unexpected error: {context.error}"
assert context.result is not None, "Expected an operator but got None"
context._bridge._operator_graph_execute.assert_called_once()
# ---------------------------------------------------------------------------
# Switch operator - case_operators with None operator (branch 453)
# ---------------------------------------------------------------------------
@when("I run a switch operator whose case pipeline includes a None operator")
def step_when_switch_case_null_operator(context: Any) -> None:
"""Build a switch operator where _create_operator returns None for a sub-op."""
results: list[Any] = []
# Build a switch config with case operators
switch_config = {
"type": "switch",
"params": {
"cases": [
{
"condition": {}, # Always matches (empty condition = True)
"operators": [
# This is a valid operator that returns identity
{"type": "map", "params": {"transform": {"type": "identity"}}},
],
},
],
},
}
# Patch _create_operator to return None for one of the case operators
original_create = context.router._create_operator
def _patched_create(config: dict[str, Any]) -> Any:
if (
config.get("type") == "map"
and config.get("params", {}).get("transform", {}).get("type") == "identity"
):
return None
return original_create(config)
context.router._create_operator = _patched_create
try:
op = original_create(switch_config)
# Create a message and run through the switch
msg = StreamMessage(content="test", metadata={})
import rx
rx.just(msg).pipe(op).subscribe(lambda x: results.append(x))
finally:
context.router._create_operator = original_create
context.results = results
@then("the switch should still emit the message through the pipeline")
def step_then_switch_emits_through_pipeline(context: Any) -> None:
assert len(context.results) > 0, "Expected at least one message from switch"
# ---------------------------------------------------------------------------
# Switch operator - target_stream missing (branch 456)
# ---------------------------------------------------------------------------
@when("I run a switch with a matching case whose target stream does not exist")
def step_when_switch_missing_target_stream(context: Any) -> None:
results: list[Any] = []
switch_config = {
"type": "switch",
"params": {
"cases": [
{
"condition": {}, # Always matches
"target": "nonexistent_stream",
},
],
"default": None,
},
}
op = context.router._create_operator(switch_config)
msg = StreamMessage(content="test", metadata={})
import rx
rx.just(msg).pipe(op).subscribe(lambda x: results.append(x))
context.results = results
@then("the switch should fall through to the default")
def step_then_switch_falls_through(context: Any) -> None:
# With no default either, the message should still come through via rx.just(msg)
assert len(context.results) > 0, "Expected message to fall through"
# ---------------------------------------------------------------------------
# Switch operator - default_operators with None operator (branch 466)
# ---------------------------------------------------------------------------
@when("I run a switch operator whose default pipeline includes a None operator")
def step_when_switch_default_null_operator(context: Any) -> None:
results: list[Any] = []
switch_config = {
"type": "switch",
"params": {
"cases": [
{
"condition": {"equals": "will_never_match_this_value"},
"target": "nowhere",
},
],
"default_operators": [
{"type": "map", "params": {"transform": {"type": "identity"}}},
],
},
}
original_create = context.router._create_operator
def _patched_create(config: dict[str, Any]) -> Any:
if (
config.get("type") == "map"
and config.get("params", {}).get("transform", {}).get("type") == "identity"
):
return None
return original_create(config)
context.router._create_operator = _patched_create
try:
op = original_create(switch_config)
msg = StreamMessage(content="test", metadata={})
import rx
rx.just(msg).pipe(op).subscribe(lambda x: results.append(x))
finally:
context.router._create_operator = original_create
context.results = results
@then("the switch should still emit the message through the default pipeline")
def step_then_switch_emits_through_default(context: Any) -> None:
assert len(context.results) > 0, (
"Expected at least one message from default pipeline"
)
# ---------------------------------------------------------------------------
# _apply_transform - replace with non-dict message (branch 526)
# ---------------------------------------------------------------------------
@when("I apply a replace transform to a non-dict message")
def step_when_apply_replace_to_non_dict(context: Any) -> None:
context.result = context.router._apply_transform(
"a plain string", {"type": "replace", "target": "key", "value": "val"}
)
@then("the message should be returned unchanged")
def step_then_message_returned_unchanged(context: Any) -> None:
assert context.result == "a plain string", (
f"Expected original, got {context.result}"
)
# ---------------------------------------------------------------------------
# _apply_transform - extract_field (branch 529)
# ---------------------------------------------------------------------------
@when("I apply an extract_field transform to a dict message with the field")
def step_when_apply_extract_field_dict(context: Any) -> None:
context.result = context.router._apply_transform(
{"name": "Alice", "age": 30},
{"type": "extract_field", "field": "name"},
)
@then("the extracted field value should be returned")
def step_then_extracted_field_value(context: Any) -> None:
assert context.result == "Alice", f"Expected 'Alice', got {context.result}"
@when("I apply an extract_field transform to a non-dict message")
def step_when_apply_extract_field_non_dict(context: Any) -> None:
context.result = context.router._apply_transform(
"just a string",
{"type": "extract_field", "field": "name"},
)
@then("the non-dict message should be returned from extract_field")
def step_then_non_dict_returned_extract_field(context: Any) -> None:
assert context.result == "just a string", (
f"Expected 'just a string', got '{context.result}'"
)
# ---------------------------------------------------------------------------
# _evaluate_condition - field condition with dict (branch 542)
# ---------------------------------------------------------------------------
@when("I evaluate a field condition against a dict message containing the field")
def step_when_evaluate_field_condition_dict_has(context: Any) -> None:
context.condition_result = context.router._evaluate_condition(
{"status": "active"}, {"field": "status"}
)
@then("the condition should return true")
def step_then_condition_true(context: Any) -> None:
assert context.condition_result is True, (
f"Expected True, got {context.condition_result}"
)
@when("I evaluate a field condition against a dict message missing the field")
def step_when_evaluate_field_condition_dict_missing(context: Any) -> None:
context.condition_result = context.router._evaluate_condition(
{"other": "value"}, {"field": "status"}
)
@then("the condition should return false")
def step_then_condition_false(context: Any) -> None:
assert context.condition_result is False, (
f"Expected False, got {context.condition_result}"
)
# ---------------------------------------------------------------------------
# split_stream - target missing from streams (branch 609)
# ---------------------------------------------------------------------------
@when("I split the stream with a condition targeting a nonexistent stream")
def step_when_split_missing_target(context: Any) -> None:
results: list[Any] = []
context.router.split_stream(
"split_source",
[{"target": "does_not_exist", "condition": {}}],
)
# Send a message - it shouldn't route anywhere
msg = StreamMessage(content="test", metadata={})
context.router.streams["split_source"].on_next(msg)
context.results = results
@then("no error should occur and the message should not route")
def step_then_no_error_no_route(context: Any) -> None:
# If we got here without an exception, the test passes
assert True
# ---------------------------------------------------------------------------
# merge_streams - target doesn't exist (branch 622)
# ---------------------------------------------------------------------------
@when("I merge streams into a target that does not exist yet")
def step_when_merge_target_not_exist(context: Any) -> None:
results: list[Any] = []
context.router.merge_streams(["merge_src_a", "merge_src_b"], "auto_created_target")
# Subscribe to the auto-created target
context.router.streams["auto_created_target"].subscribe(lambda x: results.append(x))
# Send messages through sources
msg = StreamMessage(content="from_a", metadata={})
context.router.streams["merge_src_a"].on_next(msg)
context.results = results
@then("the target stream should be created and receive merged messages")
def step_then_target_created_receives(context: Any) -> None:
assert "auto_created_target" in context.router.streams, (
"Target stream was not auto-created"
)
assert len(context.results) > 0, "Expected messages in merged target"
# ---------------------------------------------------------------------------
# merge_streams - source doesn't exist (branch 628)
# ---------------------------------------------------------------------------
@when("I merge streams including a nonexistent source into an existing target")
def step_when_merge_nonexistent_source(context: Any) -> None:
# Create a target first
context.router.create_stream(StreamConfig(name="merge_target_c"))
results: list[Any] = []
context.router.streams["merge_target_c"].subscribe(lambda x: results.append(x))
# Merge with one real source and one nonexistent
context.router.merge_streams(
["merge_src_c", "nonexistent_source"], "merge_target_c"
)
# Send a message through the real source
msg = StreamMessage(content="from_c", metadata={})
context.router.streams["merge_src_c"].on_next(msg)
context.results = results
@then("only the existing source should be subscribed")
def step_then_only_existing_subscribed(context: Any) -> None:
# The real source should have routed successfully
assert len(context.results) > 0, "Expected messages from existing source"
# ---------------------------------------------------------------------------
# dispose - stream without dispose attribute (branch 657)
# ---------------------------------------------------------------------------
@given("a stream that lacks a dispose method is injected into the router")
def step_given_stream_without_dispose(context: Any) -> None:
# Inject a plain object without a dispose method into the streams dict
class NoDispose:
pass
context.router.streams["no_dispose_stream"] = NoDispose()
@when("I dispose the router")
def step_when_dispose_router(context: Any) -> None:
try:
context.router.dispose()
context.error = None
except Exception as exc:
context.error = exc
@then("the router should be fully cleared without errors")
def step_then_router_cleared(context: Any) -> None:
assert context.error is None, f"Unexpected error during dispose: {context.error}"
assert len(context.router.streams) == 0, "Streams not cleared"
assert len(context.router.observables) == 0, "Observables not cleared"
assert len(context.router.agents) == 0, "Agents not cleared"
# ---------------------------------------------------------------------------
# _apply_transform - unknown transform type (branch 529->533)
# ---------------------------------------------------------------------------
@when("I apply a transform with unknown type to a message")
def step_when_apply_unknown_transform_type(context: Any) -> None:
context.result = context.router._apply_transform(
{"key": "value"}, {"type": "unknown_type", "field": "key"}
)
@then("the original message should be returned")
def step_then_original_message_returned(context: Any) -> None:
assert context.result == {"key": "value"}, (
f"Expected original dict, got {context.result}"
)
# ---------------------------------------------------------------------------
# _evaluate_condition - field condition with non-dict (branch 542->544)
# ---------------------------------------------------------------------------
@when("I evaluate a field condition against a non-dict message")
def step_when_evaluate_field_non_dict(context: Any) -> None:
# message is a StreamMessage object (not a dict), so isinstance check is False
msg = StreamMessage(content="text", metadata={})
context.condition_result = context.router._evaluate_condition(
msg, {"field": "status"}
)
# ---------------------------------------------------------------------------
# LangGraph bridge - bridge without factory method (branch 378->381)
# ---------------------------------------------------------------------------
@given("a LangGraph bridge stub without the requested factory method is registered")
def step_given_bridge_without_factory(context: Any) -> None:
# Create a bridge object that does NOT have _operator_graph_execute
bridge = MagicMock(spec=[])
context.router._langgraph_bridge = bridge
@when("I attempt to create a graph_execute operator via the bridge")
def step_when_attempt_graph_execute_no_factory(context: Any) -> None:
try:
context.result = context.router._create_operator(
{"type": "graph_execute", "params": {"graph": "test"}}
)
except StreamRoutingError as exc:
context.error = exc
@then("a StreamRoutingError should be raised about LangGraph bridge")
def step_then_routing_error_langgraph(context: Any) -> None:
assert context.error is not None, "Expected StreamRoutingError but none raised"
assert isinstance(context.error, StreamRoutingError)
assert "LangGraph" in str(context.error)