forked from HAL9000/cleveragents-core
02250473ad
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 (commit9c6d6915) 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 (commit0d5d9cf0and 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 (commit1a07a891): - '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 (commit300a5d6d): - 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
919 lines
33 KiB
Python
919 lines
33 KiB
Python
"""Step definitions for M5 ACMS pipeline and large-project context smoke tests.
|
|
|
|
All step names are prefixed with ``m5 smoke`` to avoid ``AmbiguousStep``
|
|
conflicts with existing steps.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path, PurePosixPath
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from pydantic import ValidationError
|
|
from typer.testing import CliRunner
|
|
from ulid import ULID
|
|
|
|
from cleveragents.application.services.acms_service import ACMSPipeline
|
|
from cleveragents.cli.commands.context import app as context_app
|
|
from cleveragents.domain.models.core.context import Context as ContextModel
|
|
from cleveragents.domain.models.core.context_fragment import (
|
|
ContextBudget,
|
|
ContextFragment,
|
|
FragmentProvenance,
|
|
)
|
|
from cleveragents.domain.models.core.context_policy import (
|
|
BudgetEnforcementResult,
|
|
ContextView,
|
|
ProjectContextPolicy,
|
|
enforce_size_budget,
|
|
)
|
|
|
|
_FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m5"
|
|
|
|
|
|
def _mock_container(
|
|
context_service: MagicMock,
|
|
project_service: MagicMock | None = None,
|
|
) -> MagicMock:
|
|
"""Build a mock DI container for context CLI tests."""
|
|
container = MagicMock()
|
|
container.context_service.return_value = context_service
|
|
if project_service is None:
|
|
project_service = MagicMock()
|
|
project_service.get_current_project.return_value = MagicMock()
|
|
container.project_service.return_value = project_service
|
|
return container
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a m5 smoke test runner")
|
|
def step_m5_smoke_runner(context: Context) -> None:
|
|
"""Set up the CLI runner for M5 smoke tests."""
|
|
context.runner = CliRunner()
|
|
|
|
|
|
@given("a m5 smoke mocked context service")
|
|
def step_m5_smoke_mocked_service(context: Context) -> None:
|
|
"""Set up mocked context service for M5 smoke tests."""
|
|
context.mock_context_service = MagicMock()
|
|
context.m5_error = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I m5 smoke load the ACMS context policy fixture")
|
|
def step_m5_load_policy_fixture(context: Context) -> None:
|
|
fixture_path = _FIXTURES_DIR / "acms_context_policy.json"
|
|
context.m5_policy_fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
@then("the m5 smoke policy fixture should have a default view")
|
|
def step_m5_policy_has_default(context: Context) -> None:
|
|
assert "default_view" in context.m5_policy_fixture
|
|
|
|
|
|
@then("the m5 smoke policy fixture should have a strategize view")
|
|
def step_m5_policy_has_strategize(context: Context) -> None:
|
|
assert "strategize_view" in context.m5_policy_fixture
|
|
|
|
|
|
@then("the m5 smoke policy fixture should have an execute view")
|
|
def step_m5_policy_has_execute(context: Context) -> None:
|
|
assert "execute_view" in context.m5_policy_fixture
|
|
|
|
|
|
@when("I m5 smoke load the large project context fixture")
|
|
def step_m5_load_large_project(context: Context) -> None:
|
|
fixture_path = _FIXTURES_DIR / "large_project_context.json"
|
|
context.m5_large_project = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
@then("the m5 smoke large project fixture should have file entries")
|
|
def step_m5_large_project_has_files(context: Context) -> None:
|
|
assert "file_entries" in context.m5_large_project
|
|
assert len(context.m5_large_project["file_entries"]) > 0
|
|
|
|
|
|
@then("the m5 smoke large project fixture should have context tiers")
|
|
def step_m5_large_project_has_tiers(context: Context) -> None:
|
|
tiers = context.m5_large_project.get("context_tiers", {})
|
|
assert "hot" in tiers
|
|
assert "warm" in tiers
|
|
assert "cold" in tiers
|
|
|
|
|
|
@when("I m5 smoke load the context analysis results fixture")
|
|
def step_m5_load_analysis(context: Context) -> None:
|
|
fixture_path = _FIXTURES_DIR / "context_analysis_results.json"
|
|
context.m5_analysis_fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
@then("the m5 smoke analysis fixture should have a summary")
|
|
def step_m5_analysis_has_summary(context: Context) -> None:
|
|
assert context.m5_analysis_fixture.get("summary")
|
|
|
|
|
|
@then("the m5 smoke analysis fixture should have dependencies")
|
|
def step_m5_analysis_has_deps(context: Context) -> None:
|
|
assert "dependencies" in context.m5_analysis_fixture
|
|
assert len(context.m5_analysis_fixture["dependencies"]) > 0
|
|
|
|
|
|
@then("the m5 smoke analysis fixture should have relevance scores")
|
|
def step_m5_analysis_has_scores(context: Context) -> None:
|
|
assert "relevance_scores" in context.m5_analysis_fixture
|
|
assert len(context.m5_analysis_fixture["relevance_scores"]) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Context policy resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a m5 smoke empty context policy")
|
|
def step_m5_empty_policy(context: Context) -> None:
|
|
context.m5_policy = ProjectContextPolicy()
|
|
|
|
|
|
@given("a m5 smoke policy with only default view")
|
|
def step_m5_default_only_policy(context: Context) -> None:
|
|
context.m5_policy = ProjectContextPolicy(
|
|
default_view=ContextView(
|
|
include_paths=["src/**/*.py"],
|
|
exclude_paths=["**/__pycache__/**"],
|
|
max_file_size=262144,
|
|
),
|
|
)
|
|
|
|
|
|
@given("a m5 smoke policy with strategize override")
|
|
def step_m5_strategize_override(context: Context) -> None:
|
|
context.m5_policy = ProjectContextPolicy(
|
|
default_view=ContextView(
|
|
include_paths=["src/**/*.py"],
|
|
),
|
|
strategize_view=ContextView(
|
|
include_paths=["src/**/*.py", "docs/**/*.md"],
|
|
max_file_size=131072,
|
|
),
|
|
)
|
|
|
|
|
|
@when('I m5 smoke resolve the view for phase "{phase}"')
|
|
def step_m5_resolve_view(context: Context, phase: str) -> None:
|
|
context.m5_resolved_view = context.m5_policy.resolve_view(phase)
|
|
|
|
|
|
@when('I m5 smoke resolve the view for invalid phase "{phase}"')
|
|
def step_m5_resolve_invalid_phase(context: Context, phase: str) -> None:
|
|
try:
|
|
context.m5_policy.resolve_view(phase)
|
|
context.m5_error = None
|
|
except ValueError as exc:
|
|
context.m5_error = exc
|
|
|
|
|
|
@then("the m5 smoke resolved view should include all paths")
|
|
def step_m5_view_includes_all(context: Context) -> None:
|
|
assert context.m5_resolved_view.include_paths == []
|
|
|
|
|
|
@then("the m5 smoke resolved view should match the default view")
|
|
def step_m5_view_matches_default(context: Context) -> None:
|
|
default_view = context.m5_policy.default_view
|
|
assert context.m5_resolved_view == default_view
|
|
|
|
|
|
@then("the m5 smoke resolved view should use the strategize override")
|
|
def step_m5_view_uses_strategize(context: Context) -> None:
|
|
assert context.m5_resolved_view == context.m5_policy.strategize_view
|
|
|
|
|
|
@then("a m5 smoke ValueError should be raised")
|
|
def step_m5_value_error_raised(context: Context) -> None:
|
|
assert context.m5_error is not None
|
|
assert isinstance(context.m5_error, ValueError)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Budget enforcement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a m5 smoke context view with max_file_size {size:d}")
|
|
def step_m5_view_max_file_size(context: Context, size: int) -> None:
|
|
context.m5_view = ContextView(max_file_size=size)
|
|
|
|
|
|
@given("a m5 smoke context view with max_total_size {size:d}")
|
|
def step_m5_view_max_total_size(context: Context, size: int) -> None:
|
|
context.m5_view = ContextView(max_total_size=size)
|
|
|
|
|
|
@given("a m5 smoke context view with no size limits")
|
|
def step_m5_view_no_limits(context: Context) -> None:
|
|
context.m5_view = ContextView()
|
|
|
|
|
|
@then("a m5 smoke file of size {size:d} should exceed the budget")
|
|
def step_m5_file_exceeds(context: Context, size: int) -> None:
|
|
assert context.m5_view.max_file_size is not None
|
|
assert size > context.m5_view.max_file_size
|
|
|
|
|
|
@then("a m5 smoke file of size {size:d} should be within the budget")
|
|
def step_m5_file_within(context: Context, size: int) -> None:
|
|
if context.m5_view.max_file_size is None:
|
|
return # No limit means always within budget
|
|
assert size <= context.m5_view.max_file_size
|
|
|
|
|
|
@then("a m5 smoke aggregate of size {size:d} should exceed the budget")
|
|
def step_m5_aggregate_exceeds(context: Context, size: int) -> None:
|
|
assert context.m5_view.max_total_size is not None
|
|
assert size > context.m5_view.max_total_size
|
|
|
|
|
|
@then("a m5 smoke aggregate of size {size:d} should be within the budget")
|
|
def step_m5_aggregate_within(context: Context, size: int) -> None:
|
|
if context.m5_view.max_total_size is None:
|
|
return # No limit means always within budget
|
|
assert size <= context.m5_view.max_total_size
|
|
|
|
|
|
@when("I m5 smoke create a context view with max_file_size {size:d}")
|
|
def step_m5_create_view_file_size(context: Context, size: int) -> None:
|
|
try:
|
|
ContextView(max_file_size=size)
|
|
context.m5_error = None
|
|
except ValidationError as exc:
|
|
context.m5_error = exc
|
|
|
|
|
|
@when("I m5 smoke create a context view with max_total_size {size:d}")
|
|
def step_m5_create_view_total_size(context: Context, size: int) -> None:
|
|
try:
|
|
ContextView(max_total_size=size)
|
|
context.m5_error = None
|
|
except ValidationError as exc:
|
|
context.m5_error = exc
|
|
|
|
|
|
@then("a m5 smoke validation error should be raised")
|
|
def step_m5_validation_error(context: Context) -> None:
|
|
assert context.m5_error is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Context assembly via CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a m5 smoke project with no context")
|
|
def step_m5_project_no_context(context: Context) -> None:
|
|
context.mock_context_service.list_context.return_value = []
|
|
|
|
|
|
@given("a m5 smoke project with mocked context service")
|
|
def step_m5_project_mocked_service(context: Context) -> None:
|
|
context.mock_context_service.add_to_context.return_value = (
|
|
[Path("src/main.py")],
|
|
[],
|
|
)
|
|
|
|
|
|
@given("a m5 smoke project with context entries")
|
|
def step_m5_project_with_entries(context: Context) -> None:
|
|
mock_entry = MagicMock(spec=ContextModel)
|
|
mock_entry.path = "src/main.py"
|
|
mock_entry.size = 1024
|
|
mock_entry.type = "FILE"
|
|
context.mock_context_service.list_context.return_value = [mock_entry]
|
|
context.mock_context_service.show_context_content.return_value = {
|
|
"src/main.py": "print('hello')"
|
|
}
|
|
context.mock_context_service.get_context_content.return_value = "print('hello')"
|
|
context.mock_context_service.clear_context.return_value = 1
|
|
|
|
|
|
@when("I m5 smoke invoke context list")
|
|
def step_m5_invoke_context_list(context: Context) -> None:
|
|
container = _mock_container(context.mock_context_service)
|
|
with patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=container,
|
|
):
|
|
result = context.runner.invoke(context_app, ["list"])
|
|
context.m5_result = result
|
|
|
|
|
|
@then("the m5 smoke context list should succeed")
|
|
def step_m5_context_list_success(context: Context) -> None:
|
|
assert context.m5_result.exit_code == 0, (
|
|
f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke context list output should be empty")
|
|
def step_m5_context_list_empty(context: Context) -> None:
|
|
# Empty context list may show "[]", "No files", or "No context" message
|
|
output = context.m5_result.output.strip().lower()
|
|
assert (
|
|
output == "[]" or "no files" in output or "no context" in output or output == ""
|
|
)
|
|
|
|
|
|
@when('I m5 smoke invoke context add with path "{path}"')
|
|
def step_m5_invoke_context_add(context: Context, path: str) -> None:
|
|
container = _mock_container(context.mock_context_service)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=container,
|
|
),
|
|
patch.object(Path, "exists", return_value=True),
|
|
):
|
|
result = context.runner.invoke(context_app, ["add", path])
|
|
context.m5_result = result
|
|
|
|
|
|
@when('I m5 smoke invoke context add with missing path "{path}"')
|
|
def step_m5_invoke_context_add_missing_path(context: Context, path: str) -> None:
|
|
container = _mock_container(context.mock_context_service)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=container,
|
|
),
|
|
patch.object(Path, "exists", return_value=False),
|
|
):
|
|
result = context.runner.invoke(context_app, ["add", path])
|
|
context.m5_result = result
|
|
|
|
|
|
@then("the m5 smoke context add should succeed")
|
|
def step_m5_context_add_success(context: Context) -> None:
|
|
assert context.m5_result.exit_code == 0, (
|
|
f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke context add output should report the missing path")
|
|
def step_m5_context_add_missing_path_reported(context: Context) -> None:
|
|
output = context.m5_result.output
|
|
assert "Path does not exist:" in output, f"Missing-path error not shown: {output}"
|
|
assert "No files were added to context." in output, (
|
|
f"Expected add command to report no-op after missing path: {output}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke context service should not add any files")
|
|
def step_m5_context_service_not_called(context: Context) -> None:
|
|
context.mock_context_service.add_to_context.assert_not_called()
|
|
|
|
|
|
@when('I m5 smoke invoke context show for path "{path}"')
|
|
def step_m5_invoke_context_show(context: Context, path: str) -> None:
|
|
container = _mock_container(context.mock_context_service)
|
|
with patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=container,
|
|
):
|
|
result = context.runner.invoke(context_app, ["show", path])
|
|
context.m5_result = result
|
|
|
|
|
|
@then("the m5 smoke context show should succeed")
|
|
def step_m5_context_show_success(context: Context) -> None:
|
|
assert context.m5_result.exit_code == 0, (
|
|
f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke context show output should contain content")
|
|
def step_m5_context_show_has_content(context: Context) -> None:
|
|
assert len(context.m5_result.output.strip()) > 0
|
|
|
|
|
|
@when("I m5 smoke invoke context clear")
|
|
def step_m5_invoke_context_clear(context: Context) -> None:
|
|
container = _mock_container(context.mock_context_service)
|
|
with patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=container,
|
|
):
|
|
result = context.runner.invoke(context_app, ["clear", "--yes"])
|
|
context.m5_result = result
|
|
|
|
|
|
@then("the m5 smoke context clear should succeed")
|
|
def step_m5_context_clear_success(context: Context) -> None:
|
|
assert context.m5_result.exit_code == 0, (
|
|
f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Project context policy CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a m5 smoke project with a saved context policy")
|
|
def step_m5_project_with_policy(context: Context) -> None:
|
|
context.m5_saved_policy = ProjectContextPolicy(
|
|
default_view=ContextView(
|
|
include_paths=["src/**/*.py"],
|
|
max_file_size=262144,
|
|
),
|
|
)
|
|
|
|
|
|
@when("I m5 smoke invoke project context show")
|
|
def step_m5_invoke_project_context_show(context: Context) -> None:
|
|
from cleveragents.cli.commands.project_context import _default_acms_config
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get.return_value = MagicMock() # project exists
|
|
|
|
mock_container = MagicMock()
|
|
mock_container.session_factory.return_value = MagicMock()
|
|
|
|
with (
|
|
patch(
|
|
"cleveragents.cli.commands.project_context._get_namespaced_project_repo",
|
|
return_value=mock_repo,
|
|
),
|
|
patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=mock_container,
|
|
),
|
|
patch(
|
|
"cleveragents.cli.commands.project_context._read_policy",
|
|
return_value=context.m5_saved_policy,
|
|
),
|
|
patch(
|
|
"cleveragents.cli.commands.project_context._read_acms_config",
|
|
return_value=_default_acms_config(),
|
|
),
|
|
):
|
|
from cleveragents.cli.commands.project_context import app as pc_app
|
|
|
|
result = context.runner.invoke(pc_app, ["show", "local/m5-proj"])
|
|
context.m5_result = result
|
|
|
|
|
|
@then("the m5 smoke project context show should succeed")
|
|
def step_m5_project_context_show_success(context: Context) -> None:
|
|
assert context.m5_result.exit_code == 0, (
|
|
f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke project context output should contain phase views")
|
|
def step_m5_project_context_has_phases(context: Context) -> None:
|
|
output = context.m5_result.output
|
|
assert "default" in output.lower() or "view" in output.lower()
|
|
|
|
|
|
@when("I m5 smoke invoke project context inspect")
|
|
def step_m5_invoke_context_inspect(context: Context) -> None:
|
|
from cleveragents.application.services.context_tiers import ContextTierService
|
|
from cleveragents.cli.commands.project_context import (
|
|
_default_acms_config,
|
|
)
|
|
from cleveragents.cli.commands.project_context import (
|
|
app as pc_app,
|
|
)
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get.return_value = MagicMock()
|
|
|
|
mock_container = MagicMock()
|
|
mock_container.session_factory.return_value = MagicMock()
|
|
mock_container.namespaced_project_repo.return_value = mock_repo
|
|
mock_container.context_tier_service.return_value = ContextTierService()
|
|
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=mock_container,
|
|
),
|
|
patch(
|
|
"cleveragents.cli.commands.project_context._read_policy",
|
|
return_value=ProjectContextPolicy(),
|
|
),
|
|
patch(
|
|
"cleveragents.cli.commands.project_context._read_acms_config",
|
|
return_value=_default_acms_config(),
|
|
),
|
|
):
|
|
result = context.runner.invoke(pc_app, ["inspect", "local/m5-proj"])
|
|
context.m5_result = result
|
|
|
|
|
|
@when("I m5 smoke invoke project context simulate")
|
|
def step_m5_invoke_context_simulate(context: Context) -> None:
|
|
from cleveragents.application.services.context_tiers import ContextTierService
|
|
from cleveragents.cli.commands.project_context import (
|
|
_default_acms_config,
|
|
)
|
|
from cleveragents.cli.commands.project_context import (
|
|
app as pc_app,
|
|
)
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get.return_value = MagicMock()
|
|
|
|
mock_container = MagicMock()
|
|
mock_container.session_factory.return_value = MagicMock()
|
|
mock_container.namespaced_project_repo.return_value = mock_repo
|
|
mock_container.context_tier_service.return_value = ContextTierService()
|
|
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=mock_container,
|
|
),
|
|
patch(
|
|
"cleveragents.cli.commands.project_context._read_policy",
|
|
return_value=ProjectContextPolicy(),
|
|
),
|
|
patch(
|
|
"cleveragents.cli.commands.project_context._read_acms_config",
|
|
return_value=_default_acms_config(),
|
|
),
|
|
):
|
|
result = context.runner.invoke(pc_app, ["simulate", "local/m5-proj"])
|
|
context.m5_result = result
|
|
|
|
|
|
@then("the m5 smoke project context inspect should succeed")
|
|
def step_m5_project_context_inspect_success(context: Context) -> None:
|
|
assert context.m5_result.exit_code == 0, (
|
|
f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke project context simulate should succeed")
|
|
def step_m5_project_context_simulate_success(context: Context) -> None:
|
|
assert context.m5_result.exit_code == 0, (
|
|
f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Context analysis agent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a m5 smoke mocked context analysis agent")
|
|
def step_m5_mocked_analysis_agent(context: Context) -> None:
|
|
fixture_path = _FIXTURES_DIR / "context_analysis_results.json"
|
|
fixture_data = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
context.m5_mock_analysis = {
|
|
"file_paths": list(fixture_data["dependencies"].keys()),
|
|
"documents": [],
|
|
"dependencies": fixture_data["dependencies"],
|
|
"summary": fixture_data["summary"],
|
|
"relevance_scores": fixture_data["relevance_scores"],
|
|
"chunks": [],
|
|
"error": None,
|
|
}
|
|
|
|
|
|
@when("I m5 smoke invoke context analysis")
|
|
def step_m5_invoke_analysis(context: Context) -> None:
|
|
context.m5_analysis_result = context.m5_mock_analysis
|
|
|
|
|
|
@then("the m5 smoke analysis result should have a non-empty summary")
|
|
def step_m5_analysis_has_nonempty_summary(context: Context) -> None:
|
|
assert context.m5_analysis_result["summary"]
|
|
|
|
|
|
@then("the m5 smoke analysis result should have dependency entries")
|
|
def step_m5_analysis_has_dep_entries(context: Context) -> None:
|
|
assert len(context.m5_analysis_result["dependencies"]) > 0
|
|
|
|
|
|
@then("the m5 smoke analysis result should have relevance scores")
|
|
def step_m5_analysis_has_rel_scores(context: Context) -> None:
|
|
assert len(context.m5_analysis_result["relevance_scores"]) > 0
|
|
|
|
|
|
@then("all m5 smoke relevance scores should be between 0 and 1")
|
|
def step_m5_scores_bounded(context: Context) -> None:
|
|
for path, score in context.m5_analysis_result["relevance_scores"].items():
|
|
assert 0.0 <= score <= 1.0, f"Score for {path} out of range: {score}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Context CLI: multiple resources
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a m5 smoke project with multiple context entries")
|
|
def step_m5_project_with_multiple_entries(context: Context) -> None:
|
|
"""Set up a project with multiple context files for testing."""
|
|
entries = []
|
|
for i, name in enumerate(["src/a.py", "src/b.py", "src/c.py"]):
|
|
mock_entry = MagicMock(spec=ContextModel)
|
|
mock_entry.path = name
|
|
mock_entry.size = 1024 * (i + 1)
|
|
mock_entry.type = "FILE"
|
|
mock_entry.added_at = "2026-03-01T00:00:00Z"
|
|
mock_entry.content = f"# content of {name}"
|
|
entries.append(mock_entry)
|
|
context.mock_context_service.list_context.return_value = entries
|
|
context.mock_context_service.clear_context.return_value = len(entries)
|
|
|
|
|
|
@then("the m5 smoke context list output should contain multiple files")
|
|
def step_m5_context_list_multiple(context: Context) -> None:
|
|
output = context.m5_result.output
|
|
assert "a.py" in output or "3 total" in output, (
|
|
f"Expected multiple files in output, got: {output}"
|
|
)
|
|
|
|
|
|
@given("a m5 smoke project with mocked multi-add context service")
|
|
def step_m5_project_mocked_multi_add(context: Context) -> None:
|
|
context.mock_context_service.add_to_context.return_value = (
|
|
[Path("src/a.py"), Path("src/b.py")],
|
|
[],
|
|
)
|
|
|
|
|
|
@when('I m5 smoke invoke context add with paths "{path1}" and "{path2}"')
|
|
def step_m5_invoke_context_add_multi(context: Context, path1: str, path2: str) -> None:
|
|
container = _mock_container(context.mock_context_service)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=container,
|
|
),
|
|
patch.object(Path, "exists", return_value=True),
|
|
):
|
|
result = context.runner.invoke(context_app, ["add", path1, path2])
|
|
context.m5_result = result
|
|
|
|
|
|
@when("I m5 smoke invoke context show without path")
|
|
def step_m5_invoke_context_show_no_path(context: Context) -> None:
|
|
container = _mock_container(context.mock_context_service)
|
|
with patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=container,
|
|
):
|
|
result = context.runner.invoke(context_app, ["show"])
|
|
context.m5_result = result
|
|
|
|
|
|
@then("the m5 smoke context show output should contain an exact summary")
|
|
def step_m5_context_show_summary(context: Context) -> None:
|
|
output = context.m5_result.output
|
|
assert "Context Summary:" in output, f"Expected summary header in output: {output}"
|
|
assert "Total files: 3" in output, f"Expected file count in output: {output}"
|
|
assert "Total size: 6,144 bytes" in output, (
|
|
f"Expected aggregate size in output: {output}"
|
|
)
|
|
assert (
|
|
"Use 'agents context show <file>'" in output
|
|
or "Use 'agents actor context show <file>'" in output
|
|
), f"Expected usage hint in output: {output}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Context CLI: clear and re-add
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I m5 smoke re-add context entry "{path}" after clear')
|
|
def step_m5_readd_after_clear(context: Context, path: str) -> None:
|
|
"""Re-add a context entry after clearing, simulating clear+re-add flow."""
|
|
context.mock_context_service.add_to_context.return_value = (
|
|
[Path(path)],
|
|
[],
|
|
)
|
|
container = _mock_container(context.mock_context_service)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=container,
|
|
),
|
|
patch.object(Path, "exists", return_value=True),
|
|
):
|
|
result = context.runner.invoke(context_app, ["add", path])
|
|
context.m5_result = result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multi-project context
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('m5 smoke project "{name}" with {count:d} context files')
|
|
def step_m5_project_with_files(context: Context, name: str, count: int) -> None:
|
|
if not hasattr(context, "m5_projects"):
|
|
context.m5_projects = {}
|
|
context.m5_projects[name] = [
|
|
{"path": f"src/file_{i}.py", "size": 1024} for i in range(count)
|
|
]
|
|
|
|
|
|
@then('m5 smoke project "{name}" should have {count:d} context entries')
|
|
def step_m5_project_entry_count(context: Context, name: str, count: int) -> None:
|
|
assert len(context.m5_projects[name]) == count, (
|
|
f"Expected {count} entries for {name}, got {len(context.m5_projects[name])}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Context exclusion patterns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a m5 smoke context view excluding "{pattern}"')
|
|
def step_m5_view_excluding(context: Context, pattern: str) -> None:
|
|
context.m5_view = ContextView(exclude_paths=[pattern])
|
|
context.m5_exclude_pattern = pattern
|
|
|
|
|
|
@then('the m5 smoke path "{path}" should be excluded')
|
|
def step_m5_path_excluded(context: Context, path: str) -> None:
|
|
assert PurePosixPath(path).match(context.m5_exclude_pattern), (
|
|
f"Expected '{path}' to match exclude pattern '{context.m5_exclude_pattern}'"
|
|
)
|
|
|
|
|
|
@then('the m5 smoke path "{path}" should not be excluded')
|
|
def step_m5_path_not_excluded(context: Context, path: str) -> None:
|
|
assert not PurePosixPath(path).match(context.m5_exclude_pattern), (
|
|
f"Expected '{path}' NOT to match exclude pattern"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Budget enforcement (enforce_size_budget)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_fragment(size: int, index: int) -> ContextFragment:
|
|
"""Create a ContextFragment with content of exactly *size* bytes."""
|
|
# ASCII 'x' is 1 byte in UTF-8, so a string of length=size gives size bytes.
|
|
return ContextFragment(
|
|
content="x" * size,
|
|
token_count=max(1, size // 4),
|
|
uko_node=f"uko://test/frag-{index}",
|
|
provenance=FragmentProvenance(
|
|
resource_uri=f"test://res-{index}",
|
|
location=f"file_{index}.py",
|
|
),
|
|
)
|
|
|
|
|
|
@given("m5 smoke fragments of sizes {sizes}")
|
|
def step_m5_fragments_of_sizes(context: Context, sizes: str) -> None:
|
|
size_list = [int(s.strip()) for s in sizes.split()]
|
|
context.m5_fragments = [_make_fragment(s, i) for i, s in enumerate(size_list)]
|
|
|
|
|
|
@given("m5 smoke fragments of sizes")
|
|
def step_m5_fragments_empty(context: Context) -> None:
|
|
context.m5_fragments = []
|
|
|
|
|
|
@given("m5 smoke fragments with unicode content")
|
|
def step_m5_fragments_unicode(context: Context) -> None:
|
|
"""Create two fragments with multi-byte UTF-8 content.
|
|
|
|
Fragment 0: 5 x e-acute = 10 bytes (each is 2 bytes in UTF-8) -- fits in 10.
|
|
Fragment 1: 6 x e-acute = 12 bytes -- exceeds max_file_size of 10.
|
|
"""
|
|
context.m5_fragments = [
|
|
ContextFragment(
|
|
content="é" * 5,
|
|
token_count=5,
|
|
uko_node="uko://test/frag-0",
|
|
provenance=FragmentProvenance(
|
|
resource_uri="test://res-0",
|
|
location="file_0.py",
|
|
),
|
|
),
|
|
ContextFragment(
|
|
content="é" * 6,
|
|
token_count=6,
|
|
uko_node="uko://test/frag-1",
|
|
provenance=FragmentProvenance(
|
|
resource_uri="test://res-1",
|
|
location="file_1.py",
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
@given("a m5 smoke context view with max_file_size {mfs:d} and max_total_size {mts:d}")
|
|
def step_m5_view_mixed_limits(context: Context, mfs: int, mts: int) -> None:
|
|
context.m5_view = ContextView(max_file_size=mfs, max_total_size=mts)
|
|
|
|
|
|
@when("I m5 smoke enforce the size budget")
|
|
def step_m5_enforce_budget(context: Context) -> None:
|
|
context.m5_enforcement = enforce_size_budget(context.m5_fragments, context.m5_view)
|
|
|
|
|
|
@then("{count:d} m5 smoke fragments should be accepted")
|
|
def step_m5_accepted_count(context: Context, count: int) -> None:
|
|
result: BudgetEnforcementResult = context.m5_enforcement
|
|
assert len(result.accepted) == count, (
|
|
f"Expected {count} accepted, got {len(result.accepted)}"
|
|
)
|
|
|
|
|
|
@then("{count:d} m5 smoke fragments should be violated")
|
|
def step_m5_violation_count(context: Context, count: int) -> None:
|
|
result: BudgetEnforcementResult = context.m5_enforcement
|
|
assert len(result.violations) == count, (
|
|
f"Expected {count} violations, got {len(result.violations)}"
|
|
)
|
|
|
|
|
|
@then('the m5 smoke violation type should be "{vtype}"')
|
|
def step_m5_violation_type(context: Context, vtype: str) -> None:
|
|
result: BudgetEnforcementResult = context.m5_enforcement
|
|
assert any(v.violation_type == vtype for v in result.violations), (
|
|
f"No violation with type {vtype!r} found"
|
|
)
|
|
|
|
|
|
@then('the m5 smoke violation reason should contain "{text}"')
|
|
def step_m5_violation_reason_contains(context: Context, text: str) -> None:
|
|
result: BudgetEnforcementResult = context.m5_enforcement
|
|
assert any(text in v.reason for v in result.violations), (
|
|
f"No violation reason contains {text!r}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke violation content size should be {size:d}")
|
|
def step_m5_violation_content_size(context: Context, size: int) -> None:
|
|
result: BudgetEnforcementResult = context.m5_enforcement
|
|
assert any(v.content_size == size for v in result.violations), (
|
|
f"No violation with content_size={size}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke total accepted size should be {size:d}")
|
|
def step_m5_total_size(context: Context, size: int) -> None:
|
|
result: BudgetEnforcementResult = context.m5_enforcement
|
|
assert result.total_size == size, (
|
|
f"Expected total_size={size}, got {result.total_size}"
|
|
)
|
|
|
|
|
|
@then('the m5 smoke violations should include type "{vtype}"')
|
|
def step_m5_violations_include_type(context: Context, vtype: str) -> None:
|
|
result: BudgetEnforcementResult = context.m5_enforcement
|
|
types = {v.violation_type for v in result.violations}
|
|
assert vtype in types, f"Expected violation type {vtype!r} in {types}"
|
|
|
|
|
|
@when("I m5 smoke assemble via pipeline with the context view")
|
|
def step_m5_assemble_pipeline(context: Context) -> None:
|
|
pipeline = ACMSPipeline()
|
|
plan_id = str(ULID())
|
|
budget = ContextBudget(max_tokens=4096)
|
|
payload = pipeline.assemble(
|
|
plan_id=plan_id,
|
|
fragments=context.m5_fragments,
|
|
budget=budget,
|
|
context_view=context.m5_view,
|
|
)
|
|
context.m5_pipeline_payload = payload
|
|
context.m5_pipeline = pipeline
|
|
|
|
|
|
@then("the m5 smoke pipeline should return {count:d} fragments")
|
|
def step_m5_pipeline_fragment_count(context: Context, count: int) -> None:
|
|
assert len(context.m5_pipeline_payload.fragments) == count, (
|
|
f"Expected {count}, got {len(context.m5_pipeline_payload.fragments)}"
|
|
)
|
|
|
|
|
|
@then("the m5 smoke pipeline enforcement result should have {count:d} violation(s)")
|
|
def step_m5_pipeline_enforcement_violations(context: Context, count: int) -> None:
|
|
result = context.m5_pipeline.last_enforcement_result
|
|
assert result is not None, "No enforcement result on pipeline"
|
|
assert len(result.violations) == count, (
|
|
f"Expected {count} violations, got {len(result.violations)}"
|
|
)
|