Files
cleveragents-core/features/steps/tdd_session_shared_steps.py
T
freemo 24aad463a1 feat(a2a): A2A facade session and plan lifecycle operations functional via CLI
Wire CLI session and plan lifecycle commands through the A2A local
facade, establishing the A2A protocol data flow:
CLI -> A2aLocalFacade.dispatch() -> Service -> Domain.

Key changes:
- Added cli_bootstrap.py module providing get_facade() which lazily
  constructs a process-wide A2aLocalFacade instance wired to the DI
  container (plan_lifecycle_service, session_service,
  resource_registry_service, tool_registry). Service wiring is
  best-effort via contextlib.suppress.
- Session CLI create command now notifies the A2A facade after session
  creation for protocol bookkeeping and telemetry.
- Plan CLI commands (use, execute, lifecycle-apply) now notify the A2A
  facade via _notify_facade() helper after operations complete. The
  notification is best-effort (exceptions are suppressed) to avoid
  breaking CLI functionality if the facade is not available.
- Added Behave feature (a2a_cli_facade_integration.feature) with 8
  scenarios covering: facade bootstrap wiring, all 11 operations
  supported, session/plan dispatch through facade, and best-effort
  error suppression.

The facade notification pattern preserves backward compatibility:
CLI commands still perform the primary work via direct service calls,
then notify the facade for A2A protocol compliance. This allows
incremental migration toward full facade-first routing.

ISSUES CLOSED: #852
2026-03-22 01:54:33 +00:00

140 lines
5.0 KiB
Python

"""Shared step definitions for TDD session DI bug tests.
Steps in this module are used by both ``tdd_session_create_di.feature`` and
``tdd_session_list_di.feature``. Behave loads all step files globally, so
placing shared steps here avoids duplication and satisfies the CONTRIBUTING.md
rule that feature-specific steps live in matching ``*_steps.py`` files while
shared steps live in clearly named reusable modules.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from pathlib import Path
from behave import given, then
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.application.container import reset_container
from cleveragents.cli.commands import session as session_mod
from cleveragents.config.settings import Settings
def _suppress_structlog_stdout() -> tuple[int, bool]:
"""Prevent structlog debug lines from contaminating CLI stdout.
When ``behave-parallel`` runs multiple features in one process,
another feature may (re)configure structlog with a ``ConsoleRenderer``
or leave it at the default ``PrintLogger`` — both write to
``sys.stdout``. The ``@database_retry`` decorator logs a *debug*
event on every attempt, and ``CliRunner.invoke()`` captures
``sys.stdout``, so those debug lines end up in the captured output,
breaking JSON/YAML assertions.
Returns the previous root-logger level and ``cache_logger_on_first_use``
flag so that the caller's cleanup function can restore them.
"""
import structlog
root = logging.getLogger()
prev_level = root.level
root.setLevel(logging.CRITICAL)
# Force structlog to route through stdlib logging (not PrintLogger)
# and disable logger caching so the level change takes effect for
# loggers that were already instantiated.
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)
@given("a CLI runner using the real session DI path")
def step_real_di_runner(context: Context) -> None:
"""Set up a CLI runner that does NOT mock the session service.
By ensuring ``session_mod._service`` is ``None``, the CLI will call
``_get_session_service()`` which hits the real DI container and
resolves the ``session_service`` provider.
"""
context.runner = CliRunner()
# Ensure we go through the real DI path — no mock service.
session_mod._service = None
# Reset singletons before setup so stale state from a parallel
# scenario does not leak in.
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()
# Provide a database URL so the container can be constructed.
fd, db_path = tempfile.mkstemp(suffix=".db")
os.close(fd)
context._tdd_db_path = db_path
db_url = f"sqlite:///{db_path}"
os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url
# Create the schema so the database file is ready for use.
from sqlalchemy import create_engine
from cleveragents.infrastructure.database.models import Base
engine = create_engine(db_url, echo=False)
try:
Base.metadata.create_all(engine)
finally:
engine.dispose()
def _cleanup() -> None:
session_mod._service = None
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
reset_container()
# Reset the Settings singleton so stale database URLs from this
# scenario do not leak into subsequent scenarios.
Settings._instance = None # type: ignore[attr-defined]
Path(db_path).unlink(missing_ok=True)
_restore_structlog(prev_level, prev_cache)
context.add_cleanup(_cleanup)
@then("the session {subcommand} command should exit successfully")
def step_session_exits_ok(context: Context, subcommand: str) -> None:
"""Assert the command exits with code 0."""
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
@then("the session {subcommand} output should be valid JSON")
def step_session_output_json(context: Context, subcommand: str) -> None:
"""Assert the output is parseable JSON."""
try:
json.loads(context.result.output)
except json.JSONDecodeError as exc:
raise AssertionError(
f"Output is not valid JSON:\n{context.result.output}"
) from exc