fix(cli): add missing Type field, Config, Capabilities, and Tools panels to actor add rich output

Extended `_print_actor()` to render the full spec-required output for
`agents actor add`: the Actor Added panel now includes a Type field, and
three additional panels (Config, Capabilities, Tools) plus a success
status line are rendered when `show_add_panels=True`.

- Add `Type:` field to the Actor Added panel (from `config_blob["type"]`)
- Implement Config panel: Path, Hash, Options count, Nodes count, Edges count
- Implement Capabilities panel: bulleted list (rendered only when non-empty)
- Implement Tools panel: Rich table with Tool, Read-Only, Safe columns
  (rendered only when non-empty; string tool entries default to yes/yes)
- Add `✓ OK Actor added` success status line after all panels
- Pass `config_path` and `show_add_panels=True` from `add()` command
- Add Behave BDD scenarios covering each new panel and the success line
- Add Robot Framework integration test verifying the full rich output

ISSUES CLOSED: #1499
This commit is contained in:
2026-04-03 00:23:58 +00:00
parent 074c472e36
commit d430d671a0
5 changed files with 637 additions and 8 deletions
+49
View File
@@ -0,0 +1,49 @@
Feature: actor add rich output panels
As a user of the CleverAgents CLI
I want `agents actor add` to display all spec-required panels
So that I can see the full details of a newly added actor
Scenario: Actor Added panel includes Type field
Given an actor add CLI runner with a typed config
When I run actor add with the typed config
Then the actor added panel should contain the Type field
Scenario: Config panel is rendered after actor add
Given an actor add CLI runner with a typed config
When I run actor add with the typed config
Then the output should contain a Config panel
Scenario: Config panel shows Path, Hash, Options, Nodes, Edges
Given an actor add CLI runner with a typed config
When I run actor add with the typed config
Then the Config panel should show Path Hash Options Nodes and Edges
Scenario: Capabilities panel is rendered when capabilities are present
Given an actor add CLI runner with capabilities in config
When I run actor add with the capabilities config
Then the output should contain a Capabilities panel with the capability list
Scenario: Tools panel is rendered when tools are present
Given an actor add CLI runner with tools in config
When I run actor add with the tools config
Then the output should contain a Tools panel with tool rows
Scenario: Success status line is printed after all panels
Given an actor add CLI runner with a typed config
When I run actor add with the typed config
Then the output should end with the actor added success line
Scenario: No Capabilities panel when capabilities list is empty
Given an actor add CLI runner with a typed config
When I run actor add with the typed config
Then the output should not contain a Capabilities panel
Scenario: No Tools panel when tools list is empty
Given an actor add CLI runner with a typed config
When I run actor add with the typed config
Then the output should not contain a Tools panel
Scenario: Non-rich format skips all extra panels
Given an actor add CLI runner with a typed config
When I run actor add with the typed config in json format
Then the output should be valid JSON without panels
@@ -0,0 +1,274 @@
"""Step definitions for actor add rich output panels feature."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.domain.models.core.actor import Actor
def _make_actor(
*,
name: str = "local/rich-actor",
provider: str = "openai",
model: str = "gpt-4",
config: dict[str, Any] | None = None,
graph_descriptor: dict[str, Any] | None = None,
unsafe: bool = False,
is_default: bool = False,
is_built_in: bool = False,
) -> Actor:
blob = config or {}
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
graph_descriptor=graph_descriptor,
unsafe=unsafe,
is_built_in=is_built_in,
is_default=is_default,
)
def _register_cleanup(context: Any, path: Path) -> None:
context._cleanup_handlers.append(lambda: path.unlink(missing_ok=True))
@given("an actor add CLI runner with a typed config")
def step_impl(context: Any) -> None:
context.runner = CliRunner()
context.actor_config_data = {
"name": "local/rich-actor",
"provider": "openai",
"model": "gpt-4",
"type": "graph",
}
with tempfile.NamedTemporaryFile(
delete=False, suffix=".json", mode="w", encoding="utf-8"
) as handle:
json.dump(context.actor_config_data, handle)
handle.flush()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
graph_descriptor = {
"nodes": [{"id": "n1"}, {"id": "n2"}, {"id": "n3"}],
"edges": [{"from": "n1", "to": "n2"}, {"from": "n2", "to": "n3"}],
}
context.mock_actor = _make_actor(
config=context.actor_config_data,
graph_descriptor=graph_descriptor,
)
@given("an actor add CLI runner with capabilities in config")
def step_impl(context: Any) -> None:
context.runner = CliRunner()
context.actor_config_data = {
"name": "local/cap-actor",
"provider": "openai",
"model": "gpt-4",
"type": "llm",
"capabilities": ["code review", "diff summarization", "lint guidance"],
}
with tempfile.NamedTemporaryFile(
delete=False, suffix=".json", mode="w", encoding="utf-8"
) as handle:
json.dump(context.actor_config_data, handle)
handle.flush()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
context.mock_actor = _make_actor(config=context.actor_config_data)
@given("an actor add CLI runner with tools in config")
def step_impl(context: Any) -> None:
context.runner = CliRunner()
context.actor_config_data = {
"name": "local/tool-actor",
"provider": "openai",
"model": "gpt-4",
"type": "llm",
"tools": [
{"name": "read_file", "read_only": True, "safe": True},
{"name": "write_file", "read_only": False, "safe": False},
],
}
with tempfile.NamedTemporaryFile(
delete=False, suffix=".json", mode="w", encoding="utf-8"
) as handle:
json.dump(context.actor_config_data, handle)
handle.flush()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
context.mock_actor = _make_actor(config=context.actor_config_data)
@when("I run actor add with the typed config")
def step_impl(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = context.mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
["add", "--config", str(context.actor_config_path)],
)
@when("I run actor add with the capabilities config")
def step_impl(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = context.mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
["add", "--config", str(context.actor_config_path)],
)
@when("I run actor add with the tools config")
def step_impl(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = context.mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
["add", "--config", str(context.actor_config_path)],
)
@when("I run actor add with the typed config in json format")
def step_impl(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = context.mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
["add", "--config", str(context.actor_config_path), "--format", "json"],
)
@then("the actor added panel should contain the Type field")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
assert "Type:" in context.result.output, (
f"Expected 'Type:' in output:\n{context.result.output}"
)
assert "graph" in context.result.output, (
f"Expected actor type value 'graph' in output:\n{context.result.output}"
)
@then("the output should contain a Config panel")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
assert "Config" in context.result.output, (
f"Expected 'Config' panel in output:\n{context.result.output}"
)
@then("the Config panel should show Path Hash Options Nodes and Edges")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
output = context.result.output
for field in ("Path:", "Hash:", "Options:", "Nodes:", "Edges:"):
assert field in output, f"Expected '{field}' in Config panel output:\n{output}"
@then("the output should contain a Capabilities panel with the capability list")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
output = context.result.output
assert "Capabilities" in output, (
f"Expected 'Capabilities' panel in output:\n{output}"
)
for cap in context.actor_config_data.get("capabilities", []):
assert cap in output, f"Expected capability '{cap}' in output:\n{output}"
@then("the output should contain a Tools panel with tool rows")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
output = context.result.output
assert "Tools" in output, f"Expected 'Tools' panel in output:\n{output}"
for tool_entry in context.actor_config_data.get("tools", []):
if isinstance(tool_entry, dict):
tool_name = tool_entry.get("name", "")
else:
tool_name = str(tool_entry)
assert tool_name in output, f"Expected tool '{tool_name}' in output:\n{output}"
@then("the output should end with the actor added success line")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
assert "✓ OK" in context.result.output, (
f"Expected '✓ OK' in output:\n{context.result.output}"
)
assert "Actor added" in context.result.output, (
f"Expected 'Actor added' in output:\n{context.result.output}"
)
@then("the output should not contain a Capabilities panel")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
assert "Capabilities" not in context.result.output, (
f"Did not expect 'Capabilities' panel in output:\n{context.result.output}"
)
@then("the output should not contain a Tools panel")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
assert "Tools" not in context.result.output, (
f"Did not expect 'Tools' panel in output:\n{context.result.output}"
)
@then("the output should be valid JSON without panels")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}, output={context.result.output}"
)
output = context.result.output.strip()
data = json.loads(output)
assert isinstance(data, dict), f"Expected JSON object, got: {type(data)}"
assert "name" in data, f"Expected 'name' field in JSON output: {data}"
assert "Config" not in output, (
f"Did not expect 'Config' panel in JSON output:\n{output}"
)
assert "✓ OK" not in output, (
f"Did not expect success line in JSON output:\n{output}"
)
+49
View File
@@ -0,0 +1,49 @@
*** Settings ***
Documentation Integration tests for actor add rich output panels
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_actor_add_rich_output.py
*** Test Cases ***
Actor Add Rich Output Contains Type Field
[Documentation] Verify that ``actor add`` rich output includes the Type field
${result}= Run Process ${PYTHON} ${HELPER} add-type-field cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-add-type-field-ok
Actor Add Rich Output Contains Config Panel
[Documentation] Verify that ``actor add`` rich output includes the Config panel
${result}= Run Process ${PYTHON} ${HELPER} add-config-panel cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-add-config-panel-ok
Actor Add Rich Output Contains Capabilities Panel
[Documentation] Verify that ``actor add`` rich output includes the Capabilities panel
${result}= Run Process ${PYTHON} ${HELPER} add-capabilities-panel cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-add-capabilities-panel-ok
Actor Add Rich Output Contains Tools Panel
[Documentation] Verify that ``actor add`` rich output includes the Tools panel
${result}= Run Process ${PYTHON} ${HELPER} add-tools-panel cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-add-tools-panel-ok
Actor Add Rich Output Contains Success Line
[Documentation] Verify that ``actor add`` rich output ends with the success status line
${result}= Run Process ${PYTHON} ${HELPER} add-success-line cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-add-success-line-ok
+196
View File
@@ -0,0 +1,196 @@
"""Helper script for actor add rich output panels Robot test."""
from __future__ import annotations
import json
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from typer.testing import CliRunner
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.domain.models.core.actor import Actor
def _make_actor(
*,
name: str = "local/robot-add-actor",
provider: str = "openai",
model: str = "gpt-4",
config: dict[str, Any] | None = None,
graph_descriptor: dict[str, Any] | None = None,
unsafe: bool = False,
is_default: bool = False,
is_built_in: bool = False,
) -> Actor:
blob = config or {}
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
graph_descriptor=graph_descriptor,
unsafe=unsafe,
is_built_in=is_built_in,
is_default=is_default,
)
def _run_actor_add(
config_data: dict[str, Any],
actor: Actor,
) -> str:
"""Invoke actor add with a temp config file and return the output."""
runner = CliRunner()
with tempfile.NamedTemporaryFile(
delete=False, suffix=".json", mode="w", encoding="utf-8"
) as handle:
json.dump(config_data, handle)
handle.flush()
config_path = Path(handle.name)
try:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = actor
mock_svc.return_value = (MagicMock(), registry)
result = runner.invoke(
actor_app,
["add", "--config", str(config_path)],
)
finally:
config_path.unlink(missing_ok=True)
assert result.exit_code == 0, (
f"actor add failed (exit_code={result.exit_code}):\n{result.output}"
)
return result.output
def test_add_type_field() -> None:
"""Verify actor add rich output includes the Type field."""
config_data: dict[str, Any] = {
"name": "local/robot-add-actor",
"provider": "openai",
"model": "gpt-4",
"type": "graph",
}
actor = _make_actor(config=config_data)
output = _run_actor_add(config_data, actor)
assert "Type:" in output, f"Expected 'Type:' in output:\n{output}"
assert "graph" in output, f"Expected type value 'graph' in output:\n{output}"
print("actor-add-type-field-ok")
def test_add_config_panel() -> None:
"""Verify actor add rich output includes the Config panel with all fields."""
config_data: dict[str, Any] = {
"name": "local/robot-add-actor",
"provider": "openai",
"model": "gpt-4",
"type": "graph",
"options": {"temperature": 0.5, "max_tokens": 256},
}
graph_descriptor = {
"nodes": [{"id": "n1"}, {"id": "n2"}],
"edges": [{"from": "n1", "to": "n2"}],
}
actor = _make_actor(config=config_data, graph_descriptor=graph_descriptor)
output = _run_actor_add(config_data, actor)
for field in ("Config", "Path:", "Hash:", "Options:", "Nodes:", "Edges:"):
assert field in output, f"Expected '{field}' in output:\n{output}"
# Verify counts
assert "2" in output, f"Expected node count '2' in output:\n{output}"
assert "1" in output, f"Expected edge count '1' in output:\n{output}"
print("actor-add-config-panel-ok")
def test_add_capabilities_panel() -> None:
"""Verify actor add rich output includes the Capabilities panel."""
config_data: dict[str, Any] = {
"name": "local/robot-add-actor",
"provider": "openai",
"model": "gpt-4",
"type": "llm",
"capabilities": ["code review", "diff summarization", "lint guidance"],
}
actor = _make_actor(config=config_data)
output = _run_actor_add(config_data, actor)
assert "Capabilities" in output, (
f"Expected 'Capabilities' panel in output:\n{output}"
)
for cap in config_data["capabilities"]:
assert cap in output, f"Expected capability '{cap}' in output:\n{output}"
print("actor-add-capabilities-panel-ok")
def test_add_tools_panel() -> None:
"""Verify actor add rich output includes the Tools panel."""
config_data: dict[str, Any] = {
"name": "local/robot-add-actor",
"provider": "openai",
"model": "gpt-4",
"type": "llm",
"tools": [
{"name": "read_file", "read_only": True, "safe": True},
{"name": "search_files", "read_only": True, "safe": True},
{"name": "git_diff", "read_only": True, "safe": True},
],
}
actor = _make_actor(config=config_data)
output = _run_actor_add(config_data, actor)
assert "Tools" in output, f"Expected 'Tools' panel in output:\n{output}"
assert "Tool" in output, f"Expected 'Tool' column header in output:\n{output}"
assert "Read-Only" in output, f"Expected 'Read-Only' column in output:\n{output}"
assert "Safe" in output, f"Expected 'Safe' column in output:\n{output}"
for tool in config_data["tools"]:
assert tool["name"] in output, (
f"Expected tool '{tool['name']}' in output:\n{output}"
)
print("actor-add-tools-panel-ok")
def test_add_success_line() -> None:
"""Verify actor add rich output ends with the success status line."""
config_data: dict[str, Any] = {
"name": "local/robot-add-actor",
"provider": "openai",
"model": "gpt-4",
"type": "graph",
}
actor = _make_actor(config=config_data)
output = _run_actor_add(config_data, actor)
assert "✓ OK" in output, f"Expected '✓ OK' in output:\n{output}"
assert "Actor added" in output, f"Expected 'Actor added' in output:\n{output}"
print("actor-add-success-line-ok")
def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else "add-type-field"
dispatch = {
"add-type-field": test_add_type_field,
"add-config-panel": test_add_config_panel,
"add-capabilities-panel": test_add_capabilities_panel,
"add-tools-panel": test_add_tools_panel,
"add-success-line": test_add_success_line,
}
fn = dispatch.get(command)
if fn is None:
print(f"Unknown command: {command}", file=sys.stderr)
sys.exit(1)
fn()
if __name__ == "__main__":
main()
+69 -8
View File
@@ -340,25 +340,81 @@ def _print_actor(
actor: Actor,
title: str = "Actor",
fmt: str = OutputFormat.RICH.value,
config_path: Path | None = None,
show_add_panels: bool = False,
) -> None:
"""Print actor details in the requested format."""
"""Print actor details in the requested format.
When ``show_add_panels`` is True (used by the ``add`` command), renders
the full spec-required output: Actor Added panel (with Type field), Config
panel, Capabilities panel, Tools panel, and a success status line.
"""
if fmt != OutputFormat.RICH.value:
data = _actor_spec_dict(actor)
console.print(format_output(data, fmt))
return
blob = actor.config_blob or {}
actor_type = str(blob.get("type", ""))
details = (
f"[bold]Name:[/bold] {actor.name}\n"
f"[bold]Provider:[/bold] {actor.provider}\n"
f"[bold]Model:[/bold] {actor.model}\n"
f"[bold]Unsafe:[/bold] {'yes' if actor.unsafe else 'no'}\n"
f"[bold]Default:[/bold] {'yes' if actor.is_default else 'no'}\n"
f"[bold]Built-in:[/bold] {'yes' if actor.is_built_in else 'no'}\n"
f"[bold]Config Hash:[/bold] {actor.config_hash}\n"
f"[bold]Updated:[/bold] {actor.updated_at}"
f"[bold]Unsafe:[/bold] {'yes' if actor.unsafe else 'no'}\n"
f"[bold]Type:[/bold] {actor_type}"
)
console.print(Panel(details, title=title, expand=False))
if not show_add_panels:
return
# ── Config panel ──────────────────────────────────────────────────────────
path_str = str(config_path) if config_path is not None else ""
options_count = len(blob.get("options", {}) or {})
graph_descriptor = actor.graph_descriptor or {}
nodes_count = len(graph_descriptor.get("nodes", []) or [])
edges_count = len(graph_descriptor.get("edges", []) or [])
config_details = (
f"[bold]Path:[/bold] {path_str}\n"
f"[bold]Hash:[/bold] {actor.config_hash}\n"
f"[bold]Options:[/bold] {options_count}\n"
f"[bold]Nodes:[/bold] {nodes_count}\n"
f"[bold]Edges:[/bold] {edges_count}"
)
console.print(Panel(config_details, title="Config", expand=False))
# ── Capabilities panel ────────────────────────────────────────────────────
capabilities: list[str] = list(blob.get("capabilities", []) or [])
if capabilities:
cap_lines = "\n".join(f"- {cap}" for cap in capabilities)
console.print(Panel(cap_lines, title="Capabilities", expand=False))
# ── Tools panel ───────────────────────────────────────────────────────────
raw_tools: list[Any] = list(blob.get("tools", []) or [])
if raw_tools:
tools_table = Table(show_header=True, header_style="bold", box=None)
tools_table.add_column("Tool")
tools_table.add_column("Read-Only", justify="center")
tools_table.add_column("Safe", justify="center")
for tool_entry in raw_tools:
if isinstance(tool_entry, dict):
tool_name = str(tool_entry.get("name", ""))
read_only = "yes" if tool_entry.get("read_only", False) else "no"
safe = "yes" if tool_entry.get("safe", False) else "no"
else:
tool_name = str(tool_entry)
read_only = "yes"
safe = "yes"
tools_table.add_row(tool_name, read_only, safe)
console.print(Panel(tools_table, title="Tools", expand=False))
# ── Success status line ───────────────────────────────────────────────────
console.print("[green bold]✓ OK[/green bold] Actor added")
def _print_role_warnings(config_blob: dict[str, Any]) -> None:
"""Print non-fatal role compatibility warnings for actor configs."""
@@ -462,7 +518,13 @@ def add(
set_default=set_default,
)
title = "Actor updated" if update_existing else "Actor added"
_print_actor(actor, title=title, fmt=fmt)
_print_actor(
actor,
title=title,
fmt=fmt,
config_path=config,
show_add_panels=(not update_existing),
)
except (ValidationError, BusinessRuleViolation) as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Abort() from exc
@@ -639,8 +701,7 @@ def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) ->
# Display Cleanup panel
cleanup_info = (
"[blue]Config:[/blue] kept on disk\n"
"[blue]Contexts:[/blue] 0 orphaned"
"[blue]Config:[/blue] kept on disk\n[blue]Contexts:[/blue] 0 orphaned"
)
cleanup_panel = Panel(cleanup_info, title="Cleanup", border_style="dim")
console.print(cleanup_panel)