Files
placeholder/features/steps/session_list_error_steps.py
freemo a0df5a4cd0 fix(cli): wrap format_output() in spec-required JSON/YAML envelope across all CLI commands
Implements the spec-required JSON/YAML output envelope for all CLI commands
that use format_output(). The envelope structure is:

  {
    "command": "<command that was run>",
    "status": "ok" | "warn" | "error",
    "exit_code": 0,
    "data": { ... command-specific payload ... },
    "timing": { "duration_ms": 123 },
    "messages": [{ "level": "ok", "text": "..." }]
  }

Changes:
- Add _build_envelope() helper to construct the spec-required envelope
- Add optional command, status, exit_code, messages parameters to format_output()
- Wrap json/yaml output in the envelope; plain/table/rich/color unchanged
- Add timing measurement (duration_ms) to all json/yaml outputs
- Add new BDD feature file (cli_json_envelope.feature) with 14 scenarios
  testing envelope field presence, values, and data payload
- Update 14 existing step files to unwrap the envelope when checking
  specific data keys (backward-compatible via _unwrap_envelope() helper)

Closes #3431
2026-04-05 19:48:40 +00:00

267 lines
9.8 KiB
Python

"""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]}"
)