fix(cli): compute real impact counts in agents actor remove command #3463

Merged
freemo merged 1 commits from fix/actor-remove-impact-computation into master 2026-04-05 18:18:08 +00:00
5 changed files with 224 additions and 11 deletions
@@ -0,0 +1,23 @@
Feature: agents actor remove Impact section shows real counts
As a user removing an actor
I want the Impact panel to show real counts of affected sessions, plans, and actions
So that I can verify the actual impact before and after removal
Background:
Given an actor CLI runner
Scenario: Impact panel shows non-zero counts when actor is referenced
Given an actor "local/my-actor" referenced by 2 sessions, 1 active plan, and 3 actions
When I run actor remove for "local/my-actor"
Then the actor remove should succeed
And the Impact panel should show "2 affected" for Sessions
And the Impact panel should show "1 affected" for Active Plans
And the Impact panel should show "3" for Actions Referencing
Scenario: Impact panel shows zero counts when actor has no references
Given an actor "local/unused-actor" with no references
When I run actor remove for "local/unused-actor"
Then the actor remove should succeed
And the Impact panel should show "0 affected" for Sessions
And the Impact panel should show "0 affected" for Active Plans
And the Impact panel should show "0" for Actions Referencing
+15 -3
View File
@@ -947,10 +947,14 @@ def step_impl(context):
@when("I run actor remove successfully")
def step_impl(context):
with patch("cleveragents.cli.commands.actor._get_services") as mock_get_services:
with (
patch("cleveragents.cli.commands.actor._get_services") as mock_get_services,
patch("cleveragents.cli.commands.actor._compute_actor_impact") as mock_impact,
):
mock_actor_service = MagicMock()
mock_actor_registry = MagicMock()
mock_get_services.return_value = (mock_actor_service, mock_actor_registry)
mock_impact.return_value = (0, 0, 0)
context.result = context.runner.invoke(actor_app, ["remove", "local/removable"])
context.mock_actor_registry = mock_actor_registry
@@ -958,11 +962,15 @@ def step_impl(context):
@when("I run actor remove and it fails validation")
def step_impl(context):
with patch("cleveragents.cli.commands.actor._get_services") as mock_get_services:
with (
patch("cleveragents.cli.commands.actor._get_services") as mock_get_services,
patch("cleveragents.cli.commands.actor._compute_actor_impact") as mock_impact,
):
mock_actor_service = MagicMock()
mock_actor_registry = MagicMock()
mock_actor_registry.remove_actor.side_effect = ValidationError("invalid")
mock_get_services.return_value = (mock_actor_service, mock_actor_registry)
mock_impact.return_value = (0, 0, 0)
context.result = context.runner.invoke(actor_app, ["remove", "local/removable"])
context.mock_actor_registry = mock_actor_registry
@@ -971,9 +979,13 @@ def step_impl(context):
@when("I run actor remove via service path")
def step_impl(context):
with patch("cleveragents.cli.commands.actor._get_services") as mock_get_services:
with (
patch("cleveragents.cli.commands.actor._get_services") as mock_get_services,
patch("cleveragents.cli.commands.actor._compute_actor_impact") as mock_impact,
):
actor_service = MagicMock()
mock_get_services.return_value = (actor_service, None)
mock_impact.return_value = (0, 0, 0)
context.result = context.runner.invoke(actor_app, ["remove", "local/removable"])
+5 -1
View File
@@ -297,9 +297,13 @@ def step_add_yaml_valid(context: Any) -> None:
@when("I run actor remove with namespaced name")
def step_remove_namespaced(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
with (
patch("cleveragents.cli.commands.actor._get_services") as mock_svc,
patch("cleveragents.cli.commands.actor._compute_actor_impact") as mock_impact,
):
mock_registry = MagicMock()
mock_svc.return_value = (MagicMock(), mock_registry)
mock_impact.return_value = (0, 0, 0)
context.result = context.runner.invoke(
actor_app, ["remove", "local/my-custom-actor"]
)
+106
View File
@@ -0,0 +1,106 @@
# pyright: reportRedeclaration=false
"""Step definitions for actor remove impact computation feature."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner # noqa: F401 - used via context.runner
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.domain.models.core.actor import Actor
def _make_actor(
*,
name: str = "local/test-actor",
provider: str = "test-provider",
model: str = "test-model",
) -> Actor:
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob={},
config_hash="abcdef12",
graph_descriptor=None,
unsafe=False,
is_default=False,
is_built_in=False,
)
@given(
'an actor "{actor_name}" referenced by {sessions:d} sessions, '
"{plans:d} active plan, and {actions:d} actions"
)
def step_actor_with_references(
context: Any, actor_name: str, sessions: int, plans: int, actions: int
) -> None:
"""Set up context with an actor that has known reference counts."""
context.actor_name = actor_name
context.expected_sessions = sessions
context.expected_plans = plans
context.expected_actions = actions
@given('an actor "{actor_name}" with no references')
def step_actor_no_references(context: Any, actor_name: str) -> None:
"""Set up context with an actor that has no references."""
context.actor_name = actor_name
context.expected_sessions = 0
context.expected_plans = 0
context.expected_actions = 0
@when('I run actor remove for "{actor_name}"')
def step_run_actor_remove(context: Any, actor_name: str) -> None:
"""Invoke the actor remove command with mocked services and impact counts."""
sessions = getattr(context, "expected_sessions", 0)
plans = getattr(context, "expected_plans", 0)
actions = getattr(context, "expected_actions", 0)
mock_actor = _make_actor(name=actor_name)
with (
patch("cleveragents.cli.commands.actor._get_services") as mock_get_services,
patch("cleveragents.cli.commands.actor._compute_actor_impact") as mock_impact,
):
mock_registry = MagicMock()
mock_registry.get_actor.return_value = mock_actor
mock_get_services.return_value = (MagicMock(), mock_registry)
mock_impact.return_value = (sessions, plans, actions)
context.result = context.runner.invoke(actor_app, ["remove", actor_name])
context.mock_impact = mock_impact
context.mock_registry = mock_registry
@then('the Impact panel should show "{text}" for Sessions')
def step_impact_sessions(context: Any, text: str) -> None:
"""Assert the Sessions line in the Impact panel shows the expected text."""
output = context.result.output
assert text in output, (
f"Expected '{text}' in output for Sessions, but got:\n{output}"
)
@then('the Impact panel should show "{text}" for Active Plans')
def step_impact_plans(context: Any, text: str) -> None:
"""Assert the Active Plans line in the Impact panel shows the expected text."""
output = context.result.output
assert text in output, (
f"Expected '{text}' in output for Active Plans, but got:\n{output}"
)
@then('the Impact panel should show "{text}" for Actions Referencing')
def step_impact_actions(context: Any, text: str) -> None:
"""Assert the Actions Referencing line in the Impact panel shows the expected text."""
output = context.result.output
assert text in output, (
f"Expected '{text}' in output for Actions Referencing, but got:\n{output}"
)
+75 -7
View File
@@ -209,6 +209,73 @@ def _get_services():
return actor_service, actor_registry
def _compute_actor_impact(actor_name: str) -> tuple[int, int, int]:
"""Compute the real impact counts for removing an actor.
Queries the database to count:
- Sessions that reference the actor via ``actor_name``
- Active plans (queued or processing) that reference the actor via
``strategy_actor`` or ``execution_actor``
- Actions configured to use the actor via any actor field
Args:
actor_name: The namespaced actor name (e.g. ``local/my-actor``).
Returns:
A 3-tuple of ``(session_count, active_plan_count, action_count)``.
Returns ``(0, 0, 0)`` if any query fails, to avoid blocking removal.
"""
container = get_container()
# --- Sessions referencing this actor ---
session_count = 0
try:
session_service = container.session_service()
sessions = session_service.list()
session_count = sum(1 for s in sessions if s.actor_name == actor_name)
except Exception: # pragma: no cover - defensive; DB may be unavailable
pass
# --- Active lifecycle plans referencing this actor ---
active_plan_count = 0
try:
uow = container.unit_of_work()
with uow.transaction() as ctx:
plans = ctx.lifecycle_plans.list_plans(limit=10000)
active_states = {"queued", "processing"}
active_plan_count = sum(
1
for p in plans
if p.processing_state in active_states
and (p.strategy_actor == actor_name or p.execution_actor == actor_name)
)
except Exception: # pragma: no cover - defensive; DB may be unavailable
pass
# --- Actions configured to use this actor ---
action_count = 0
try:
lifecycle_service = container.plan_lifecycle_service()
actions = lifecycle_service.list_actions()
action_count = sum(
1
for a in actions
if actor_name
in (
a.strategy_actor,
a.execution_actor,
getattr(a, "review_actor", None),
getattr(a, "apply_actor", None),
getattr(a, "estimation_actor", None),
getattr(a, "invariant_actor", None),
)
)
except Exception: # pragma: no cover - defensive; DB may be unavailable
pass
return session_count, active_plan_count, action_count
def _load_config(config_path: Path | None) -> dict[str, Any] | None:
"""Load a JSON or YAML config file if provided."""
@@ -672,6 +739,10 @@ def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) ->
actor_provider = "unknown"
actor_model = "unknown"
# Compute impact counts before removal so the display reflects
# what was actually affected at the time of removal.
session_count, active_plan_count, action_count = _compute_actor_impact(name)
# Perform the removal
if registry:
registry.remove_actor(name)
@@ -687,14 +758,11 @@ def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) ->
actor_panel = Panel(actor_info, title="Actor Removed", border_style="green")
console.print(actor_panel)
# Display Impact panel
# Note: Computing actual impact requires deep integration
# with session/plan subsystems
# For now, display panels with conservative estimates (0 affected)
# Display Impact panel with real computed counts
impact_info = (
"[yellow]Sessions:[/yellow] 0 affected\n"
"[yellow]Active Plans:[/yellow] 0 affected\n"
"[yellow]Actions Referencing:[/yellow] 0"
f"[yellow]Sessions:[/yellow] {session_count} affected\n"
f"[yellow]Active Plans:[/yellow] {active_plan_count} affected\n"
f"[yellow]Actions Referencing:[/yellow] {action_count}"
)
impact_panel = Panel(impact_info, title="Impact", border_style="yellow")
console.print(impact_panel)