diff --git a/features/context/steps/scope_chain_resolver_steps.py b/features/context/steps/scope_chain_resolver_steps.py index b76f341aa..67a94385b 100644 --- a/features/context/steps/scope_chain_resolver_steps.py +++ b/features/context/steps/scope_chain_resolver_steps.py @@ -95,13 +95,13 @@ def step_create_multiple_resolvers(context: Any) -> None: context.resolvers.append(resolver) -@given('a base scope context with {context_dict}') +@given("a base scope context with {context_dict}") def step_create_base_context(context: Any, context_dict: str) -> None: """Create a base scope context.""" context.base_context = json.loads(context_dict) -@given('a custom scope resolver that returns {output_dict}') +@given("a custom scope resolver that returns {output_dict}") def step_create_resolver_with_output(context: Any, output_dict: str) -> None: """Create a resolver with specific output.""" output = json.loads(output_dict) @@ -126,7 +126,7 @@ def step_register_resolver(context: Any) -> None: context.registry.register_resolver(context.resolver) -@given('resolver1 returns {output_dict}') +@given("resolver1 returns {output_dict}") def step_set_resolver1_output(context: Any, output_dict: str) -> None: """Set the output for resolver1.""" output = json.loads(output_dict) @@ -136,7 +136,7 @@ def step_set_resolver1_output(context: Any, output_dict: str) -> None: break -@given('resolver2 returns {output_dict} when scope1 is present') +@given("resolver2 returns {output_dict} when scope1 is present") def step_set_resolver2_conditional_output(context: Any, output_dict: str) -> None: """Set resolver2 to return output only when scope1 is present in context.""" output = json.loads(output_dict) @@ -181,7 +181,7 @@ def step_unregister_resolver(context: Any) -> None: context.registry.unregister_resolver(context.resolver.resolver_name) -@when('I try to register another resolver with the same name') +@when("I try to register another resolver with the same name") def step_try_register_duplicate(context: Any) -> None: """Try to register a duplicate resolver.""" duplicate = MockScopeResolver(context.resolver.resolver_name) @@ -234,7 +234,7 @@ def step_check_resolver_order(context: Any) -> None: assert registered == expected -@then('the merged context should contain {expected_dict}') +@then("the merged context should contain {expected_dict}") def step_check_merged_context_contains(context: Any, expected_dict: str) -> None: """Check that merged context contains expected values.""" expected = json.loads(expected_dict) @@ -257,7 +257,7 @@ def step_check_merged_context_key_count(context: Any, count: int) -> None: assert len(context.merged_context) == count -@then('the merged context should have {expected_dict}') +@then("the merged context should have {expected_dict}") def step_check_merged_context_has(context: Any, expected_dict: str) -> None: """Check that merged context has expected key-value pairs.""" expected = json.loads(expected_dict) diff --git a/features/steps/autonomy_guardrail_atomic_load_steps.py b/features/steps/autonomy_guardrail_atomic_load_steps.py new file mode 100644 index 000000000..d1a6ba787 --- /dev/null +++ b/features/steps/autonomy_guardrail_atomic_load_steps.py @@ -0,0 +1,382 @@ +"""Step definitions for atomic load_from_metadata scenarios.""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.application.services.autonomy_guardrail_service import ( + _MAX_CONFIRMATIONS, + _MAX_METADATA_ENTRIES, + AutonomyGuardrailService, +) +from cleveragents.domain.models.core.autonomy_guardrails import ( + AutonomyGuardrails, +) + +# ---- Setup and initialization ---- + + +@given("I have metadata with valid guardrails and audit trail") +def step_setup_valid_metadata(context: Context) -> None: + """Create metadata with valid guardrails and audit trail.""" + context.metadata = { + "autonomy_guardrails": { + "max_steps": 10, + "tool_budget": 100.0, + "budget_spent": 0.0, + "step_count": 0, + "required_confirmations": [], + "actor_limits": { + "max_tool_calls_per_invocation": 5, + "max_retries_per_failure": 3, + }, + }, + "guardrail_audit_trail": { + "entries": [], + }, + } + + +@given("I have metadata with valid guardrails but no audit trail") +def step_setup_guardrails_only(context: Context) -> None: + """Create metadata with only guardrails.""" + context.metadata = { + "autonomy_guardrails": { + "max_steps": 10, + "tool_budget": 100.0, + "budget_spent": 0.0, + "step_count": 0, + "required_confirmations": [], + "actor_limits": { + "max_tool_calls_per_invocation": 5, + "max_retries_per_failure": 3, + }, + }, + } + + +@given("I have metadata with valid audit trail but no guardrails") +def step_setup_audit_trail_only(context: Context) -> None: + """Create metadata with only audit trail.""" + context.metadata = { + "guardrail_audit_trail": { + "entries": [], + }, + } + + +@given("I have empty metadata") +def step_setup_empty_metadata(context: Context) -> None: + """Create empty metadata.""" + context.metadata = {} + + +@given("I have metadata with invalid guardrails and valid audit trail") +def step_setup_invalid_guardrails(context: Context) -> None: + """Create metadata with invalid guardrails.""" + context.metadata = { + "autonomy_guardrails": { + "max_steps": -1, # Invalid: negative max_steps + "tool_budget": 100.0, + }, + "guardrail_audit_trail": { + "entries": [], + }, + } + + +@given("I have metadata with valid guardrails and invalid audit trail") +def step_setup_invalid_audit_trail(context: Context) -> None: + """Create metadata with invalid audit trail.""" + context.metadata = { + "autonomy_guardrails": { + "max_steps": 10, + "tool_budget": 100.0, + "budget_spent": 0.0, + "step_count": 0, + "required_confirmations": [], + "actor_limits": { + "max_tool_calls_per_invocation": 5, + "max_retries_per_failure": 3, + }, + }, + "guardrail_audit_trail": { + "entries": "invalid", # Invalid: should be list + }, + } + + +@given("I have metadata with invalid guardrails and invalid audit trail") +def step_setup_both_invalid(context: Context) -> None: + """Create metadata with both invalid.""" + context.metadata = { + "autonomy_guardrails": { + "max_steps": -1, # Invalid + }, + "guardrail_audit_trail": { + "entries": "invalid", # Invalid + }, + } + + +@given("I have metadata with guardrails containing oversized confirmations") +def step_setup_oversized_confirmations(context: Context) -> None: + """Create metadata with oversized confirmations list.""" + context.metadata = { + "autonomy_guardrails": { + "max_steps": 10, + "tool_budget": 100.0, + "budget_spent": 0.0, + "step_count": 0, + "required_confirmations": ["op"] * (_MAX_CONFIRMATIONS + 1), + "actor_limits": { + "max_tool_calls_per_invocation": 5, + "max_retries_per_failure": 3, + }, + }, + } + + +@given("valid audit trail") +def step_add_valid_audit_trail(context: Context) -> None: + """Add valid audit trail to metadata.""" + context.metadata["guardrail_audit_trail"] = { + "entries": [], + } + + +@given("audit trail containing oversized entries") +def step_setup_oversized_entries(context: Context) -> None: + """Create metadata with oversized audit trail entries.""" + context.metadata["guardrail_audit_trail"] = { + "entries": [ + { + "timestamp": "2026-04-13T00:00:00Z", + "event_type": "step_allowed", + "guard_name": "step_limit", + "result": "allowed", + "reason": "Within limits", + "context": {}, + } + ] + * (_MAX_METADATA_ENTRIES + 1), + } + + +@given('plan "{plan_id}" has no prior state') +def step_ensure_no_prior_state(context: Context, plan_id: str) -> None: + """Ensure plan has no prior state.""" + if not hasattr(context, "service"): + context.service = AutonomyGuardrailService() + # Ensure plan is not in service + context.service.remove_plan(plan_id) + + +@given('plan "{plan_id}" has existing guardrails and audit trail') +def step_setup_existing_state(context: Context, plan_id: str) -> None: + """Set up existing guardrails and audit trail for a plan.""" + if not hasattr(context, "service"): + context.service = AutonomyGuardrailService() + + # Configure initial state + initial_guardrails = AutonomyGuardrails(max_steps=5, tool_budget=50.0) + context.service.configure_guardrails(plan_id, initial_guardrails) + + # Store original values for later comparison + context.original_guardrails = context.service.get_guardrails(plan_id) + context.original_audit_trail = context.service.get_audit_trail(plan_id) + + +@given("I have metadata with different valid guardrails and audit trail") +def step_setup_different_metadata(context: Context) -> None: + """Create metadata with different values.""" + context.metadata = { + "autonomy_guardrails": { + "max_steps": 20, # Different from original 5 + "tool_budget": 200.0, # Different from original 50.0 + "budget_spent": 0.0, + "step_count": 0, + "required_confirmations": [], + "actor_limits": { + "max_tool_calls_per_invocation": 5, + "max_retries_per_failure": 3, + }, + }, + "guardrail_audit_trail": { + "entries": [], + }, + } + + +# ---- Loading and validation ---- + + +@when('I load the metadata for plan "{plan_id}"') +def step_load_metadata(context: Context, plan_id: str) -> None: + """Load metadata into the service.""" + if not hasattr(context, "service"): + context.service = AutonomyGuardrailService() + + context.plan_id = plan_id + context.load_error = None + try: + context.service.load_from_metadata(plan_id, context.metadata) + except Exception as exc: + context.load_error = exc + + +@when('I try to load the metadata for plan "{plan_id}"') +def step_try_load_metadata(context: Context, plan_id: str) -> None: + """Try to load metadata and capture any error.""" + if not hasattr(context, "service"): + context.service = AutonomyGuardrailService() + + context.plan_id = plan_id + context.load_error = None + context.error = None + try: + context.service.load_from_metadata(plan_id, context.metadata) + except Exception as exc: + context.load_error = exc + context.error = exc + + +# ---- Assertions: successful loads ---- + + +@then('the guardrails should be loaded for plan "{plan_id}"') +def step_assert_guardrails_loaded(context: Context, plan_id: str) -> None: + """Assert that guardrails were loaded.""" + guardrails = context.service.get_guardrails(plan_id) + assert guardrails is not None, f"Guardrails not loaded for plan {plan_id}" + assert guardrails.max_steps == 10 + assert guardrails.tool_budget == 100.0 + + +@then('the audit trail should be loaded for plan "{plan_id}"') +def step_assert_audit_trail_loaded(context: Context, plan_id: str) -> None: + """Assert that audit trail was loaded.""" + trail = context.service.get_audit_trail(plan_id) + assert trail is not None + assert len(trail.entries) == 0 + + +@then("both guardrails and audit trail should be in sync") +def step_assert_in_sync(context: Context) -> None: + """Assert that guardrails and audit trail are in sync (both present or both absent).""" + # Both should be present after a successful load + guardrails = context.service.get_guardrails(context.plan_id) + trail = context.service.get_audit_trail(context.plan_id) + assert guardrails is not None, "Guardrails should be present after successful load" + assert trail is not None, "Audit trail should be present after successful load" + + +@then('the audit trail should be empty for plan "{plan_id}"') +def step_assert_audit_trail_empty(context: Context, plan_id: str) -> None: + """Assert that audit trail is empty.""" + trail = context.service.get_audit_trail(plan_id) + assert len(trail.entries) == 0 + + +@then('the guardrails should be absent for plan "{plan_id}"') +def step_assert_guardrails_absent(context: Context, plan_id: str) -> None: + """Assert that guardrails are not loaded.""" + guardrails = context.service.get_guardrails(plan_id) + assert guardrails is None, f"Guardrails should be absent for plan {plan_id}" + + +# ---- Assertions: failed loads (atomicity) ---- + + +@then("a validation error should be raised for metadata load") +def step_assert_validation_error(context: Context) -> None: + """Assert that a validation error was raised.""" + assert context.load_error is not None + assert isinstance(context.load_error, ValidationError) + + +@then('a ValueError should be raised for metadata mentioning "{text}"') +def step_assert_value_error(context: Context, text: str) -> None: + """Assert that a ValueError was raised with specific text.""" + assert context.load_error is not None + assert isinstance(context.load_error, ValueError) + assert text in str(context.load_error) + + +@then('the guardrails should remain absent for plan "{plan_id}"') +def step_assert_guardrails_still_absent(context: Context, plan_id: str) -> None: + """Assert that guardrails remain absent after failed load.""" + guardrails = context.service.get_guardrails(plan_id) + assert guardrails is None + + +@then('the audit trail should remain absent for plan "{plan_id}"') +def step_assert_audit_trail_still_absent(context: Context, plan_id: str) -> None: + """Assert that audit trail remains absent after failed load.""" + trail = context.service.get_audit_trail(plan_id) + assert len(trail.entries) == 0 + + +# ---- Assertions: overwriting state ---- + + +@then('the guardrails should be updated to new values for plan "{plan_id}"') +def step_assert_guardrails_updated(context: Context, plan_id: str) -> None: + """Assert that guardrails were updated to new values.""" + guardrails = context.service.get_guardrails(plan_id) + assert guardrails is not None + assert guardrails.max_steps == 20 # New value + assert guardrails.tool_budget == 200.0 # New value + + +@then('the audit trail should be updated to new values for plan "{plan_id}"') +def step_assert_audit_trail_updated(context: Context, plan_id: str) -> None: + """Assert that audit trail was updated.""" + trail = context.service.get_audit_trail(plan_id) + assert trail is not None + + +@then("both should be in sync") +def step_assert_both_in_sync(context: Context) -> None: + """Assert that both guardrails and audit trail are in sync after update.""" + # Both should be present and updated + guardrails = context.service.get_guardrails(context.plan_id) + trail = context.service.get_audit_trail(context.plan_id) + assert guardrails is not None, "Guardrails should be present after update" + assert trail is not None, "Audit trail should be present after update" + + +@then('the guardrails should retain original values for plan "{plan_id}"') +def step_assert_guardrails_unchanged(context: Context, plan_id: str) -> None: + """Assert that guardrails retain original values.""" + guardrails = context.service.get_guardrails(plan_id) + assert guardrails is not None + assert guardrails.max_steps == context.original_guardrails.max_steps + assert guardrails.tool_budget == context.original_guardrails.tool_budget + + +@then('the audit trail should retain original values for plan "{plan_id}"') +def step_assert_audit_trail_unchanged(context: Context, plan_id: str) -> None: + """Assert that audit trail retains original values.""" + trail = context.service.get_audit_trail(plan_id) + assert len(trail.entries) == len(context.original_audit_trail.entries) + + +@given("I have metadata with valid guardrails") +def step_setup_valid_guardrails_only(context: Context) -> None: + """Create metadata with only valid guardrails (no audit trail).""" + context.metadata = { + "autonomy_guardrails": { + "max_steps": 10, + "tool_budget": 100.0, + "budget_spent": 0.0, + "step_count": 0, + "required_confirmations": [], + "actor_limits": { + "max_tool_calls_per_invocation": 5, + "max_retries_per_failure": 3, + }, + }, + } diff --git a/features/steps/domain_model_immutability_steps.py b/features/steps/domain_model_immutability_steps.py new file mode 100644 index 000000000..7f8ba55a5 --- /dev/null +++ b/features/steps/domain_model_immutability_steps.py @@ -0,0 +1,427 @@ +"""Step definitions for domain model immutability tests. + +Verifies that Plan and Action identity fields are read-only after construction, +while mutable state fields remain assignable. + +Issue #7553: enforce immutability on Plan and Action identity fields. +""" + +from __future__ import annotations + +import datetime as dt +from typing import Any + +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.domain.models.core.action import Action, ActionState +from cleveragents.domain.models.core.plan import ( + NamespacedName, + Plan, + PlanIdentity, + PlanPhase, + PlanTimestamps, + ProcessingState, +) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_VALID_ULID = "01HZTEST0000000000000000AA" +_VALID_ULID_2 = "01HZTEST0000000000000000BB" +_VALID_ULID_ROOT = "01HZTEST0000000000000000CC" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_plan( + plan_id: str = _VALID_ULID, + phase: PlanPhase = PlanPhase.STRATEGIZE, + processing_state: ProcessingState = ProcessingState.QUEUED, + created_at: dt.datetime | None = None, + namespaced_name_str: str = "local/test-plan", +) -> Plan: + """Create a minimal valid Plan domain object.""" + timestamps_kwargs: dict[str, Any] = {} + if created_at is not None: + timestamps_kwargs["created_at"] = created_at + + return Plan( + identity=PlanIdentity(plan_id=plan_id), + namespaced_name=NamespacedName.parse(namespaced_name_str), + description="Test plan description", + action_name="local/test-action", + phase=phase, + processing_state=processing_state, + timestamps=PlanTimestamps(**timestamps_kwargs), + ) + + +def _make_action(namespaced_name_str: str = "local/test-action") -> Action: + """Create a minimal valid Action domain object.""" + return Action( + namespaced_name=NamespacedName.parse(namespaced_name_str), + description="Test action description", + definition_of_done="All tests pass", + strategy_actor="local/strategy-actor", + execution_actor="local/execution-actor", + ) + + +# --------------------------------------------------------------------------- +# Plan identity — plan_id +# --------------------------------------------------------------------------- + + +@given("I create a Plan with a known ULID plan_id") +def step_create_plan_with_known_ulid(context: Context) -> None: + """Create a Plan with a known ULID plan_id.""" + context.known_ulid = _VALID_ULID + context.immut_plan = _make_plan(plan_id=_VALID_ULID) + context.immut_error = None + + +@then("the plan identity plan_id should match the known ULID") +def step_check_plan_identity_plan_id(context: Context) -> None: + """Verify the plan_id matches the known ULID.""" + assert context.immut_plan.identity.plan_id == context.known_ulid, ( + f"Expected plan_id '{context.known_ulid}', " + f"got '{context.immut_plan.identity.plan_id}'" + ) + + +@when("I attempt to reassign the plan identity plan_id") +def step_attempt_reassign_plan_id(context: Context) -> None: + """Attempt to reassign plan_id on a frozen PlanIdentity.""" + context.immut_error = None + try: + context.immut_plan.identity.plan_id = _VALID_ULID_2 + except (ValidationError, TypeError) as exc: + context.immut_error = exc + + +@then("a frozen model error should be raised for plan_id") +def step_check_frozen_error_plan_id(context: Context) -> None: + """Verify that a frozen model error was raised.""" + assert context.immut_error is not None, ( + "Expected a ValidationError or TypeError when reassigning plan_id, " + "but no error was raised" + ) + + +# --------------------------------------------------------------------------- +# Plan identity — root_plan_id auto-resolution +# --------------------------------------------------------------------------- + + +@given("I create a Plan without specifying root_plan_id") +def step_create_plan_without_root_plan_id(context: Context) -> None: + """Create a Plan without explicitly setting root_plan_id.""" + context.immut_plan = _make_plan(plan_id=_VALID_ULID) + context.immut_error = None + + +@then("the plan identity root_plan_id should equal the plan_id") +def step_check_root_plan_id_auto_resolved(context: Context) -> None: + """Verify root_plan_id was auto-resolved to plan_id.""" + assert ( + context.immut_plan.identity.root_plan_id == context.immut_plan.identity.plan_id + ), ( + f"Expected root_plan_id '{context.immut_plan.identity.plan_id}', " + f"got '{context.immut_plan.identity.root_plan_id}'" + ) + + +@when("I attempt to reassign the plan identity root_plan_id") +def step_attempt_reassign_root_plan_id(context: Context) -> None: + """Attempt to reassign root_plan_id on a frozen PlanIdentity.""" + context.immut_error = None + try: + context.immut_plan.identity.root_plan_id = _VALID_ULID_ROOT + except (ValidationError, TypeError) as exc: + context.immut_error = exc + + +@then("a frozen model error should be raised for root_plan_id") +def step_check_frozen_error_root_plan_id(context: Context) -> None: + """Verify that a frozen model error was raised for root_plan_id.""" + assert context.immut_error is not None, ( + "Expected a ValidationError or TypeError when reassigning root_plan_id, " + "but no error was raised" + ) + + +# --------------------------------------------------------------------------- +# Plan timestamps — created_at +# --------------------------------------------------------------------------- + + +@given("I create a Plan with a specific created_at timestamp") +def step_create_plan_with_specific_created_at(context: Context) -> None: + """Create a Plan with a specific created_at timestamp.""" + context.specific_created_at = dt.datetime(2026, 1, 15, 10, 0, 0, tzinfo=dt.UTC) + context.immut_plan = _make_plan(created_at=context.specific_created_at) + context.immut_error = None + + +@then("the plan timestamps created_at should match the specified timestamp") +def step_check_plan_created_at(context: Context) -> None: + """Verify the created_at timestamp matches the specified value.""" + actual = context.immut_plan.timestamps.created_at + expected = context.specific_created_at + assert actual == expected, f"Expected created_at '{expected}', got '{actual}'" + + +@when("I attempt to reassign the plan timestamps created_at") +def step_attempt_reassign_created_at(context: Context) -> None: + """Attempt to reassign created_at on PlanTimestamps.""" + context.immut_error = None + try: + context.immut_plan.timestamps.created_at = dt.datetime( + 2099, 1, 1, tzinfo=dt.UTC + ) + except AttributeError as exc: + context.immut_error = exc + + +@then("an AttributeError should be raised for created_at") +def step_check_attribute_error_created_at(context: Context) -> None: + """Verify that an AttributeError was raised for created_at.""" + assert context.immut_error is not None, ( + "Expected an AttributeError when reassigning created_at, " + "but no error was raised" + ) + assert isinstance(context.immut_error, AttributeError), ( + f"Expected AttributeError, got {type(context.immut_error).__name__}" + ) + assert ( + "created_at" in str(context.immut_error).lower() + or "read-only" in str(context.immut_error).lower() + ), ( + f"Expected error message to mention 'created_at' or 'read-only', " + f"got: {context.immut_error}" + ) + + +@when("I update the plan timestamps updated_at to a new datetime") +def step_update_plan_updated_at(context: Context) -> None: + """Update the plan's updated_at timestamp.""" + context.new_updated_at = dt.datetime(2026, 6, 1, 12, 0, 0, tzinfo=dt.UTC) + context.immut_plan.timestamps.updated_at = context.new_updated_at + context.immut_error = None + + +@then("the plan timestamps updated_at should reflect the new datetime") +def step_check_plan_updated_at(context: Context) -> None: + """Verify the updated_at timestamp was updated.""" + actual = context.immut_plan.timestamps.updated_at + expected = context.new_updated_at + assert actual == expected, f"Expected updated_at '{expected}', got '{actual}'" + + +@when("I set the plan timestamps strategize_started_at to a new datetime") +def step_set_plan_strategize_started_at(context: Context) -> None: + """Set the plan's strategize_started_at timestamp.""" + context.new_strategize_started_at = dt.datetime(2026, 6, 1, 13, 0, 0, tzinfo=dt.UTC) + context.immut_plan.timestamps.strategize_started_at = ( + context.new_strategize_started_at + ) + context.immut_error = None + + +@then("the plan timestamps strategize_started_at should reflect the new datetime") +def step_check_plan_strategize_started_at(context: Context) -> None: + """Verify the strategize_started_at timestamp was set.""" + actual = context.immut_plan.timestamps.strategize_started_at + expected = context.new_strategize_started_at + assert actual == expected, ( + f"Expected strategize_started_at '{expected}', got '{actual}'" + ) + + +# --------------------------------------------------------------------------- +# Action namespaced_name — name +# --------------------------------------------------------------------------- + + +@given('I create an Action with namespaced name "{namespaced_name}"') +def step_create_action_with_namespaced_name( + context: Context, namespaced_name: str +) -> None: + """Create an Action with the given namespaced name.""" + context.immut_action = _make_action(namespaced_name_str=namespaced_name) + context.immut_error = None + + +@then('the action namespaced_name name should be "{expected}"') +def step_check_action_name(context: Context, expected: str) -> None: + """Verify the action's namespaced_name.name.""" + actual = context.immut_action.namespaced_name.name + assert actual == expected, f"Expected action name '{expected}', got '{actual}'" + + +@when("I attempt to reassign the action namespaced_name name") +def step_attempt_reassign_action_name(context: Context) -> None: + """Attempt to reassign the action's namespaced_name.name.""" + context.immut_error = None + try: + context.immut_action.namespaced_name.name = "new-name" + except (ValidationError, TypeError) as exc: + context.immut_error = exc + + +@then("a frozen model error should be raised for action name") +def step_check_frozen_error_action_name(context: Context) -> None: + """Verify that a frozen model error was raised for action name.""" + assert context.immut_error is not None, ( + "Expected a ValidationError or TypeError when reassigning action name, " + "but no error was raised" + ) + + +# --------------------------------------------------------------------------- +# Action namespaced_name — namespace +# --------------------------------------------------------------------------- + + +@then('the action namespaced_name namespace should be "{expected}"') +def step_check_action_namespace(context: Context, expected: str) -> None: + """Verify the action's namespaced_name.namespace.""" + actual = context.immut_action.namespaced_name.namespace + assert actual == expected, f"Expected action namespace '{expected}', got '{actual}'" + + +@when("I attempt to reassign the action namespaced_name namespace") +def step_attempt_reassign_action_namespace(context: Context) -> None: + """Attempt to reassign the action's namespaced_name.namespace.""" + context.immut_error = None + try: + context.immut_action.namespaced_name.namespace = "neworg" + except (ValidationError, TypeError) as exc: + context.immut_error = exc + + +@then("a frozen model error should be raised for action namespace") +def step_check_frozen_error_action_namespace(context: Context) -> None: + """Verify that a frozen model error was raised for action namespace.""" + assert context.immut_error is not None, ( + "Expected a ValidationError or TypeError when reassigning action namespace, " + "but no error was raised" + ) + + +# --------------------------------------------------------------------------- +# Mutable state fields +# --------------------------------------------------------------------------- + + +@given("I create a Plan in STRATEGIZE phase") +def step_create_plan_in_strategize(context: Context) -> None: + """Create a Plan in STRATEGIZE phase.""" + context.immut_plan = _make_plan( + phase=PlanPhase.STRATEGIZE, + processing_state=ProcessingState.QUEUED, + ) + context.immut_error = None + + +@when("I update the plan phase to EXECUTE") +def step_update_plan_phase_to_execute(context: Context) -> None: + """Update the plan's phase to EXECUTE.""" + context.immut_plan.phase = PlanPhase.EXECUTE + context.immut_error = None + + +@then("the plan phase should be EXECUTE") +def step_check_plan_phase_execute(context: Context) -> None: + """Verify the plan phase is EXECUTE.""" + assert context.immut_plan.phase == PlanPhase.EXECUTE, ( + f"Expected phase EXECUTE, got {context.immut_plan.phase}" + ) + + +@when("I update the plan processing_state to PROCESSING") +def step_update_plan_processing_state(context: Context) -> None: + """Update the plan's processing_state to PROCESSING.""" + context.immut_plan.processing_state = ProcessingState.PROCESSING + context.immut_error = None + + +@then("the plan processing_state should be PROCESSING") +def step_check_plan_processing_state(context: Context) -> None: + """Verify the plan processing_state is PROCESSING.""" + assert context.immut_plan.processing_state == ProcessingState.PROCESSING, ( + f"Expected processing_state PROCESSING, got {context.immut_plan.processing_state}" + ) + + +@when("I update the action state to archived") +def step_update_action_state_archived(context: Context) -> None: + """Update the action's state to archived.""" + context.immut_action.state = ActionState.ARCHIVED + context.immut_error = None + + +@then("the action state should be archived") +def step_check_action_state_archived(context: Context) -> None: + """Verify the action state is archived.""" + assert context.immut_action.state == ActionState.ARCHIVED, ( + f"Expected state ARCHIVED, got {context.immut_action.state}" + ) + + +# --------------------------------------------------------------------------- +# Plan namespaced_name — frozen +# --------------------------------------------------------------------------- + + +@given('I create a Plan with namespaced name "{namespaced_name}"') +def step_create_plan_with_namespaced_name( + context: Context, namespaced_name: str +) -> None: + """Create a Plan with the given namespaced name.""" + context.immut_plan = _make_plan(namespaced_name_str=namespaced_name) + context.immut_error = None + + +@when("I attempt to reassign the plan namespaced_name name") +def step_attempt_reassign_plan_namespaced_name(context: Context) -> None: + """Attempt to reassign the plan's namespaced_name.name.""" + context.immut_error = None + try: + context.immut_plan.namespaced_name.name = "new-name" + except (ValidationError, TypeError) as exc: + context.immut_error = exc + + +@then("a frozen model error should be raised for plan namespaced name") +def step_check_frozen_error_plan_namespaced_name(context: Context) -> None: + """Verify that a frozen model error was raised for plan namespaced name.""" + assert context.immut_error is not None, ( + "Expected a ValidationError or TypeError when reassigning plan namespaced name, " + "but no error was raised" + ) + + +@when("I attempt to reassign the plan namespaced_name namespace") +def step_attempt_reassign_plan_namespaced_namespace(context: Context) -> None: + """Attempt to reassign the plan's namespaced_name.namespace.""" + context.immut_error = None + try: + context.immut_plan.namespaced_name.namespace = "neworg" + except (ValidationError, TypeError) as exc: + context.immut_error = exc + + +@then("a frozen model error should be raised for plan namespaced namespace") +def step_check_frozen_error_plan_namespaced_namespace(context: Context) -> None: + """Verify that a frozen model error was raised for plan namespaced namespace.""" + assert context.immut_error is not None, ( + "Expected a ValidationError or TypeError when reassigning plan namespaced namespace, " + "but no error was raised" + ) diff --git a/features/steps/scope_chain_resolver_steps.py b/features/steps/scope_chain_resolver_steps.py index b76f341aa..67a94385b 100644 --- a/features/steps/scope_chain_resolver_steps.py +++ b/features/steps/scope_chain_resolver_steps.py @@ -95,13 +95,13 @@ def step_create_multiple_resolvers(context: Any) -> None: context.resolvers.append(resolver) -@given('a base scope context with {context_dict}') +@given("a base scope context with {context_dict}") def step_create_base_context(context: Any, context_dict: str) -> None: """Create a base scope context.""" context.base_context = json.loads(context_dict) -@given('a custom scope resolver that returns {output_dict}') +@given("a custom scope resolver that returns {output_dict}") def step_create_resolver_with_output(context: Any, output_dict: str) -> None: """Create a resolver with specific output.""" output = json.loads(output_dict) @@ -126,7 +126,7 @@ def step_register_resolver(context: Any) -> None: context.registry.register_resolver(context.resolver) -@given('resolver1 returns {output_dict}') +@given("resolver1 returns {output_dict}") def step_set_resolver1_output(context: Any, output_dict: str) -> None: """Set the output for resolver1.""" output = json.loads(output_dict) @@ -136,7 +136,7 @@ def step_set_resolver1_output(context: Any, output_dict: str) -> None: break -@given('resolver2 returns {output_dict} when scope1 is present') +@given("resolver2 returns {output_dict} when scope1 is present") def step_set_resolver2_conditional_output(context: Any, output_dict: str) -> None: """Set resolver2 to return output only when scope1 is present in context.""" output = json.loads(output_dict) @@ -181,7 +181,7 @@ def step_unregister_resolver(context: Any) -> None: context.registry.unregister_resolver(context.resolver.resolver_name) -@when('I try to register another resolver with the same name') +@when("I try to register another resolver with the same name") def step_try_register_duplicate(context: Any) -> None: """Try to register a duplicate resolver.""" duplicate = MockScopeResolver(context.resolver.resolver_name) @@ -234,7 +234,7 @@ def step_check_resolver_order(context: Any) -> None: assert registered == expected -@then('the merged context should contain {expected_dict}') +@then("the merged context should contain {expected_dict}") def step_check_merged_context_contains(context: Any, expected_dict: str) -> None: """Check that merged context contains expected values.""" expected = json.loads(expected_dict) @@ -257,7 +257,7 @@ def step_check_merged_context_key_count(context: Any, count: int) -> None: assert len(context.merged_context) == count -@then('the merged context should have {expected_dict}') +@then("the merged context should have {expected_dict}") def step_check_merged_context_has(context: Any, expected_dict: str) -> None: """Check that merged context has expected key-value pairs.""" expected = json.loads(expected_dict) diff --git a/features/steps/tui_prompt_textarea_steps.py b/features/steps/tui_prompt_textarea_steps.py new file mode 100644 index 000000000..e7da1a296 --- /dev/null +++ b/features/steps/tui_prompt_textarea_steps.py @@ -0,0 +1,217 @@ +"""Step definitions for tui_prompt_textarea.feature. + +Tests that PromptInput uses TextArea (multi-line) instead of Input (single-line). +""" + +from __future__ import annotations + +import importlib +import sys +from types import ModuleType +from typing import Any + +from behave import given, then, when + +_MOCK_TEXTUAL_KEYS = [ + "textual", + "textual.app", + "textual.containers", + "textual.widgets", +] + + +def _build_mock_textual_with_textarea(): + """Build mock textual modules that expose TextArea.""" + mock_textual = ModuleType("textual") + mock_textual_app = ModuleType("textual.app") + mock_textual_containers = ModuleType("textual.containers") + mock_textual_widgets = ModuleType("textual.widgets") + + class MockTextArea: + """Minimal TextArea stand-in for the Textual base class.""" + + text: str = "" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.text = "" + + mock_textual_app.App = object + mock_textual_containers.Vertical = object + mock_textual_widgets.Header = object + mock_textual_widgets.Footer = object + mock_textual_widgets.Static = object + mock_textual_widgets.TextArea = MockTextArea + + return { + "textual": mock_textual, + "textual.app": mock_textual_app, + "textual.containers": mock_textual_containers, + "textual.widgets": mock_textual_widgets, + }, MockTextArea + + +_PROMPT_MOD_NAME = "cleveragents.tui.widgets.prompt" + + +def _get_prompt_mod() -> Any: + """Return the canonical prompt module from sys.modules. + + Uses ``importlib.import_module`` (which always returns + ``sys.modules[name]``) instead of ``import cleveragents.tui.widgets.prompt + as mod`` (which walks parent-package attributes and can return a stale + module object when a prior feature deleted and re-created the + ``cleveragents.tui.*`` namespace). The stale object causes + ``importlib.reload()`` to fail with + ``ImportError: module ... not in sys.modules`` because Python 3.13's + reload checks ``sys.modules.get(name) is module``. + """ + return importlib.import_module(_PROMPT_MOD_NAME) + + +def _install_mock_textual(context: Any) -> None: + """Inject mock textual into sys.modules and reload the prompt module.""" + mocks, mock_textarea_cls = _build_mock_textual_with_textarea() + context._prompt_saved_modules = {} + for key in _MOCK_TEXTUAL_KEYS: + context._prompt_saved_modules[key] = sys.modules.pop(key, None) + for key, mod in mocks.items(): + sys.modules[key] = mod + + prompt_mod = _get_prompt_mod() + importlib.reload(prompt_mod) + context._prompt_mod = prompt_mod + context._mock_textarea_cls = mock_textarea_cls + + +def _restore_modules(context: Any) -> None: + """Restore original sys.modules and reload the prompt module.""" + for key, val in getattr(context, "_prompt_saved_modules", {}).items(): + if val is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = val + + importlib.reload(_get_prompt_mod()) + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("the prompt module is loaded with a mocked TextArea") +def step_load_prompt_with_mock_textarea(context): + """Install mock Textual with TextArea, reload prompt module.""" + _install_mock_textual(context) + context.add_cleanup(lambda: _restore_modules(context)) + + +@given("the prompt module is loaded without textual") +def step_load_prompt_without_textual(context: Any) -> None: + """Remove textual from sys.modules so the fallback path is used.""" + context._prompt_saved_modules_fallback = {} + for key in _MOCK_TEXTUAL_KEYS: + context._prompt_saved_modules_fallback[key] = sys.modules.pop(key, None) + + prompt_mod = _get_prompt_mod() + importlib.reload(prompt_mod) + context._prompt_mod_fallback = prompt_mod + + def restore() -> None: + for key, val in context._prompt_saved_modules_fallback.items(): + if val is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = val + importlib.reload(_get_prompt_mod()) + + context.add_cleanup(restore) + + +# --------------------------------------------------------------------------- +# Scenario: PromptInput base class is TextArea not Input +# --------------------------------------------------------------------------- + + +@then("the PromptInput base class should be the mocked TextArea") +def step_base_class_is_textarea(context): + PromptInput = context._prompt_mod.PromptInput + assert issubclass(PromptInput, context._mock_textarea_cls), ( + f"Expected PromptInput to subclass MockTextArea, " + f"but got bases: {PromptInput.__bases__}" + ) + + +# --------------------------------------------------------------------------- +# Scenario: PromptInput exposes a text property not value +# --------------------------------------------------------------------------- + + +@when("I create a PromptInput instance") +def step_create_prompt_input(context): + context._prompt_instance = context._prompt_mod.PromptInput() + + +@then("the PromptInput instance should have a text attribute") +def step_has_text_attribute(context): + assert hasattr(context._prompt_instance, "text"), ( + "PromptInput instance should have a 'text' attribute" + ) + + +# --------------------------------------------------------------------------- +# Scenario: consume_text returns the current text content +# --------------------------------------------------------------------------- + + +@when('I set the PromptInput text to "{text}"') +def step_set_prompt_input_text(context, text): + context._prompt_instance.text = text + + +@when("I call consume_text on the PromptInput") +def step_call_consume_text(context): + context._prompt_submitted = context._prompt_instance.consume_text() + + +@then('the PromptSubmitted text should be "{expected}"') +def step_prompt_submitted_text(context, expected): + assert context._prompt_submitted.text == expected, ( + f"Expected '{expected}', got '{context._prompt_submitted.text}'" + ) + + +# --------------------------------------------------------------------------- +# Scenario: consume_text clears the text after consuming +# --------------------------------------------------------------------------- + + +@then("the PromptInput text should be empty") +def step_prompt_input_text_empty(context): + assert context._prompt_instance.text == "", ( + f"Expected empty text, got '{context._prompt_instance.text}'" + ) + + +# --------------------------------------------------------------------------- +# Scenario: PromptInput fallback uses text attribute when TextArea unavailable +# --------------------------------------------------------------------------- + + +@when("I create a PromptInput instance from the fallback") +def step_create_fallback_prompt_input(context): + context._fallback_prompt_instance = context._prompt_mod_fallback.PromptInput() + + +@then("the fallback PromptInput instance should have a text attribute") +def step_fallback_has_text_attribute(context): + assert hasattr(context._fallback_prompt_instance, "text"), ( + "Fallback PromptInput instance should have a 'text' attribute" + ) + + +@then("the fallback PromptInput text should be empty string") +def step_fallback_text_empty(context): + assert context._fallback_prompt_instance.text == "", ( + f"Expected empty string, got '{context._fallback_prompt_instance.text}'" + ) diff --git a/src/cleveragents/application/services/scope_chain_registry.py b/src/cleveragents/application/services/scope_chain_registry.py index e0cfa3847..019de5926 100644 --- a/src/cleveragents/application/services/scope_chain_registry.py +++ b/src/cleveragents/application/services/scope_chain_registry.py @@ -137,9 +137,7 @@ class ScopeChainRegistry: with self._lock: return list(self._resolvers.keys()) - def resolve_all( - self, scope_context: Mapping[str, Any] - ) -> Mapping[str, Any]: + def resolve_all(self, scope_context: Mapping[str, Any]) -> Mapping[str, Any]: """Invoke all registered resolvers and merge their output. Resolvers are invoked in registration order. Each resolver receives