Files
cleveragents-core/features/steps/m6_facade_steps.py
T
freemo 02250473ad fix(ci): restore all CI quality gates to passing on master
Fix all failing CI quality gates (lint, unit_tests, format) without
suppressing any quality enforcement.

Root causes and fixes:

1. Format: features/steps/plan_namespaced_name_tdd_steps.py had trailing
   whitespace; fixed by running ruff format.

2. Unit tests - A2A JSON-RPC 2.0 migration (commit 9c6d6915) renamed
   A2aRequest fields (operation→method, request_id→id, a2a_version→jsonrpc)
   and A2aResponse fields (status+data→result, request_id→id) but did not
   update all step files and feature files:
   - a2a_jsonrpc_wire_format_steps.py: added use_step_matcher('re') and
     reset to 'parse' at end to prevent parallel test interference
   - a2a_facade_wiring_steps.py: updated operation= to method=, .status/.data
     to .result
   - a2a_facade_steps.py: updated request_id→id, a2a_version→jsonrpc,
     A2aResponse(request_id=..., status=...) to new API
   - m6_facade_steps.py: updated all old API usage
   - devcontainer_cleanup_steps.py: updated A2aRequest(operation=...)
   - plan_prompt_command_steps.py: updated A2aRequest(operation=...)
   - wf03_plan_prompt_confidence_steps.py: updated A2aRequest(operation=...)
   - consolidated_misc.feature: updated old A2aRequest/A2aResponse scenarios

3. Unit tests - Session CLI output changed (commit 0d5d9cf0 and others):
   - 'Session Created' → 'Session created' (lowercase)
   - 'Session Details' → 'Session Summary'
   - 'Sessions (N total)' → 'Sessions'
   - session list JSON: top-level 'total' → nested 'summary.total'
   - Fixed in: session_cli.feature, session_cli_coverage_boost.feature,
     session_cli_uncovered_branches.feature, session_list_error.feature,
     tdd_session_create_persist_steps.py

4. Unit tests - Plan list output changed (commit 1a07a891):
   - 'V3 Lifecycle Plans' → 'Plans'
   - 'Lifecycle Plans' → 'Plans'
   - Name column removed (restored in source)
   - Invariants column removed (restored in source)
   - Project truncation removed (restored in source)
   - Fixed in: plan_cli_cancel_revert_coverage.feature,
     plan_lifecycle_cli_coverage.feature, plan_cli_coverage_boost_steps.py,
     plan.py (source code restored)

5. Unit tests - Plan apply command now requires ULID (commit 300a5d6d):
   - plan_cli_coverage_r3.feature: updated 'PLAN-001' to valid ULID
   - plan_cli_coverage_r3_steps.py: added --yes flag, added new step for
     no-eligible-plans path

6. Unit tests - Various source code bugs:
   - ThoughtBlock: converted from @dataclass to Pydantic BaseModel
     (architecture test requires all dataclasses to use Pydantic)
   - session.py: added DatabaseError handling to export, import, tell commands
   - database.py: fixed rollback_to() to reuse checkpoint connection for writes
   - database.py: added _get_checkpoint_conn() helper
   - check-tls-cert.py: fixed SSLCertVerificationError.reason AttributeError

7. Unit tests - Test step bugs:
   - error_recovery_coverage_boost_steps.py: fixed invalid ULID _PLAN_ID
   - session_service_coverage_steps.py: fixed 'sha256:' prefix bug in checksum
   - database_models_new_coverage_steps.py: added 'name' field to session mock
   - async_audit_recording_steps.py: fixed Settings(audit_async=False) via env var
   - coverage_threshold_config_steps.py: added --coverage-min pattern support
   - m5_acms_smoke_steps.py: updated usage hint text
   - actor_cli_yaml_steps.py: updated 'Removed actor' → 'Actor removed'
   - aimodelscredentials_steps.py: set context.imported_class in import step
   - domain_base_model.feature: added missing 'When I examine model_config' step
   - tui_first_run_steps.py: fixed module reload to restore cleveragents.tui.*
     modules after test (prevented patch interference in subsequent tests)
   - tui_first_run_steps.py: added set_search('') step for empty string
   - resource_handler_base_coverage_r3_steps.py: use _MinimalHandler instead
     of DatabaseResourceHandler for NotImplementedError tests
   - resource_handler_crud.feature: updated to test new DatabaseHandler behavior
   - resource_handler_sandbox.feature: updated to test new DatabaseHandler behavior
   - tdd_json_decode_crash_persistence.feature: fixed @tdd_bug → @tdd_issue tags

8. Parallel test interference:
   - All step files using use_step_matcher('re') now reset to 'parse' at end
     to prevent global matcher state leaking to subsequent step files
2026-04-04 20:38:16 +00:00

409 lines
14 KiB
Python

"""Step definitions for M6 A2A facade, event queue, transport, and version tests.
Split from ``m6_autonomy_acceptance_steps.py`` to stay under the project's
500-line guideline. All step names keep the ``m6 smoke`` prefix to avoid
``AmbiguousStep`` conflicts.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.a2a.errors import (
A2aNotAvailableError,
A2aOperationNotFoundError,
A2aVersionMismatchError,
)
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import (
A2aErrorDetail,
A2aEvent,
A2aRequest,
A2aResponse,
)
from cleveragents.a2a.transport import A2aHttpTransport
from cleveragents.a2a.versioning import A2aVersionNegotiator
_FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m6"
# -----------------------------------------------------------------------
# Background
# -----------------------------------------------------------------------
@given("a m6 smoke test runner")
def step_m6_smoke_runner(context: Context) -> None:
"""Initialise the m6 smoke test context."""
context.m6_result = None
context.m6_response = None
context.m6_error = None
context.m6_profile = None
context.m6_guard_result = None
context.m6_operations = None
context.m6_event_queue = None
context.m6_callback_calls = []
context.m6_subscription_id = None
context.m6_version_result = None
context.m6_version_supported = None
context.m6_transport_connected = None
context.m6_profiles_list = []
@given("a m6 smoke A2A local facade")
def step_m6_smoke_facade(context: Context) -> None:
"""Create an A2aLocalFacade for testing."""
context.m6_facade = A2aLocalFacade()
# -----------------------------------------------------------------------
# Fixture loading — A2A facade flows
# -----------------------------------------------------------------------
@when("I m6 smoke load the A2A facade flows fixture")
def step_m6_smoke_load_facade_fixture(context: Context) -> None:
with open(_FIXTURES_DIR / "a2a_facade_flows.json") as f:
context.m6_fixture_data = json.load(f)
@then("the m6 smoke facade fixture should have a session lifecycle entry")
def step_m6_smoke_facade_session(context: Context) -> None:
names = [f["name"] for f in context.m6_fixture_data["fixtures"]]
assert "session_lifecycle" in names
@then("the m6 smoke facade fixture should have a plan lifecycle entry")
def step_m6_smoke_facade_plan(context: Context) -> None:
names = [f["name"] for f in context.m6_fixture_data["fixtures"]]
assert "plan_lifecycle" in names
# -----------------------------------------------------------------------
# A2A facade dispatch operations
# -----------------------------------------------------------------------
@when('I m6 smoke dispatch "{operation}" with params {params_json}')
def step_m6_smoke_dispatch(
context: Context,
operation: str,
params_json: str,
) -> None:
params = json.loads(params_json)
request = A2aRequest(method=operation, params=params)
context.m6_response = context.m6_facade.dispatch(request)
@then('the m6 smoke response status should be "{status}"')
def step_m6_smoke_response_status(context: Context, status: str) -> None:
assert context.m6_response is not None
if status == "ok":
assert context.m6_response.result is not None, (
f"Expected ok response but got error: {context.m6_response.error}"
)
else:
assert context.m6_response.error is not None, (
f"Expected error response but got result: {context.m6_response.result}"
)
@then('the m6 smoke response data should contain key "{key}"')
def step_m6_smoke_response_key(context: Context, key: str) -> None:
assert context.m6_response is not None
data = context.m6_response.result or {}
assert key in data, f"Key '{key}' not found in response data: {data}"
@then('the m6 smoke response data "{key}" should equal "{value}"')
def step_m6_smoke_response_value(
context: Context,
key: str,
value: str,
) -> None:
assert context.m6_response is not None
data = context.m6_response.result or {}
assert data[key] == value, f"Expected {key}={value!r}, got {data.get(key)!r}"
# -----------------------------------------------------------------------
# A2A facade error handling
# -----------------------------------------------------------------------
@when('I m6 smoke dispatch unknown operation "{operation}"')
def step_m6_smoke_dispatch_unknown(context: Context, operation: str) -> None:
request = A2aRequest(method=operation, params={})
try:
context.m6_facade.dispatch(request)
context.m6_error = None
except A2aOperationNotFoundError as exc:
context.m6_error = exc
@then("the m6 smoke facade should raise A2aOperationNotFoundError")
def step_m6_smoke_error_op_not_found(context: Context) -> None:
assert isinstance(context.m6_error, A2aOperationNotFoundError)
@when("I m6 smoke dispatch with a non-A2aRequest object")
def step_m6_smoke_dispatch_invalid(context: Context) -> None:
try:
context.m6_facade.dispatch("not a request") # type: ignore[arg-type]
context.m6_error = None
except TypeError as exc:
context.m6_error = exc
@then("the m6 smoke facade should raise TypeError")
def step_m6_smoke_error_type(context: Context) -> None:
assert isinstance(context.m6_error, TypeError)
# -----------------------------------------------------------------------
# A2A facade service registration and list operations
# -----------------------------------------------------------------------
@when('I m6 smoke register service "{name}" on the facade')
def step_m6_smoke_register_service(context: Context, name: str) -> None:
mock_service = MagicMock()
context.m6_facade.register_service(name, mock_service)
@then('the m6 smoke facade should have service "{name}"')
def step_m6_smoke_has_service(context: Context, name: str) -> None:
assert name in context.m6_facade._services
@when("I m6 smoke list facade operations")
def step_m6_smoke_list_ops(context: Context) -> None:
context.m6_operations = context.m6_facade.list_operations()
@then('the m6 smoke operations should include "{operation}"')
def step_m6_smoke_ops_include(context: Context, operation: str) -> None:
assert operation in context.m6_operations
@then("the m6 smoke operations count should be {count:d}")
def step_m6_smoke_ops_count(context: Context, count: int) -> None:
assert len(context.m6_operations) == count
# -----------------------------------------------------------------------
# A2A event queue
# -----------------------------------------------------------------------
@given("a m6 smoke A2A event queue")
def step_m6_smoke_event_queue(context: Context) -> None:
context.m6_event_queue = A2aEventQueue()
context.m6_callback_calls = []
@when('I m6 smoke publish an event with type "{event_type}"')
def step_m6_smoke_publish_event(context: Context, event_type: str) -> None:
event = A2aEvent(event_type=event_type, data={"test": True})
context.m6_event_queue.publish(event)
@then("the m6 smoke event queue should have {count:d} event")
def step_m6_smoke_event_count(context: Context, count: int) -> None:
events = context.m6_event_queue.get_events()
assert len(events) == count
@then('the m6 smoke last event type should be "{event_type}"')
def step_m6_smoke_last_event_type(context: Context, event_type: str) -> None:
events = context.m6_event_queue.get_events()
assert events[-1].event_type == event_type
@when("I m6 smoke subscribe a local callback")
def step_m6_smoke_subscribe_local(context: Context) -> None:
def _callback(event: A2aEvent) -> None:
context.m6_callback_calls.append(event)
context.m6_subscription_id = context.m6_event_queue.subscribe_local(_callback)
@then("the m6 smoke callback should have been called once")
def step_m6_smoke_callback_called(context: Context) -> None:
assert len(context.m6_callback_calls) == 1
@when("I m6 smoke unsubscribe the callback")
def step_m6_smoke_unsubscribe(context: Context) -> None:
context.m6_event_queue.unsubscribe(context.m6_subscription_id)
@then("the m6 smoke callback should not have been called")
def step_m6_smoke_callback_not_called(context: Context) -> None:
assert len(context.m6_callback_calls) == 0
@when("I m6 smoke close the event queue")
def step_m6_smoke_close_queue(context: Context) -> None:
context.m6_event_queue.close()
@then("the m6 smoke publishing should raise RuntimeError")
def step_m6_smoke_publish_after_close(context: Context) -> None:
event = A2aEvent(event_type="after.close", data={})
try:
context.m6_event_queue.publish(event)
raise AssertionError("Expected RuntimeError")
except RuntimeError:
pass
@when('I m6 smoke attempt remote subscribe to "{endpoint}"')
def step_m6_smoke_remote_subscribe(context: Context, endpoint: str) -> None:
try:
context.m6_event_queue.subscribe_remote(endpoint)
context.m6_error = None
except A2aNotAvailableError as exc:
context.m6_error = exc
@then("the m6 smoke facade should raise A2aNotAvailableError")
def step_m6_smoke_error_not_available(context: Context) -> None:
assert isinstance(context.m6_error, A2aNotAvailableError)
# -----------------------------------------------------------------------
# A2A HTTP transport stub
# -----------------------------------------------------------------------
@when("I m6 smoke attempt transport send")
def step_m6_smoke_transport_send(context: Context) -> None:
transport = A2aHttpTransport()
request = A2aRequest(method="plan.create", params={})
try:
transport.send(request)
context.m6_error = None
except A2aNotAvailableError as exc:
context.m6_error = exc
@when('I m6 smoke attempt transport connect to "{url}"')
def step_m6_smoke_transport_connect(context: Context, url: str) -> None:
transport = A2aHttpTransport()
try:
transport.connect(url)
context.m6_error = None
except A2aNotAvailableError as exc:
context.m6_error = exc
@when("I m6 smoke attempt transport disconnect")
def step_m6_smoke_transport_disconnect(context: Context) -> None:
transport = A2aHttpTransport()
try:
transport.disconnect()
context.m6_error = None
except A2aNotAvailableError as exc:
context.m6_error = exc
@when("I m6 smoke check transport is_connected")
def step_m6_smoke_transport_connected(context: Context) -> None:
transport = A2aHttpTransport()
context.m6_transport_connected = transport.is_connected()
@then("the m6 smoke transport should not be connected")
def step_m6_smoke_transport_not_connected(context: Context) -> None:
assert context.m6_transport_connected is False
# -----------------------------------------------------------------------
# A2A version negotiation
# -----------------------------------------------------------------------
@when('I m6 smoke negotiate A2A version "{version}"')
def step_m6_smoke_negotiate(context: Context, version: str) -> None:
negotiator = A2aVersionNegotiator()
try:
context.m6_version_result = negotiator.negotiate(version)
context.m6_error = None
except A2aVersionMismatchError as exc:
context.m6_error = exc
@then('the m6 smoke negotiated version should be "{version}"')
def step_m6_smoke_negotiated(context: Context, version: str) -> None:
assert context.m6_version_result == version
@then("the m6 smoke facade should raise A2aVersionMismatchError")
def step_m6_smoke_error_version(context: Context) -> None:
assert isinstance(context.m6_error, A2aVersionMismatchError)
@when('I m6 smoke check if version "{version}" is supported')
def step_m6_smoke_version_supported(context: Context, version: str) -> None:
negotiator = A2aVersionNegotiator()
context.m6_version_supported = negotiator.is_supported(version)
@then("the m6 smoke version support should be true")
def step_m6_smoke_version_true(context: Context) -> None:
assert context.m6_version_supported is True
@then("the m6 smoke version support should be false")
def step_m6_smoke_version_false(context: Context) -> None:
assert context.m6_version_supported is False
# -----------------------------------------------------------------------
# A2A model validation
# -----------------------------------------------------------------------
@when("I m6 smoke create A2aRequest with empty operation")
def step_m6_smoke_invalid_request(context: Context) -> None:
try:
A2aRequest(method="")
context.m6_error = None
except ValueError as exc:
context.m6_error = exc
@when('I m6 smoke create A2aResponse with invalid status "{status}"')
def step_m6_smoke_invalid_response(context: Context, status: str) -> None:
try:
# A2aResponse requires either result or error (not both, not neither)
A2aResponse(id="test")
context.m6_error = None
except ValueError as exc:
context.m6_error = exc
@when("I m6 smoke create A2aEvent with empty event_type")
def step_m6_smoke_invalid_event(context: Context) -> None:
try:
A2aEvent(event_type="")
context.m6_error = None
except ValueError as exc:
context.m6_error = exc
@when("I m6 smoke create A2aErrorDetail with empty code")
def step_m6_smoke_invalid_error_detail(context: Context) -> None:
try:
A2aErrorDetail(code="", message="test")
context.m6_error = None
except ValueError as exc:
context.m6_error = exc