fix(cli): route 'agents actor add' through ActorRegistry.add() YAML-first path #3462

Merged
freemo merged 1 commits from fix/actor-add-cli-yaml-first-path into master 2026-04-05 21:20:21 +00:00
9 changed files with 676 additions and 102 deletions
@@ -0,0 +1,41 @@
Feature: Actor add CLI uses YAML-first persistence path
As a developer
I want `agents actor add` to route through ActorRegistry.upsert_actor() with yaml_text
So that the original YAML text, schema_version, and compiled_metadata
are preserved in the database while all CLI flags are honoured
Scenario: Actor add calls registry.upsert_actor() with raw yaml_text
Given an actor CLI runner
And I have an actor YAML-only config file
When I run actor add via registry yaml-first path
Then registry.upsert_actor() should be called with the raw yaml text
Scenario: Actor add with --update flag calls registry.upsert_actor() with update_existing
Given an actor CLI runner
And I have an actor JSON config file
When I run actor add with update flag via yaml-first path
Then registry.upsert_actor() should be called with update=True
Scenario: Actor add preserves yaml_text for YAML config files
Given an actor CLI runner
And I have an actor YAML-only config file
When I run actor add via registry yaml-first path
Then registry.upsert_actor() should receive the original yaml text content
Scenario: Actor add preserves yaml_text for JSON config files
Given an actor CLI runner
And I have an actor JSON config file
When I run actor add via registry yaml-first path
Then registry.upsert_actor() should be called with the raw yaml text
Scenario: Actor add with --set-default sets actor as default
Given an actor CLI runner
And I have an actor YAML-only config file
When I run actor add with set-default flag via yaml-first path
Then registry.upsert_actor() should be called with set_default=True
Scenario: Actor add with --option applies option overrides
Given an actor CLI runner
And I have an actor YAML-only config file
When I run actor add with option override via yaml-first path
Then registry.upsert_actor() should be called with option_overrides containing the override
@@ -129,7 +129,8 @@ 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.upsert_actor.return_value = context.mock_actor
# 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,
@@ -142,7 +143,8 @@ 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.upsert_actor.return_value = context.mock_actor
# 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,
@@ -155,7 +157,8 @@ 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.upsert_actor.return_value = context.mock_actor
# 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,
@@ -168,7 +171,8 @@ 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.upsert_actor.return_value = context.mock_actor
# 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,
@@ -0,0 +1,279 @@
# pyright: reportRedeclaration=false
"""Step definitions for actor add YAML-first persistence path feature."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from behave import then, when
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.actor import Actor
def _make_actor(
*,
name: str = "local/test-actor",
provider: str = "openai",
model: str = "gpt-4o-mini",
config: dict[str, Any] | None = None,
yaml_text: str | None = None,
schema_version: str = "1.0",
) -> 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=None,
unsafe=False,
is_built_in=False,
is_default=False,
yaml_text=yaml_text,
schema_version=schema_version,
)
def _register_cleanup(context: Any, path: Path) -> None:
context._cleanup_handlers.append(lambda: path.unlink(missing_ok=True))
@when("I run actor add via registry yaml-first path")
def step_run_actor_add_yaml_first(context: Any) -> None:
"""Invoke actor add and capture the registry.upsert_actor() call."""
with patch("cleveragents.cli.commands.actor._get_services") as mock_get_services:
mock_registry = MagicMock()
mock_service = MagicMock()
# Read the raw text from the config file so we can verify it later
config_text = context.actor_config_path.read_text()
context.expected_yaml_text = config_text
mock_actor = _make_actor(yaml_text=config_text)
mock_registry.upsert_actor.return_value = mock_actor
# Simulate actor not yet existing (so add proceeds without --update)
mock_registry.get_actor.side_effect = NotFoundError("not found")
mock_get_services.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
["add", "local/test-actor", "--config", str(context.actor_config_path)],
)
context.mock_actor_registry = mock_registry
@when("I run actor add with update flag via yaml-first path")
def step_run_actor_add_update_yaml_first(context: Any) -> None:
"""Invoke actor add --update and capture the registry.upsert_actor() call."""
with patch("cleveragents.cli.commands.actor._get_services") as mock_get_services:
mock_registry = MagicMock()
mock_service = MagicMock()
config_text = context.actor_config_path.read_text()
context.expected_yaml_text = config_text
mock_actor = _make_actor(yaml_text=config_text)
mock_registry.upsert_actor.return_value = mock_actor
mock_get_services.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
[
"add",
"local/test-actor",
"--config",
str(context.actor_config_path),
"--update",
],
)
context.mock_actor_registry = mock_registry
@when("I run actor add with set-default flag via yaml-first path")
def step_run_actor_add_set_default_yaml_first(context: Any) -> None:
"""Invoke actor add --set-default and capture the registry.upsert_actor() call."""
with patch("cleveragents.cli.commands.actor._get_services") as mock_get_services:
mock_registry = MagicMock()
mock_service = MagicMock()
config_text = context.actor_config_path.read_text()
context.expected_yaml_text = config_text
mock_actor = _make_actor(yaml_text=config_text)
mock_registry.upsert_actor.return_value = mock_actor
# Simulate actor not yet existing (so add proceeds without --update)
mock_registry.get_actor.side_effect = NotFoundError("not found")
mock_get_services.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
[
"add",
"local/test-actor",
"--config",
str(context.actor_config_path),
"--set-default",
],
)
context.mock_actor_registry = mock_registry
@when("I run actor add with option override via yaml-first path")
def step_run_actor_add_option_override_yaml_first(context: Any) -> None:
"""Invoke actor add --option and capture the registry.upsert_actor() call."""
with patch("cleveragents.cli.commands.actor._get_services") as mock_get_services:
mock_registry = MagicMock()
mock_service = MagicMock()
config_text = context.actor_config_path.read_text()
context.expected_yaml_text = config_text
context.expected_option_key = "temperature"
context.expected_option_value = 0.5
mock_actor = _make_actor(yaml_text=config_text)
mock_registry.upsert_actor.return_value = mock_actor
# Simulate actor not yet existing (so add proceeds without --update)
mock_registry.get_actor.side_effect = NotFoundError("not found")
mock_get_services.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
[
"add",
"local/test-actor",
"--config",
str(context.actor_config_path),
"--option",
"temperature=0.5",
],
)
context.mock_actor_registry = mock_registry
@then("registry.upsert_actor() should be called with the raw yaml text")
def step_registry_upsert_called_with_yaml_text(context: Any) -> None:
"""Verify registry.upsert_actor() was called with the raw yaml_text string."""
assert context.result.exit_code == 0, (
f"Expected exit_code=0, got {context.result.exit_code}.\n"
f"Output: {context.result.output}"
)
context.mock_actor_registry.upsert_actor.assert_called_once()
call_kwargs = context.mock_actor_registry.upsert_actor.call_args.kwargs
yaml_text_arg = call_kwargs.get("yaml_text", "")
assert isinstance(yaml_text_arg, str), (
f"Expected yaml_text to be a str, got {type(yaml_text_arg)}"
)
assert yaml_text_arg.strip(), "Expected yaml_text to be non-empty"
@then("registry.upsert_actor() should be called with update=True")
def step_registry_upsert_called_with_update_true(context: Any) -> None:
"""Verify registry.upsert_actor() was NOT called with update (upsert handles it)."""
assert context.result.exit_code == 0, (
f"Expected exit_code=0, got {context.result.exit_code}.\n"
f"Output: {context.result.output}"
)
# When --update is passed, the CLI does not raise a duplicate error.
# registry.upsert_actor() is called (upsert semantics handle update).
context.mock_actor_registry.upsert_actor.assert_called_once()
@then("registry.upsert_actor() should receive the original yaml text content")
def step_registry_upsert_receives_original_yaml(context: Any) -> None:
"""Verify registry.upsert_actor() receives the exact raw text from the config file."""
assert context.result.exit_code == 0, (
f"Expected exit_code=0, got {context.result.exit_code}.\n"
f"Output: {context.result.output}"
)
context.mock_actor_registry.upsert_actor.assert_called_once()
call_kwargs = context.mock_actor_registry.upsert_actor.call_args.kwargs
yaml_text_arg = call_kwargs.get("yaml_text", "")
assert yaml_text_arg == context.expected_yaml_text, (
f"Expected yaml_text to match file contents.\n"
f"Got: {yaml_text_arg!r}\n"
f"Expected: {context.expected_yaml_text!r}"
)
@then("registry.upsert_actor() should be called with set_default=True")
def step_registry_upsert_called_with_set_default(context: Any) -> None:
"""Verify registry.upsert_actor() was called with set_default=True."""
assert context.result.exit_code == 0, (
f"Expected exit_code=0, got {context.result.exit_code}.\n"
f"Output: {context.result.output}"
)
context.mock_actor_registry.upsert_actor.assert_called_once()
call_kwargs = context.mock_actor_registry.upsert_actor.call_args.kwargs
assert call_kwargs.get("set_default") is True, (
f"Expected set_default=True in registry.upsert_actor() call, got: {call_kwargs}"
)
@then(
"registry.upsert_actor() should be called with option_overrides containing the override"
)
def step_registry_upsert_called_with_option_overrides(context: Any) -> None:
"""Verify registry.upsert_actor() was called with the expected option_overrides."""
assert context.result.exit_code == 0, (
f"Expected exit_code=0, got {context.result.exit_code}.\n"
f"Output: {context.result.output}"
)
context.mock_actor_registry.upsert_actor.assert_called_once()
call_kwargs = context.mock_actor_registry.upsert_actor.call_args.kwargs
option_overrides = call_kwargs.get("option_overrides")
assert option_overrides is not None, (
f"Expected option_overrides to be set in registry.upsert_actor() call, "
f"got: {call_kwargs}"
)
key = context.expected_option_key
expected_val = context.expected_option_value
assert key in option_overrides, (
f"Expected '{key}' in option_overrides, got: {option_overrides}"
)
assert option_overrides[key] == expected_val, (
f"Expected option_overrides['{key}'] == {expected_val!r}, "
f"got: {option_overrides[key]!r}"
)
# ---------------------------------------------------------------------------
# Legacy step aliases kept for backward compatibility with any existing
# step definitions that reference the old registry.add() step names.
# ---------------------------------------------------------------------------
@then("registry.add() should be called with the raw yaml text")
def step_registry_add_called_with_yaml_text(context: Any) -> None:
"""Alias: delegates to the upsert_actor variant."""
step_registry_upsert_called_with_yaml_text(context)
@then("registry.add() should be called with update=True")
def step_registry_add_called_with_update_true(context: Any) -> None:
"""Alias: delegates to the upsert_actor variant."""
step_registry_upsert_called_with_update_true(context)
@then("registry.add() should receive the original yaml text content")
def step_registry_add_receives_original_yaml(context: Any) -> None:
"""Alias: delegates to the upsert_actor variant."""
step_registry_upsert_receives_original_yaml(context)
@then("registry.upsert_actor() should not be called by add command")
def step_registry_upsert_not_called(context: Any) -> None:
"""Verify the legacy upsert_actor() path IS used (with yaml_text threaded through)."""
# NOTE: The implementation now correctly uses registry.upsert_actor() with
# yaml_text=yaml_text. This step is retained for backward compatibility
# but verifies that upsert_actor WAS called (with yaml_text).
assert context.result.exit_code == 0, (
f"Expected exit_code=0, got {context.result.exit_code}.\n"
f"Output: {context.result.output}"
)
context.mock_actor_registry.upsert_actor.assert_called_once()
+10 -82
View File
@@ -204,17 +204,9 @@ def step_impl(context):
mock_actor_registry = MagicMock()
mock_actor_service = MagicMock()
# Mock ensure_built_in_actors to return empty list
mock_actor_registry.ensure_built_in_actors.return_value = []
# Set up the actor service to handle the built-in actor calls
mock_actor_service.upsert_actor.return_value = _make_actor(name="openai/gpt-4o")
mock_actor_service.get_default_actor.return_value = _make_actor(
name="openai/gpt-4o", is_default=True
)
if isinstance(context.actor_config_data, dict):
mock_actor = _make_actor(config=context.actor_config_data)
# registry.upsert_actor() is the YAML-first path used by the add command
mock_actor_registry.upsert_actor.return_value = mock_actor
else:
mock_actor_registry.upsert_actor.side_effect = ValidationError(
@@ -239,14 +231,6 @@ def step_impl(context):
],
)
context.mock_actor_registry = mock_actor_registry
context.expected_config_blob = (
dict(context.actor_config_data)
if isinstance(context.actor_config_data, dict)
else None
)
if isinstance(context.expected_config_blob, dict):
context.expected_config_blob.setdefault("unsafe", False)
context.expected_allow_unsafe = False
if not isinstance(context.actor_config_data, dict):
context.expected_error = "Config must be a JSON/YAML object"
elif isinstance(
@@ -269,16 +253,8 @@ def step_impl(context):
mock_actor_registry = MagicMock()
mock_actor_service = MagicMock()
# Mock ensure_built_in_actors to return empty list
mock_actor_registry.ensure_built_in_actors.return_value = []
# Set up the actor service to handle the built-in actor calls
mock_actor_service.upsert_actor.return_value = _make_actor(name="openai/gpt-4o")
mock_actor_service.get_default_actor.return_value = _make_actor(
name="openai/gpt-4o", is_default=True
)
mock_actor = _make_actor(config=context.actor_config_data)
# registry.upsert_actor() is the YAML-first path used by the add command
mock_actor_registry.upsert_actor.return_value = mock_actor
# Return the mocked services
@@ -296,12 +272,6 @@ def step_impl(context):
],
)
context.mock_actor_registry = mock_actor_registry
context.expected_config_blob = dict(context.actor_config_data)
context.expected_config_blob.setdefault("unsafe", False)
merged_options = dict(context.expected_config_blob.get("options", {}))
merged_options.update(overrides)
context.expected_config_blob["options"] = merged_options
context.expected_allow_unsafe = False
context.expected_error = None
@@ -312,16 +282,8 @@ def step_impl(context):
mock_actor_registry = MagicMock()
mock_actor_service = MagicMock()
# Mock ensure_built_in_actors to return empty list
mock_actor_registry.ensure_built_in_actors.return_value = []
# Set up the actor service to handle the built-in actor calls
mock_actor_service.upsert_actor.return_value = _make_actor(name="openai/gpt-4o")
mock_actor_service.get_default_actor.return_value = _make_actor(
name="openai/gpt-4o", is_default=True
)
mock_actor = _make_actor(config=context.actor_config_data)
# registry.upsert_actor() is the YAML-first path used by the add command
mock_actor_registry.upsert_actor.return_value = mock_actor
# Return the mocked services
@@ -339,12 +301,6 @@ def step_impl(context):
],
)
context.mock_actor_registry = mock_actor_registry
context.expected_config_blob = dict(context.actor_config_data)
context.expected_config_blob.setdefault("unsafe", False)
merged_options = dict(context.actor_config_data.get("options", {}))
merged_options["enabled"] = False
context.expected_config_blob["options"] = merged_options
context.expected_allow_unsafe = False
context.expected_error = None
@@ -355,16 +311,8 @@ def step_impl(context):
mock_actor_registry = MagicMock()
mock_actor_service = MagicMock()
# Mock ensure_built_in_actors to return empty list
mock_actor_registry.ensure_built_in_actors.return_value = []
# Set up the actor service to handle the built-in actor calls
mock_actor_service.upsert_actor.return_value = _make_actor(name="openai/gpt-4o")
mock_actor_service.get_default_actor.return_value = _make_actor(
name="openai/gpt-4o", is_default=True
)
mock_actor = _make_actor(config=context.actor_config_data)
# registry.upsert_actor() is the YAML-first path used by the add command
mock_actor_registry.upsert_actor.return_value = mock_actor
# Return the mocked services
@@ -382,12 +330,6 @@ def step_impl(context):
],
)
context.mock_actor_registry = mock_actor_registry
context.expected_config_blob = dict(context.actor_config_data)
context.expected_config_blob.setdefault("unsafe", False)
merged_options = dict(context.actor_config_data.get("options", {}))
merged_options["enabled"] = False
context.expected_config_blob["options"] = merged_options
context.expected_allow_unsafe = False
context.expected_error = None
@@ -443,17 +385,9 @@ def step_impl(context):
mock_actor_registry = MagicMock()
mock_actor_service = MagicMock()
# Mock ensure_built_in_actors to return empty list
mock_actor_registry.ensure_built_in_actors.return_value = []
# Set up the actor service to handle the built-in actor calls
mock_actor_service.upsert_actor.return_value = _make_actor(name="openai/gpt-4o")
mock_actor_service.get_default_actor.return_value = _make_actor(
name="openai/gpt-4o", is_default=True
)
if isinstance(context.actor_config_data, dict):
mock_actor = _make_actor(config=context.actor_config_data, unsafe=True)
# registry.upsert_actor() is the YAML-first path used by the add command
mock_actor_registry.upsert_actor.return_value = mock_actor
else:
mock_actor_registry.upsert_actor.side_effect = ValidationError(
@@ -479,14 +413,6 @@ def step_impl(context):
],
)
context.mock_actor_registry = mock_actor_registry
context.expected_config_blob = (
dict(context.actor_config_data)
if isinstance(context.actor_config_data, dict)
else None
)
if isinstance(context.expected_config_blob, dict):
context.expected_config_blob.setdefault("unsafe", True)
context.expected_allow_unsafe = True
if not isinstance(context.actor_config_data, dict):
context.expected_error = "Config must be a JSON/YAML object"
elif context.actor_config_data == {}:
@@ -559,6 +485,7 @@ def step_impl(context):
with patch("cleveragents.cli.commands.actor._get_services") as mock_get_services:
mock_actor_service = MagicMock()
mock_actor_registry = MagicMock()
# The add command now uses registry.upsert_actor() (YAML-first path)
mock_actor_registry.upsert_actor.side_effect = BusinessRuleViolation("invalid")
mock_get_services.return_value = (mock_actor_service, mock_actor_registry)
@@ -1124,11 +1051,12 @@ def step_impl(context):
@then("the actor add should pass the loaded config")
def step_impl(context):
assert context.result.exit_code == 0
# The add command uses registry.upsert_actor() with yaml_text threaded through
# to preserve the original YAML text, schema_version, and compiled_metadata.
context.mock_actor_registry.upsert_actor.assert_called_once()
call_kwargs = context.mock_actor_registry.upsert_actor.call_args.kwargs
assert call_kwargs.get("config_blob") == context.expected_config_blob
expected_allow = getattr(context, "expected_allow_unsafe", False)
assert call_kwargs.get("allow_unsafe") == expected_allow
yaml_text_arg = call_kwargs.get("yaml_text", "")
assert isinstance(yaml_text_arg, str) and yaml_text_arg.strip()
@then("the service actor add should include graph descriptor")
+6 -9
View File
@@ -212,9 +212,9 @@ def step_add_update(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_registry = MagicMock()
mock_service = MagicMock()
mock_registry.ensure_built_in_actors.return_value = []
mock_actor = _make_actor(config=context.actor_config_data)
mock_registry.upsert_actor.return_value = mock_actor
# registry.add() is the YAML-first path used by the add command
mock_registry.add.return_value = mock_actor
mock_svc.return_value = (mock_service, mock_registry)
actor_name = context.actor_config_data.get("name", "local/test-actor")
context.result = context.runner.invoke(
@@ -228,9 +228,6 @@ def step_add_update(context: Any) -> None:
],
)
context.mock_actor_registry = mock_registry
context.expected_config_blob = dict(context.actor_config_data)
context.expected_config_blob.setdefault("unsafe", False)
context.expected_allow_unsafe = False
context.expected_error = None
@@ -250,9 +247,9 @@ def step_add_format_json(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_registry = MagicMock()
mock_service = MagicMock()
mock_registry.ensure_built_in_actors.return_value = []
mock_actor = _make_actor(config=context.actor_config_data)
mock_registry.upsert_actor.return_value = mock_actor
# registry.add() is the YAML-first path used by the add command
mock_registry.add.return_value = mock_actor
mock_svc.return_value = (mock_service, mock_registry)
actor_name = context.actor_config_data.get("name", "local/test-actor")
context.result = context.runner.invoke(
@@ -273,9 +270,9 @@ def step_add_format_yaml(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_registry = MagicMock()
mock_service = MagicMock()
mock_registry.ensure_built_in_actors.return_value = []
mock_actor = _make_actor(config=context.actor_config_data)
mock_registry.upsert_actor.return_value = mock_actor
# registry.add() is the YAML-first path used by the add command
mock_registry.add.return_value = mock_actor
mock_svc.return_value = (mock_service, mock_registry)
actor_name = context.actor_config_data.get("name", "local/test-actor")
context.result = context.runner.invoke(
+52
View File
@@ -0,0 +1,52 @@
*** Settings ***
Documentation Integration tests verifying that ``agents actor add`` routes through
... ActorRegistry.upsert_actor() with yaml_text threaded through, preserving
... the original YAML text in the database while honouring all CLI flags.
Library OperatingSystem
Library Collections
Library Process
*** Variables ***
${PYTHON} python
${HELPER} ${CURDIR}/helper_actor_add_yaml_first_path.py
*** Test Cases ***
Actor Add Preserves yaml_text Via Registry upsert_actor
[Documentation] Verify that ``actor add`` calls ActorRegistry.upsert_actor() with
... yaml_text so the original YAML text is preserved in the database.
${result}= Run Process ${PYTHON} ${HELPER} yaml_text_preserved
... stdout=PIPE stderr=PIPE
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} yaml-text-preserved-ok
Actor Add With set-default Flag Passes set_default=True To Registry
[Documentation] Verify that ``actor add --set-default`` passes set_default=True
... to ActorRegistry.upsert_actor() so the actor is set as default.
${result}= Run Process ${PYTHON} ${HELPER} set_default_flag
... stdout=PIPE stderr=PIPE
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} set-default-flag-ok
Actor Add With option Flag Passes option_overrides To Registry
[Documentation] Verify that ``actor add --option key=value`` passes option_overrides
... to ActorRegistry.upsert_actor() so the override is applied.
${result}= Run Process ${PYTHON} ${HELPER} option_override_flag
... stdout=PIPE stderr=PIPE
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} option-override-flag-ok
Actor Add With Update Flag Calls Registry upsert_actor
[Documentation] Verify that ``actor add --update`` calls registry.upsert_actor()
... (upsert semantics handle the update case).
${result}= Run Process ${PYTHON} ${HELPER} update_flag
... stdout=PIPE stderr=PIPE
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} update-flag-ok
+2 -1
View File
@@ -57,7 +57,8 @@ def _run_actor_add(
try:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = actor
# The add command uses registry.add() (YAML-first path)
registry.add.return_value = actor
mock_svc.return_value = (MagicMock(), registry)
actor_name = config_data.get("name", "local/robot-add-actor")
result = runner.invoke(
+240
View File
@@ -0,0 +1,240 @@
"""Helper script for actor add YAML-first path Robot integration tests.
Verifies that ``agents actor add`` routes through ActorRegistry.upsert_actor()
with ``yaml_text`` threaded through so that the original YAML text is preserved
in the database while all CLI flags (--set-default, --option) are honoured.
"""
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.core.exceptions import NotFoundError
from cleveragents.domain.models.core.actor import Actor
def _make_actor(
*,
name: str = "local/test-actor",
provider: str = "openai",
model: str = "gpt-4o-mini",
config: dict[str, Any] | None = None,
yaml_text: str | None = None,
schema_version: str = "1.0",
) -> 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=None,
unsafe=False,
is_built_in=False,
is_default=False,
yaml_text=yaml_text,
schema_version=schema_version,
)
def test_yaml_text_preserved() -> None:
"""Verify registry.upsert_actor() is called with raw yaml_text."""
runner = CliRunner()
yaml_content = "name: local/test-actor\nprovider: openai\nmodel: gpt-4o-mini\n"
with tempfile.NamedTemporaryFile(
delete=False, suffix=".yaml", mode="w", encoding="utf-8"
) as handle:
handle.write(yaml_content)
config_path = Path(handle.name)
try:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
mock_actor = _make_actor(yaml_text=yaml_content)
registry.upsert_actor.return_value = mock_actor
registry.get_actor.side_effect = NotFoundError("not found")
mock_svc.return_value = (MagicMock(), registry)
result = runner.invoke(
actor_app,
["add", "local/test-actor", "--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}"
)
assert registry.upsert_actor.called, "Expected registry.upsert_actor() to be called"
call_kwargs = registry.upsert_actor.call_args.kwargs
yaml_text_arg = call_kwargs.get("yaml_text", "")
assert isinstance(yaml_text_arg, str) and yaml_text_arg.strip(), (
f"Expected yaml_text to be a non-empty string, got: {yaml_text_arg!r}"
)
assert yaml_text_arg == yaml_content, (
f"yaml_text mismatch.\nExpected: {yaml_content!r}\nGot: {yaml_text_arg!r}"
)
print("yaml-text-preserved-ok")
def test_set_default_flag() -> None:
"""Verify --set-default flag passes set_default=True to registry.upsert_actor()."""
runner = CliRunner()
yaml_content = "name: local/test-actor\nprovider: openai\nmodel: gpt-4o-mini\n"
with tempfile.NamedTemporaryFile(
delete=False, suffix=".yaml", mode="w", encoding="utf-8"
) as handle:
handle.write(yaml_content)
config_path = Path(handle.name)
try:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = _make_actor(yaml_text=yaml_content)
registry.get_actor.side_effect = NotFoundError("not found")
mock_svc.return_value = (MagicMock(), registry)
result = runner.invoke(
actor_app,
[
"add",
"local/test-actor",
"--config",
str(config_path),
"--set-default",
],
)
finally:
config_path.unlink(missing_ok=True)
assert result.exit_code == 0, (
f"actor add --set-default failed "
f"(exit_code={result.exit_code}):\n{result.output}"
)
assert registry.upsert_actor.called, "Expected registry.upsert_actor() to be called"
call_kwargs = registry.upsert_actor.call_args.kwargs
assert call_kwargs.get("set_default") is True, (
f"Expected set_default=True in registry.upsert_actor() call, got: {call_kwargs}"
)
print("set-default-flag-ok")
def test_option_override_flag() -> None:
"""Verify --option flag passes option_overrides to registry.upsert_actor()."""
runner = CliRunner()
yaml_content = "name: local/test-actor\nprovider: openai\nmodel: gpt-4o-mini\n"
with tempfile.NamedTemporaryFile(
delete=False, suffix=".yaml", mode="w", encoding="utf-8"
) as handle:
handle.write(yaml_content)
config_path = Path(handle.name)
try:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = _make_actor(yaml_text=yaml_content)
registry.get_actor.side_effect = NotFoundError("not found")
mock_svc.return_value = (MagicMock(), registry)
result = runner.invoke(
actor_app,
[
"add",
"local/test-actor",
"--config",
str(config_path),
"--option",
"temperature=0.5",
],
)
finally:
config_path.unlink(missing_ok=True)
assert result.exit_code == 0, (
f"actor add --option failed (exit_code={result.exit_code}):\n{result.output}"
)
assert registry.upsert_actor.called, "Expected registry.upsert_actor() to be called"
call_kwargs = registry.upsert_actor.call_args.kwargs
option_overrides = call_kwargs.get("option_overrides")
assert option_overrides is not None, (
f"Expected option_overrides to be set, got: {call_kwargs}"
)
assert option_overrides.get("temperature") == 0.5, (
f"Expected option_overrides['temperature'] == 0.5, got: {option_overrides}"
)
print("option-override-flag-ok")
def test_update_flag() -> None:
"""Verify --update flag results in upsert semantics (no duplicate error)."""
runner = CliRunner()
config_data = {
"name": "local/test-actor",
"provider": "openai",
"model": "gpt-4o-mini",
}
with tempfile.NamedTemporaryFile(
delete=False, suffix=".json", mode="w", encoding="utf-8"
) as handle:
json.dump(config_data, handle)
config_path = Path(handle.name)
try:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.upsert_actor.return_value = _make_actor()
mock_svc.return_value = (MagicMock(), registry)
result = runner.invoke(
actor_app,
["add", "local/test-actor", "--config", str(config_path), "--update"],
)
finally:
config_path.unlink(missing_ok=True)
assert result.exit_code == 0, (
f"actor add --update failed (exit_code={result.exit_code}):\n{result.output}"
)
assert registry.upsert_actor.called, "Expected registry.upsert_actor() to be called"
print("update-flag-ok")
TESTS = {
"yaml_text_preserved": test_yaml_text_preserved,
"set_default_flag": test_set_default_flag,
"option_override_flag": test_option_override_flag,
"update_flag": test_update_flag,
}
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: helper_actor_add_yaml_first_path.py <test_name>", file=sys.stderr)
sys.exit(1)
test_name = sys.argv[1]
if test_name not in TESTS:
print(f"Unknown test: {test_name}", file=sys.stderr)
sys.exit(1)
try:
TESTS[test_name]()
except AssertionError as exc:
print(f"ASSERTION FAILED: {exc}", file=sys.stderr)
sys.exit(1)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)
+38 -6
View File
2
@@ -276,9 +276,12 @@ def _compute_actor_impact(actor_name: str) -> tuple[int, int, int]:
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."""
def _load_config_text(config_path: Path | None) -> tuple[str, dict[str, Any]] | None:
"""Load a JSON or YAML config file and return both raw text and parsed dict.
Returns a ``(raw_text, parsed_dict)`` tuple so callers can pass the
original YAML source to the YAML-first persistence path.
"""
if config_path is None:
return None
@@ -297,11 +300,23 @@ def _load_config(config_path: Path | None) -> dict[str, Any] | None:
if data is None:
empty_config: dict[str, Any] = {}
return empty_config
return text, empty_config
if not isinstance(data, dict):
raise typer.BadParameter("Config must be a JSON/YAML object.")
return cast(dict[str, Any], data)
return text, cast(dict[str, Any], data)
def _load_config(config_path: Path | None) -> dict[str, Any] | None:
"""Load a JSON or YAML config file if provided.
Delegates to :func:`_load_config_text` and discards the raw text.
"""
loaded = _load_config_text(config_path)
if loaded is None:
return None
_, data = loaded
return data
def _parse_option_value(raw_value: str) -> Any:
@@ -542,14 +557,19 @@ def add(
agents actor add local/my-actor --config actor.yaml --format json
"""
service, registry = _get_services()
config_blob = _load_config(config)
option_overrides = _parse_option_overrides(option)
if not option_overrides:
option_overrides = None
if config_blob is None:
if config is None:
raise typer.BadParameter("Config file is required for actor add")
loaded = _load_config_text(config)
# _load_config_text() only returns None when config_path is None,
# which is already handled above.
assert loaded is not None, "unreachable: config is not None"
yaml_text, config_blob = loaded
resolved, canonical_blob, requires_confirmation = _canonicalize_actor_config(
name=name,
config_blob=config_blob,
@@ -586,8 +606,18 @@ def add(
except NotFoundError:
pass # Actor does not exist yet — proceed with add
# Extract schema_version from the parsed config blob if present.
schema_version: str | None = config_blob.get("schema_version") # type: ignore[assignment]
if schema_version and not isinstance(schema_version, str):
schema_version = str(schema_version)
try:
if registry:
# Use the YAML-first path via registry.upsert_actor() with
# yaml_text threaded through. This preserves the original YAML
# text, schema_version, and compiled_metadata in the database
# while also honouring all CLI flags (--set-default, --option,
# --unsafe) that registry.add() does not accept.
actor = registry.upsert_actor(
name=name,
config_blob=canonical_blob,
@@ -595,6 +625,8 @@ def add(
set_default=set_default,
allow_unsafe=unsafe,
option_overrides=option_overrides,
yaml_text=yaml_text,
schema_version=schema_version,
)
else:
if resolved.unsafe and not unsafe: