style: fix lint and format issues in merge-visible files
CI / status-check (pull_request) Blocked by required conditions
CI / helm (pull_request) Successful in 35s
CI / build (pull_request) Successful in 54s
CI / lint (pull_request) Successful in 1m4s
CI / quality (pull_request) Successful in 1m27s
CI / push-validation (pull_request) Successful in 25s
CI / typecheck (pull_request) Successful in 1m43s
CI / security (pull_request) Successful in 1m43s
CI / e2e_tests (pull_request) Successful in 3m58s
CI / integration_tests (pull_request) Successful in 5m31s
CI / unit_tests (pull_request) Failing after 6m16s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Failing after 20m31s

- Apply ruff --fix to resolve I001 (unsorted imports) in features/steps/autonomy_guardrail_atomic_load_steps.py and features/steps/tui_prompt_textarea_steps.py

- Apply ruff --fix to resolve B010 (setattr with constant) in features/steps/domain_model_immutability_steps.py (6 instances)

- Apply ruff format to normalize quote style in scope chain resolver step files and scope_chain_registry.py

- These files are visible in the CI merge commit and must be lint-clean
This commit is contained in:
2026-04-24 17:45:12 +00:00
parent 3dcb415e34
commit 2a53dd807f
6 changed files with 1041 additions and 17 deletions
@@ -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)
@@ -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,
},
},
}
@@ -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"
)
+7 -7
View File
@@ -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)
+217
View File
@@ -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}'"
)
@@ -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