Files
CoreRasurae 38fe40553d test(robot): add end-to-end integration tests for email categorization actor graph
Adds 104 Robot Framework integration tests exercising the full
6-agent email categorization actor graph across five configuration
strategies: local, remote, mixed, component-decomposed (merge_configs),
and namespaced (local: refs via ReferenceResolver + LocalPackageStore).

- 7 new email graph YAML fixtures under tests/fixtures/email_graph/
- 7 namespaced component YAMLs with local namespace identifiers
- 9 robot test suite files (email_categorization_graph, per-agent,
  negative tests, merge_configs validation, components, namespaced)
- EmailGraphLib.py keyword library (executor, graph, merge, components)
- CleverActorsLib.py refactored into per-module libs:
  robot/lib/config_lib.py, app_lib.py, registry_lib.py
- email_testdata.resource with 12 realistic email scenarios
- All 104 integration tests pass, all 23 existing tests preserved

Closes #30
2026-06-15 23:13:35 +01:00

275 lines
11 KiB
Python

"""Robot Framework keyword library for application lifecycle and agent factory.
Covers: context manager, exceptions, progress bar, agent factory,
application, and stream router.
"""
from __future__ import annotations
import asyncio
import os
import shutil
import tempfile
from pathlib import Path
import yaml
from cleveractors import ContextManager, ReactiveCleverAgentsApp
from cleveractors.agents.factory import Agent, AgentFactory
from cleveractors.agents.tool import ToolAgent
from cleveractors.core.exceptions import (
AgentCreationError,
CleverAgentsException,
ConfigurationError,
)
from cleveractors.core.progress import ProgressBarManager
from cleveractors.registry.exceptions import (
AccessDeniedError,
AuthenticationRequiredError,
ConflictError,
InvalidPackageIdError,
InvalidPackageReferenceError,
PackageNotFoundError,
RegistryError,
RegistryNetworkError,
ValidationError,
VersionNotFoundError,
exception_for_status,
)
from cleveractors.templates.renderer import TemplateEngine, TemplateRenderer
_EXCEPTION_CLASS_MAP: dict[str, type[CleverAgentsException]] = {
"CleverAgentsException": CleverAgentsException,
"ConfigurationError": ConfigurationError,
"AgentCreationError": AgentCreationError,
"RegistryError": RegistryError,
"PackageNotFoundError": PackageNotFoundError,
"InvalidPackageIdError": InvalidPackageIdError,
"InvalidPackageReferenceError": InvalidPackageReferenceError,
"VersionNotFoundError": VersionNotFoundError,
"ValidationError": ValidationError,
"AuthenticationRequiredError": AuthenticationRequiredError,
"AccessDeniedError": AccessDeniedError,
"ConflictError": ConflictError,
"RegistryNetworkError": RegistryNetworkError,
}
class AppLib: # pragma: no cover - integration test library
"""Keyword library for application lifecycle and agent factory."""
ROBOT_LIBRARY_SCOPE = "TEST SUITE"
# ── context manager ───────────────────────────────────────────────
def create_context(self, name: str) -> None:
self._temp_dir = tempfile.mkdtemp(prefix="ca_test_ctx_")
self._ctx = ContextManager(name, context_dir=Path(self._temp_dir))
self._ctx.clear()
def add_context_message(self, role: str, content: str) -> None:
self._ctx.add_message(role, content)
def context_history_count_equals(self, expected: str) -> None:
history = self._ctx.get_conversation_history()
if len(history) != int(expected):
raise AssertionError(
f"Expected {expected} messages, got {len(history)}: {history}"
)
def context_last_n_count_equals(self, n: str, expected: str) -> None:
messages = self._ctx.get_last_n_messages(int(n))
if len(messages) != int(expected):
raise AssertionError(f"Expected {expected} messages, got {len(messages)}")
def context_state_equals(self, key: str, expected: str) -> None:
value = self._ctx.get_state(key)
if str(value) != expected:
raise AssertionError(f"State {key}: expected {expected!r}, got {value!r}")
def context_update_state(self, key: str, value: str) -> None:
self._ctx.update_state(key, value)
def context_save_and_reload(self) -> None:
self._ctx.save()
self._ctx = ContextManager(
self._ctx.context_name, context_dir=Path(self._temp_dir)
)
def context_exists_should_be(self, expected: str) -> None:
exists = self._ctx.exists()
expected_bool = expected.lower() == "true"
if exists != expected_bool:
raise AssertionError(
f"Expected exists={expected_bool}, got exists={exists}"
)
def context_should_have_global_key(self, key: str, value: str) -> None:
gc = self._ctx.get_global_context()
stored = gc.get(key)
if str(stored) != value:
raise AssertionError(
f"Global context {key}: expected {value!r}, got {stored!r}"
)
def context_save_global(self, key: str, value: str) -> None:
self._ctx.save_global_context({key: value})
def context_clear(self) -> None:
self._ctx.clear()
def context_delete(self) -> None:
self._ctx.delete()
def context_export_import(self, export_key: str, export_val: str) -> None:
self._ctx.update_state(export_key, export_val)
self._ctx.save()
export_path = Path(self._temp_dir) / "export.json"
self._ctx.export_context(export_path)
self._ctx.delete()
os.makedirs(Path(self._temp_dir) / self._ctx.context_name, exist_ok=True)
self._ctx.import_context(export_path)
imported = self._ctx.get_state(export_key)
if str(imported) != export_val:
raise AssertionError(
f"Import mismatch: {export_key}={export_val!r} -> {imported!r}"
)
def context_list_contains(self, name: str) -> None:
contexts = ContextManager.list_contexts(context_dir=Path(self._temp_dir))
if name not in contexts:
raise AssertionError(f"Context {name!r} not in list: {contexts}")
def cleanup_temp_dir(self) -> None:
if hasattr(self, "_temp_dir") and os.path.isdir(self._temp_dir):
shutil.rmtree(self._temp_dir, ignore_errors=True)
# ── exceptions ────────────────────────────────────────────────────
def exception_is_subclass_of(self, child: str, parent: str) -> None:
child_cls = _EXCEPTION_CLASS_MAP[child]
parent_cls = _EXCEPTION_CLASS_MAP[parent]
if not issubclass(child_cls, parent_cls):
raise AssertionError(f"{child} is not a subclass of {parent}")
def exception_is_instance_cleveragents_exception(self, exc_name: str) -> None:
cls = _EXCEPTION_CLASS_MAP[exc_name]
if not issubclass(cls, CleverAgentsException):
raise AssertionError(f"{exc_name} is not a CleverAgentsException")
def exception_for_status_returns(self, code: str, expected_class: str) -> None:
result = exception_for_status(int(code), f"status {code}")
expected = _EXCEPTION_CLASS_MAP[expected_class]
if not isinstance(result, expected):
raise AssertionError(
f"Expected {expected_class} for code {code}, "
f"got {type(result).__name__}"
)
if (
getattr(result, "status_code", None) is not None
and str(result.status_code) != code
):
raise AssertionError(
f"Expected status_code {code}, got {result.status_code}"
)
# ── progress bar ──────────────────────────────────────────────────
def create_progress_bar(self) -> None:
self._pb = ProgressBarManager()
self._pb_exists = True
def progress_bar_renders_without_error(self) -> None:
self._pb.update(stage="test", current=1, total=2, message="start")
result = self._pb.update(stage="test", current=2, total=2, message="done")
if not result:
raise AssertionError("ProgressBarManager.update returned empty")
# ── agent factory ─────────────────────────────────────────────────
def create_agent_factory_from_yaml(self, yaml_text: str) -> None:
self._renderer = TemplateRenderer(TemplateEngine.SIMPLE)
config = yaml.safe_load(yaml_text)
self._factory = AgentFactory(config, self._renderer)
def create_agent_factory_from_fixture(self, fixture_name: str) -> None:
base = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures"
path = base / fixture_name
with open(path) as f:
config = yaml.safe_load(f)
self._renderer = TemplateRenderer(TemplateEngine.SIMPLE)
self._factory = AgentFactory(config, self._renderer)
def factory_registers_tool_agent(self) -> None:
self._factory.register_agent_type("tool", ToolAgent)
agent_types = self._factory.get_agent_types()
if "tool" not in agent_types:
raise AssertionError(f"Tool not registered: {agent_types}")
def factory_creates_agent(self, agent_name: str) -> None:
agent = self._factory.create_agent(agent_name)
if not isinstance(agent, Agent):
raise AssertionError(f"Not an Agent: {type(agent)}")
def factory_get_metadata_has_key(self, agent_name: str, key: str) -> None:
meta = self._factory.get_agent_metadata(agent_name)
if key not in meta:
raise AssertionError(f"Metadata missing key {key!r}: {meta}")
def factory_create_all_agents_count(self, expected: str) -> None:
self._factory.register_agent_type("tool", ToolAgent)
agents = self._factory.create_agents_from_config()
if len(agents) != int(expected):
raise AssertionError(f"Expected {expected} agents, got {len(agents)}")
def factory_invalid_config_should_raise(self, yaml_text: str) -> None:
config = yaml.safe_load(yaml_text)
self._renderer = TemplateRenderer(TemplateEngine.SIMPLE)
try:
factory = AgentFactory(config, self._renderer)
except Exception:
return
try:
factory.create_agent("nonexistent_agent_name")
except Exception:
return
raise AssertionError("Expected exception for invalid config")
# ── application ───────────────────────────────────────────────────
def create_app_with_fixture(self, fixture_name: str) -> None:
base = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures"
path = base / fixture_name
self._app = ReactiveCleverAgentsApp(config_files=[path], verbose=0)
def app_loads_without_error(self) -> None:
if self._app is None:
raise AssertionError("App is None")
def app_has_agents(self) -> None:
if not self._app.agents:
raise AssertionError("App has no agents")
def app_can_dispose(self) -> None:
async def _dispose():
await self._app.dispose()
try:
asyncio.get_event_loop().run_until_complete(_dispose())
except RuntimeError:
asyncio.run(_dispose())
def app_can_visualize(self) -> None:
result = self._app.visualize_network("mermaid")
if not result:
raise AssertionError("Visualization returned empty result")
# ── stream router ─────────────────────────────────────────────────
def app_stream_router_has_streams(self) -> None:
router = getattr(self._app, "stream_router", None)
if router is None:
raise AssertionError("App has no stream_router")
if not router.streams:
raise AssertionError("Stream router has no streams")