Compare commits

...

3 Commits

Author SHA1 Message Date
HAL9000 03ed12cf0d fix(cli): address reviewer feedback on actor remove --format option
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 53s
CI / quality (pull_request) Successful in 47s
CI / typecheck (pull_request) Successful in 1m3s
CI / security (pull_request) Successful in 1m1s
CI / build (pull_request) Successful in 35s
CI / push-validation (pull_request) Successful in 28s
CI / helm (pull_request) Successful in 37s
CI / e2e_tests (pull_request) Successful in 3m32s
CI / integration_tests (pull_request) Successful in 5m6s
CI / unit_tests (pull_request) Successful in 5m37s
CI / docker (pull_request) Successful in 1m35s
CI / coverage (pull_request) Successful in 9m46s
CI / status-check (pull_request) Successful in 4s
CI / benchmark-regression (pull_request) Successful in 58m7s
- Validate --format argument before any side effects; raise typer.BadParameter
  with a clear message for unsupported format values (fail-fast principle)
- Pass normalised fmt_value (lowercased) to format_output instead of raw fmt
  to ensure consistent behaviour regardless of input casing
- Rewrite robot/helper_actor_remove_cli.py to exercise the real CLI end-to-end
  via subprocess (no mocking); seeds a test actor via agents actor add, then
  removes it with --format json and validates the JSON envelope

ISSUES CLOSED: #6491
2026-05-05 19:09:40 +00:00
HAL9000 bed3993cca test(cli): cover actor remove format regression (#6491)
CI / quality (pull_request) Successful in 42s
CI / push-validation (pull_request) Successful in 29s
CI / lint (pull_request) Successful in 44s
CI / build (pull_request) Successful in 30s
CI / helm (pull_request) Successful in 44s
CI / typecheck (pull_request) Successful in 1m5s
CI / security (pull_request) Successful in 1m7s
CI / e2e_tests (pull_request) Successful in 3m50s
CI / integration_tests (pull_request) Successful in 4m11s
CI / unit_tests (pull_request) Successful in 5m34s
CI / docker (pull_request) Successful in 1m19s
CI / coverage (pull_request) Successful in 13m3s
CI / status-check (pull_request) Successful in 2s
CI / benchmark-publish (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
Add Robot regression coverage for `actor remove --format json`, document the flag in the CLI synopsis, and record the fix in the changelog.\n\nISSUES CLOSED: #6491
2026-04-11 01:01:58 +00:00
HAL9000 43847e7506 fix(cli): add --format option to actor remove command (#6491)
ISSUES CLOSED: #6491
2026-04-11 01:01:58 +00:00
7 changed files with 305 additions and 2 deletions
+9
View File
@@ -129,6 +129,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`pr-merge-pool-supervisor` to the product-builder's supervisor launch list (18 total
supervisors). Updated all numeric references, pre-flight checklists, and validation logic.
---
### Fixed
- **CLI (`agents actor remove`)** (#6491): Restores output parity with the
other actor commands by honoring `--format`/`-f` for JSON/YAML/plain/Rich
envelopes. Adds a Robot Framework regression test to assert the JSON
envelope structure and updates the CLI synopsis in `docs/specification.md`
to document the option.
---
## [3.8.0] — 2026-04-05
+1 -1
View File
@@ -277,7 +277,7 @@ The following standards are integrated into the architecture:
[(<span style="color: magenta;"><span style="color: cyan;">--temperature</span>|<span style="color: yellow;">-t</span></span>) <span style="color: #66cc66;">&lt;TEMP&gt;</span>] [<span style="color: cyan;">--allow-rxpy-in-run-mode</span>]
[<span style="color: cyan;">--skill</span> <span style="color: #66cc66;">&lt;SKILL&gt;</span>]... <span style="color: #66cc66;">&lt;NAME&gt;</span> <span style="color: #66cc66;">&lt;PROMPT&gt;</span>
<span style="color: cyan; font-weight: 600;">agents</span> actor add <span style="color: cyan;">--config</span>|<span style="color: yellow;">-c</span> <span style="color: #66cc66;">&lt;FILE&gt;</span> [<span style="color: cyan;">--update</span>]
<span style="color: cyan; font-weight: 600;">agents</span> actor remove <span style="color: #66cc66;">&lt;NAME&gt;</span>
<span style="color: cyan; font-weight: 600;">agents</span> actor remove [<span style="color: cyan;">--format</span> <span style="color: #66cc66;">&lt;FORMAT&gt;</span>] <span style="color: #66cc66;">&lt;NAME&gt;</span>
<span style="color: cyan; font-weight: 600;">agents</span> actor list
<span style="color: cyan; font-weight: 600;">agents</span> actor show <span style="color: #66cc66;">&lt;NAME&gt;</span>
<span style="color: cyan; font-weight: 600;">agents</span> actor context remove [<span style="color: cyan;">--yes</span>|<span style="color: yellow;">-y</span>] (<span style="color: magenta;"><span style="color: cyan;">--all</span>|<span style="color: yellow;">-a</span>|<span style="color: #66cc66;">&lt;NAME&gt;</span></span>)
+5
View File
@@ -61,6 +61,11 @@ Feature: Actor CLI YAML-first alignment
When I run actor remove with namespaced name
Then the actor remove should succeed for namespaced name
Scenario: Actor remove outputs JSON format
Given an actor CLI runner
When I run actor remove with format json
Then the actor remove output should be valid JSON envelope
Scenario: Actor update outputs JSON format
Given an actor CLI runner
When I run actor update with format json
+57
View File
@@ -337,6 +337,63 @@ def step_remove_namespaced_ok(context: Any) -> None:
)
@when("I run actor remove with format json")
def step_remove_format_json(context: Any) -> None:
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_service = MagicMock()
actor = _make_actor(
name="local/remove-json",
provider="json-provider",
model="gpt-json",
)
mock_registry.get_actor.return_value = actor
mock_impact.return_value = (2, 1, 3)
mock_svc.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
["remove", actor.name, "--format", "json"],
)
context.mock_actor_registry = mock_registry
context.actor = actor
context.impact_counts = (2, 1, 3)
@then("the actor remove output should be valid JSON envelope")
def step_remove_json_valid(context: Any) -> None:
assert context.result.exit_code == 0
parsed = json.loads(context.result.output.strip())
assert _ENVELOPE_KEYS.issubset(parsed.keys())
assert parsed["command"] == f"agents actor remove {context.actor.name}"
assert parsed["status"] == "ok"
assert parsed["exit_code"] == 0
data = _unwrap_envelope(parsed)
assert isinstance(data, dict)
actor_data = data.get("actor_removed", {})
assert actor_data.get("name") == context.actor.name
assert actor_data.get("provider") == context.actor.provider
assert actor_data.get("model") == context.actor.model
impact = data.get("impact", {})
expected_sessions, expected_plans, expected_actions = context.impact_counts
assert impact.get("sessions") == expected_sessions
assert impact.get("active_plans") == expected_plans
assert impact.get("actions_referencing") == expected_actions
cleanup = data.get("cleanup", {})
assert cleanup.get("config") == "kept on disk"
assert cleanup.get("contexts") == "0 orphaned"
messages = parsed.get("messages", [])
assert messages, "expected messages in envelope"
first_message = messages[0]
assert first_message.get("level") == "ok"
assert "Actor removed" in first_message.get("text", "")
context.mock_actor_registry.remove_actor.assert_called_once_with(context.actor.name)
# ------------------------------------------------------------------
# Update with --format
# ------------------------------------------------------------------
+18
View File
@@ -0,0 +1,18 @@
*** Settings ***
Documentation Integration tests for actor remove CLI format output
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_actor_remove_cli.py
*** Test Cases ***
Actor Remove Format JSON Emits Spec Envelope
[Documentation] Verify that ``actor remove --format json`` emits a spec-compliant envelope
[Tags] actor_remove_cli_format
${result}= Run Process ${PYTHON} ${HELPER} remove-json cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-remove-json-format-ok
+164
View File
@@ -0,0 +1,164 @@
"""Helper script for Robot integration tests covering ``actor remove --format`` output.
Exercises the real ``agents`` CLI via subprocess — no mocking of any kind.
A test actor is seeded via ``agents actor add``, then removed via
``agents actor remove --format json``, and the resulting JSON envelope is
validated against the spec.
Usage::
python helper_actor_remove_cli.py <command>
Where *command* is one of:
- ``remove-json`` — seed an actor, remove it with ``--format json``, validate envelope
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
# Ensure src is importable when run from workspace root
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Ensure robot/ is on the import path for helper_e2e_common.
_ROBOT = str(Path(__file__).resolve().parent)
if _ROBOT not in sys.path:
sys.path.insert(0, _ROBOT)
from helper_e2e_common import cleanup_workspace, run_cli, setup_workspace # noqa: E402
_ACTOR_NAME = "local/robot-remove-actor"
_ACTOR_CONFIG: dict[str, object] = {
"name": _ACTOR_NAME,
"provider": "openai",
"model": "gpt-4",
}
def _write_actor_config(workspace: str) -> str:
"""Write actor config JSON to a temp file and return its path."""
config_path = os.path.join(workspace, "robot_remove_actor.json")
with open(config_path, "w", encoding="utf-8") as fh:
json.dump(_ACTOR_CONFIG, fh)
return config_path
def test_remove_format_json() -> None:
"""Seed an actor via the real CLI, remove it with ``--format json``.
Validates the resulting JSON envelope against the spec.
"""
workspace = setup_workspace(prefix="robot_actor_remove_")
try:
config_path = _write_actor_config(workspace)
# Step 1: Add the actor using the real CLI.
add_result = run_cli(
"actor",
"add",
_ACTOR_NAME,
"--config",
config_path,
workspace=workspace,
)
assert add_result.returncode == 0, (
f"actor add failed (rc={add_result.returncode}):\n"
f"stdout: {add_result.stdout}\nstderr: {add_result.stderr}"
)
# Step 2: Remove the actor with --format json using the real CLI.
remove_result = run_cli(
"actor",
"remove",
_ACTOR_NAME,
"--format",
"json",
workspace=workspace,
)
assert remove_result.returncode == 0, (
f"actor remove --format json failed (rc={remove_result.returncode}):\n"
f"stdout: {remove_result.stdout}\nstderr: {remove_result.stderr}"
)
# Step 3: Parse and validate the JSON envelope.
output = remove_result.stdout.strip()
assert output, (
f"actor remove --format json produced no output.\n"
f"stderr: {remove_result.stderr}"
)
payload = json.loads(output)
assert payload["command"] == f"agents actor remove {_ACTOR_NAME}", (
f"Unexpected command field: {payload.get('command')!r}"
)
assert payload["status"] == "ok", (
f"Unexpected status: {payload.get('status')!r}"
)
assert payload["exit_code"] == 0, (
f"Unexpected exit_code: {payload.get('exit_code')!r}"
)
data = payload["data"]
removed = data.get("actor_removed", {})
assert removed.get("name") == _ACTOR_NAME, (
f"actor_removed.name mismatch: {removed.get('name')!r}"
)
assert removed.get("provider") == _ACTOR_CONFIG["provider"], (
f"actor_removed.provider mismatch: {removed.get('provider')!r}"
)
assert removed.get("model") == _ACTOR_CONFIG["model"], (
f"actor_removed.model mismatch: {removed.get('model')!r}"
)
impact = data.get("impact", {})
assert "sessions" in impact, f"Missing 'sessions' in impact: {impact}"
assert "active_plans" in impact, f"Missing 'active_plans' in impact: {impact}"
assert "actions_referencing" in impact, (
f"Missing 'actions_referencing' in impact: {impact}"
)
cleanup = data.get("cleanup", {})
assert cleanup.get("config") == "kept on disk", (
f"cleanup.config mismatch: {cleanup.get('config')!r}"
)
assert "contexts" in cleanup, f"Missing 'contexts' in cleanup: {cleanup}"
messages = payload.get("messages", [])
assert messages, f"Expected non-empty messages list, got: {messages}"
assert messages[0].get("level") == "ok", (
f"Unexpected message level: {messages[0].get('level')!r}"
)
assert "Actor removed" in messages[0].get("text", ""), (
f"Expected 'Actor removed' in message text: {messages[0].get('text')!r}"
)
print("actor-remove-json-format-ok")
finally:
cleanup_workspace(workspace)
def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else "remove-json"
dispatch: dict[str, object] = {
"remove-json": test_remove_format_json,
}
handler = dispatch.get(command)
if handler is None:
print(f"Unknown command: {command}", file=sys.stderr)
sys.exit(1)
assert callable(handler)
handler()
if __name__ == "__main__":
main()
+51 -1
View File
@@ -779,12 +779,28 @@ def update(
@app.command()
def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) -> None:
def remove(
name: Annotated[str, typer.Argument(help="Actor name to remove")],
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = OutputFormat.RICH.value,
) -> None:
"""Remove a custom actor.
Specify the namespaced name (e.g. ``local/my-actor``).
"""
# Validate --format argument first; fail fast before any side effects.
fmt_value = fmt.lower()
_valid_formats = {f.value for f in OutputFormat}
if fmt_value not in _valid_formats:
raise typer.BadParameter(
f"Invalid format {fmt!r}. "
f"Supported values: {', '.join(sorted(_valid_formats))}",
param_hint="'--format'",
)
service, registry = _get_services()
try:
# Get actor details before removal for display
@@ -807,6 +823,40 @@ def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) ->
else:
service.remove_actor(name)
command_name = f"agents actor remove {name}"
payload = {
"actor_removed": {
"name": name,
"provider": actor_provider,
"model": actor_model,
},
"impact": {
"sessions": session_count,
"active_plans": active_plan_count,
"actions_referencing": action_count,
},
"cleanup": {
"config": "kept on disk",
# NOTE: context-cleanup count is deferred; always 0 for now.
# Follow-up: implement dynamic orphaned-context detection.
"contexts": "0 orphaned",
},
}
messages = [{"level": "ok", "text": "Actor removed"}]
if fmt_value != OutputFormat.RICH.value:
rendered = format_output(
payload,
fmt_value,
command=command_name,
status="ok",
exit_code=0,
messages=messages,
)
if rendered:
console.print(rendered)
return
# Display Actor Removed panel
actor_info = (
f"[cyan bold]Name:[/cyan bold] {name}\n"