"""Step definitions for session_list_error.feature (bug #554). TDD regression tests for ``agents session list`` after ``agents init``. These scenarios assert the correct expected behaviour and will fail until the DI container fix is applied. Design rationale ~~~~~~~~~~~~~~~~ ``_get_session_service()`` calls ``container.db()`` but the DI ``Container`` class has no ``db`` provider, raising ``AttributeError``. We reset ``_service`` to ``None`` so the real ``_get_session_service()`` is exercised. A file-based SQLite database and ``CLEVERAGENTS_DATABASE_URL`` override ensure the commands can reach the database once the fix lands. Private API access ~~~~~~~~~~~~~~~~~~ This module accesses ``session_mod._service`` (module-level singleton cache) to force the real ``_get_session_service()`` code path during tests. This is intentional: the public API (``CliRunner.invoke``) does not expose the DI wiring that triggers the bug, so we must bypass the cache to exercise it. """ from __future__ import annotations import json import logging import os import shutil import tempfile from typing import Any import yaml from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from typer.testing import CliRunner from cleveragents.application.container import reset_container from cleveragents.application.services.session_service import ( PersistentSessionService, ) from cleveragents.cli.commands import session as session_mod from cleveragents.cli.commands.session import app as session_app from cleveragents.config.settings import Settings from cleveragents.infrastructure.database.models import Base from cleveragents.infrastructure.database.repositories import ( SessionMessageRepository, SessionRepository, ) runner = CliRunner() _ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"} def _unwrap_envelope(parsed: Any) -> Any: """Return the ``data`` field if *parsed* is a spec envelope, else *parsed* as-is.""" if isinstance(parsed, dict) and _ENVELOPE_KEYS.issubset(parsed.keys()): return parsed["data"] return parsed # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _suppress_structlog_stdout() -> tuple[int, bool]: """Prevent structlog debug lines from contaminating CLI stdout. See ``tdd_session_shared_steps._suppress_structlog_stdout`` for the full rationale. """ import structlog root = logging.getLogger() prev_level = root.level root.setLevel(logging.WARNING) prev_config = structlog.get_config() prev_cache: bool = prev_config.get("cache_logger_on_first_use", True) # type: ignore[assignment] structlog.configure( logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=False, ) return prev_level, prev_cache def _restore_structlog(prev_level: int, prev_cache: bool) -> None: """Undo the suppression applied by :func:`_suppress_structlog_stdout`.""" import structlog logging.getLogger().setLevel(prev_level) structlog.configure(cache_logger_on_first_use=prev_cache) def _setup_real_di_path(context: Context) -> None: """Prepare a temp dir with a fresh SQLite DB and override container.""" # Store original _service so cleanup can restore it. context.sle_original_service = session_mod._service # Reset any stale DI container singleton before configuring the env var # so that cached providers don't carry over from a prior test suite (F17). reset_container() Settings._instance = None # type: ignore[attr-defined] # Suppress structlog debug output so CliRunner captures clean output. prev_level, prev_cache = _suppress_structlog_stdout() context.sle_log_prev = (prev_level, prev_cache) context.sle_tmpdir = tempfile.mkdtemp(prefix="session_list_err_554_") # Register cleanup immediately after mkdtemp so the temp directory is # always removed even if the rest of setup fails (F21). context.add_cleanup(_cleanup_sle, context) context.sle_db_path = os.path.join(context.sle_tmpdir, "test.db") db_url = f"sqlite:///{context.sle_db_path}" # Create schema so the DB file exists with all tables. engine = create_engine(db_url, echo=False) try: Base.metadata.create_all(engine) finally: engine.dispose() # Override the container's database_url so real DI can find the DB. os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url # Reset the module-level _service so _get_session_service() is used. # This direct attribute mutation is fragile — if the module's internal # caching mechanism changes (e.g. lazy singleton via descriptor), this # line will need to be updated. See module docstring for rationale. session_mod._service = None context.sle_result = None def _cleanup_sle(context: Context) -> None: """Remove temp dir, restore env, original _service, and container.""" session_mod._service = context.sle_original_service os.environ.pop("CLEVERAGENTS_DATABASE_URL", None) # Reset the DI container singleton to avoid polluting later scenarios. reset_container() Settings._instance = None # type: ignore[attr-defined] # Restore structlog / logging state. prev_level, prev_cache = context.sle_log_prev _restore_structlog(prev_level, prev_cache) shutil.rmtree(context.sle_tmpdir, ignore_errors=True) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a session-list-error CLI runner using the real DI path") def step_session_list_error_runner(context: Context) -> None: _setup_real_di_path(context) # --------------------------------------------------------------------------- # Given - pre-populated session # --------------------------------------------------------------------------- @given("a session-list-error service with a pre-populated session") def step_pre_populate_session(context: Context) -> None: """Insert a session directly via the repository so list has data.""" db_url = f"sqlite:///{context.sle_db_path}" engine = create_engine(db_url, echo=False) try: factory = sessionmaker(bind=engine, expire_on_commit=False) repo = SessionRepository(session_factory=factory, auto_commit=True) msg_repo = SessionMessageRepository(session_factory=factory, auto_commit=True) svc = PersistentSessionService(repo, msg_repo) svc.create(actor_name="openai/gpt-4") finally: engine.dispose() # --------------------------------------------------------------------------- # When - list # --------------------------------------------------------------------------- @when("I invoke session-list-error list with default format") def step_invoke_list_default(context: Context) -> None: context.sle_result = runner.invoke(session_app, ["list"]) @when('I invoke session-list-error list with format "{fmt}"') def step_invoke_list_format(context: Context, fmt: str) -> None: context.sle_result = runner.invoke(session_app, ["list", "--format", fmt]) # --------------------------------------------------------------------------- # Then - assertions # --------------------------------------------------------------------------- @then("the session-list-error command should exit successfully") def step_exit_success(context: Context) -> None: result = context.sle_result assert result is not None, "No command was invoked" assert result.exit_code == 0, ( f"Expected exit code 0, got {result.exit_code}.\n" f"Output: {result.output}\n" f"Exception: {result.exception!r}" ) @then('the session-list-error output should contain "{text}"') def step_output_contains(context: Context, text: str) -> None: result = context.sle_result assert result is not None, "No command was invoked" assert text in result.output, ( f"Expected '{text}' in output but got:\n{result.output}" ) @then('the session-list-error output should not contain "{text}"') def step_output_not_contains(context: Context, text: str) -> None: result = context.sle_result assert result is not None, "No command was invoked" assert text not in result.output, ( f"Did not expect '{text}' in output but found it:\n{result.output}" ) @then('the session-list-error output should be valid JSON containing "{key}"') def step_output_json_key(context: Context, key: str) -> None: result = context.sle_result assert result is not None, "No command was invoked" try: parsed = json.loads(result.output) except json.JSONDecodeError as exc: raise AssertionError(f"Output is not valid JSON:\n{result.output}") from exc data = _unwrap_envelope(parsed) assert isinstance(data, dict), f"Expected JSON object, got {type(data)}: {data}" assert key in data, f"Key '{key}' not in JSON: {data}" assert isinstance(data[key], list), ( f"Expected '{key}' to be a list, got {type(data[key])}: {data[key]}" ) @then('the session-list-error output should be valid YAML containing "{key}"') def step_output_yaml_key(context: Context, key: str) -> None: result = context.sle_result assert result is not None, "No command was invoked" try: data = yaml.safe_load(result.output) except yaml.YAMLError as exc: raise AssertionError(f"Output is not valid YAML:\n{result.output}") from exc assert isinstance(data, dict), f"Expected YAML dict, got {type(data)}: {data}" assert key in data, f"Key '{key}' not in YAML: {data}" assert isinstance(data[key], list), ( f"Expected '{key}' to be a list, got {type(data[key])}: {data[key]}" )