Files
temp/features/steps/actor_add_rich_output_steps.py
freemo 62ded31c24 fix(cli): route 'agents actor add' through ActorRegistry.add() YAML-first path
Route the 'agents actor add' CLI command through ActorRegistry.add() instead
of the legacy registry.upsert_actor() path. This ensures the original YAML
text, schema_version, and compiled_metadata are preserved in the database.

Changes:
- src/cleveragents/cli/commands/actor.py: Add _load_config_text() helper that
  returns both raw text and parsed dict. Refactor add() to call registry.add()
  with the raw yaml_text and update=update_existing flag when a registry is
  available. The service fallback path (no registry) is unchanged.
- features/steps/actor_cli_steps.py: Update add command step definitions to
  mock registry.add() instead of registry.upsert_actor(). Update 'the actor
  add should pass the loaded config' assertion to verify registry.add() is
  called with a non-empty yaml_text string.
- features/steps/actor_cli_yaml_steps.py: Update add command steps to mock
  registry.add() instead of registry.upsert_actor().
- features/steps/actor_add_rich_output_steps.py: Update add command steps to
  mock registry.add() instead of registry.upsert_actor().
- robot/helper_actor_add_rich_output.py: Update helper to mock registry.add()
  instead of registry.upsert_actor().
- features/actor_add_yaml_first_path.feature: New Behave feature verifying
  the YAML-first persistence path is used by actor add.
- features/steps/actor_add_yaml_first_path_steps.py: Step definitions for
  the new YAML-first path feature.
- robot/actor_add_yaml_first_path.robot: New Robot integration tests verifying
  yaml_text is preserved and upsert_actor is not called.
- robot/helper_actor_add_yaml_first_path.py: Helper script for Robot tests.

Fixes #3426

ISSUES CLOSED: #3426
2026-04-05 21:19:40 +00:00

300 lines
11 KiB
Python

"""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
_ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"}
def _unwrap_envelope(parsed: Any) -> Any:
"""Return the ``data`` field if *parsed* is a spec envelope, else *parsed* as-is."""
if isinstance(parsed, dict) and _ENVELOPE_KEYS.issubset(parsed.keys()):
return parsed["data"]
return parsed
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:
actor_name = context.actor_config_data.get("name", "local/rich-actor")
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
# registry.add() is the YAML-first path used by the add command
registry.add.return_value = context.mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
["add", actor_name, "--config", str(context.actor_config_path)],
)
@when("I run actor add with the capabilities config")
def step_impl(context: Any) -> None:
actor_name = context.actor_config_data.get("name", "local/cap-actor")
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
# registry.add() is the YAML-first path used by the add command
registry.add.return_value = context.mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
["add", actor_name, "--config", str(context.actor_config_path)],
)
@when("I run actor add with the tools config")
def step_impl(context: Any) -> None:
actor_name = context.actor_config_data.get("name", "local/tool-actor")
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
# registry.add() is the YAML-first path used by the add command
registry.add.return_value = context.mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
["add", actor_name, "--config", str(context.actor_config_path)],
)
@when("I run actor add with the typed config in json format")
def step_impl(context: Any) -> None:
actor_name = context.actor_config_data.get("name", "local/rich-actor")
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
# registry.add() is the YAML-first path used by the add command
registry.add.return_value = context.mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
[
"add",
actor_name,
"--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()
parsed = json.loads(output)
data = _unwrap_envelope(parsed)
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}"
)