fix(cli): route 'agents actor add' through ActorRegistry.add() YAML-first path #3462
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user