@extensibility @plugin_architecture Feature: Plugin Architecture Framework with module:ClassName resolution As a CleverAgents developer I want a plugin architecture that loads custom implementations via module:ClassName resolution and entry-point discovery So that tools, strategies, backends, and pipeline components can be extended without modifying core code # --------------------------------------------------------------------------- # PluginState enum # --------------------------------------------------------------------------- @plugin_state Scenario: PluginState enum has all required values Given the PluginState enum is available Then it should have values "discovered", "activated", "executing", "deactivated", "errored" @plugin_state Scenario: PluginState values are strings Given the PluginState enum is available Then each PluginState value should be a string # --------------------------------------------------------------------------- # ExtensionPoint model # --------------------------------------------------------------------------- @extension_point Scenario: ExtensionPoint creation with required fields Given I create an ExtensionPoint with name "test-ep" and a protocol type Then the ExtensionPoint name should be "test-ep" And the ExtensionPoint should have a protocol_type @extension_point Scenario: ExtensionPoint is frozen Given I create an ExtensionPoint with name "frozen-ep" and a protocol type Then attempting to mutate the ExtensionPoint name should raise an error @extension_point Scenario: ExtensionPoint rejects empty name When I attempt to create an ExtensionPoint with an empty name Then a plugin validation error should be raised # --------------------------------------------------------------------------- # PluginDescriptor model # --------------------------------------------------------------------------- @plugin_descriptor Scenario: PluginDescriptor creation with defaults Given I create a PluginDescriptor with name "my-plugin" Then the descriptor state should be "discovered" And the descriptor version should be "0.0.0" And the descriptor dependencies should be empty @plugin_descriptor Scenario: PluginDescriptor with full metadata Given I create a PluginDescriptor with full metadata Then the descriptor should have name "full-plugin" And the descriptor should have version "1.2.3" And the descriptor should have author "TestAuthor" And the descriptor should have module_path "cleveragents.test" And the descriptor should have class_name "TestClass" @plugin_descriptor Scenario: PluginDescriptor state is mutable Given I create a PluginDescriptor with name "mutable-plugin" When I set the descriptor state to "activated" Then the descriptor state should be "activated" @plugin_descriptor Scenario: PluginDescriptor rejects empty name When I attempt to create a PluginDescriptor with an empty name Then a plugin validation error should be raised # --------------------------------------------------------------------------- # Plugin exceptions # --------------------------------------------------------------------------- @plugin_exceptions Scenario: PluginError is base exception Then PluginLoadError should be a subclass of PluginError And PluginNotFoundError should be a subclass of PluginError And ProtocolMismatchError should be a subclass of PluginError @plugin_exceptions Scenario: PluginLoadError carries message When I raise a PluginLoadError with message "test error" Then the exception message should contain "test error" @plugin_exceptions Scenario: PluginNotFoundError carries message When I raise a PluginNotFoundError with message "not found" Then the exception message should contain "not found" @plugin_exceptions Scenario: ProtocolMismatchError carries message When I raise a ProtocolMismatchError with message "mismatch" Then the exception message should contain "mismatch" # --------------------------------------------------------------------------- # PluginLoader — load_class success and failure # --------------------------------------------------------------------------- @plugin_loader @success Scenario: PluginLoader loads a valid class Given a PluginLoader with default prefixes When I load class "InMemoryTextBackend" from module "cleveragents.domain.models.acms.stubs" Then the loaded class should not be None And the loaded class name should be "InMemoryTextBackend" @plugin_loader @success Scenario: PluginLoader loads another valid class Given a PluginLoader with default prefixes When I load class "InMemoryVectorBackend" from module "cleveragents.domain.models.acms.stubs" Then the loaded class should not be None @plugin_loader @failure Scenario: PluginLoader rejects module outside allowed prefix Given a PluginLoader with default prefixes When I attempt to load class "Path" from module "pathlib" Then a PluginLoadError should be raised And the plugin error message should contain "not in the allowed prefix list" @plugin_loader @failure Scenario: PluginLoader fails on nonexistent module Given a PluginLoader with default prefixes When I attempt to load class "Foo" from module "cleveragents.nonexistent_module_xyz" Then a PluginLoadError should be raised And the plugin error message should contain "Cannot import module" @plugin_loader @failure Scenario: PluginLoader fails on nonexistent class Given a PluginLoader with default prefixes When I attempt to load class "NonExistentClassXyz" from module "cleveragents.domain.models.acms.stubs" Then a PluginLoadError should be raised And the plugin error message should contain "not found in module" @plugin_loader @security Scenario: PluginLoader with custom prefixes allows matching module Given a PluginLoader with allowed prefixes "cleveragents.,mypackage." When I load class "InMemoryTextBackend" from module "cleveragents.domain.models.acms.stubs" Then the loaded class should not be None @plugin_loader @security Scenario: PluginLoader with empty prefix allowlist allows anything Given a PluginLoader with empty prefix allowlist When I load class "Path" from module "pathlib" Then the loaded class should not be None @plugin_loader Scenario: PluginLoader allowed_prefixes property Given a PluginLoader with default prefixes Then the loader allowed_prefixes should contain "cleveragents." @plugin_loader @failure Scenario: PluginLoader rejects non-class attribute Given a PluginLoader with default prefixes When I attempt to load class "logger" from module "cleveragents.infrastructure.plugins.loader" Then a PluginLoadError should be raised And the plugin error message should contain "is not a class" # --------------------------------------------------------------------------- # Protocol validation # --------------------------------------------------------------------------- @protocol_validation @success Scenario: validate_protocol passes for conforming class Given a class that implements TextBackend protocol When I validate it against the TextBackend protocol Then the validation should return True @protocol_validation @failure Scenario: validate_protocol fails for non-conforming class Given a class that does not implement any protocol When I attempt to validate it against the TextBackend protocol Then a ProtocolMismatchError should be raised # --------------------------------------------------------------------------- # Entry-point discovery # --------------------------------------------------------------------------- @entry_points Scenario: load_from_entry_points returns empty for unknown group Given a PluginLoader with default prefixes When I discover plugins from entry point group "cleveragents.nonexistent_group_xyz" Then the discovered plugin list should be empty @entry_points @mock Scenario: load_from_entry_points discovers mocked entry points Given a PluginLoader with default prefixes And a mocked entry point group "cleveragents.plugins" with entry "test-ep=cleveragents.domain.models.acms.stubs:InMemoryTextBackend" When I discover plugins from the mocked entry point group Then the discovered plugin list should have 1 entry And the first descriptor name should be "test-ep" @entry_points @security @tdd_issue @tdd_issue_7476 Scenario: Entry point with disallowed prefix is skipped without loading module Given a PluginLoader with default prefixes And a mocked entry point group "cleveragents.plugins" with raw entry "malicious=maliciouspkg.attack:Exploit" When I discover plugins from the mocked entry point group Then the discovered plugin list should be empty And the mocked entry point load should not be called And a security warning should be emitted for disallowed entry point "malicious" # --------------------------------------------------------------------------- # PluginManager — lifecycle # --------------------------------------------------------------------------- @plugin_manager @registration Scenario: PluginManager registers and retrieves a plugin Given a fresh PluginManager instance And a PluginDescriptor for "test-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" When I register the plugin descriptor Then get_plugin should return the descriptor for "test-plugin" And the plugin state should be "discovered" @plugin_manager @registration Scenario: PluginManager rejects duplicate registration Given a fresh PluginManager instance And a PluginDescriptor for "dup-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" When I register the plugin descriptor And I attempt to register the same descriptor again Then a PluginError should be raised @plugin_manager @listing Scenario: PluginManager lists all registered plugins Given a fresh PluginManager instance And I register plugins named "alpha", "beta", "gamma" Then list_plugins should return 3 descriptors @plugin_manager @not_found Scenario: PluginManager raises on unknown plugin Given a fresh PluginManager instance When I attempt to get plugin "nonexistent" Then a PluginNotFoundError should be raised @plugin_manager @activate Scenario: PluginManager activates a registered plugin Given a fresh PluginManager instance And a PluginDescriptor for "act-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" When I register the plugin descriptor And I activate the plugin "act-plugin" Then the plugin state should be "activated" And get_plugin_class for "act-plugin" should not be None And get_plugin_instance for "act-plugin" should not be None @plugin_manager @activate Scenario: PluginManager rejects activating already-activated plugin Given a fresh PluginManager instance And a PluginDescriptor for "double-act" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" When I register the plugin descriptor And I activate the plugin "double-act" And I attempt to activate the plugin "double-act" again Then a PluginError should be raised @plugin_manager @activate @failure Scenario: PluginManager transitions to ERRORED on activation failure Given a fresh PluginManager instance And a PluginDescriptor for "bad-plugin" with module "cleveragents.nonexistent_module_xyz" and class "BadClass" When I register the plugin descriptor And I attempt to activate the plugin "bad-plugin" Then a PluginLoadError should be raised And the plugin "bad-plugin" state should be "errored" @plugin_manager @activate @failure Scenario: PluginManager fails activation with empty module_path Given a fresh PluginManager instance And a PluginDescriptor for "no-path" with empty module_path When I register the plugin descriptor And I attempt to activate the plugin "no-path" Then a PluginLoadError should be raised @plugin_manager @deactivate Scenario: PluginManager deactivates an activated plugin Given a fresh PluginManager instance And a PluginDescriptor for "deact-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" When I register the plugin descriptor And I activate the plugin "deact-plugin" And I deactivate the plugin "deact-plugin" Then the plugin state should be "deactivated" And get_plugin_class for "deact-plugin" should be None And get_plugin_instance for "deact-plugin" should be None @plugin_manager @deactivate Scenario: PluginManager rejects deactivating a discovered plugin Given a fresh PluginManager instance And a PluginDescriptor for "disc-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" When I register the plugin descriptor And I attempt to deactivate the plugin "disc-plugin" Then a PluginError should be raised @plugin_manager @deactivate Scenario: PluginManager can deactivate an errored plugin Given a fresh PluginManager instance And a PluginDescriptor for "err-plugin" with module "cleveragents.nonexistent_module_xyz" and class "BadClass" When I register the plugin descriptor And I attempt to activate the plugin "err-plugin" ignoring error And I deactivate the plugin "err-plugin" Then the plugin state should be "deactivated" # --------------------------------------------------------------------------- # Config-driven registration # --------------------------------------------------------------------------- @plugin_manager @config Scenario: PluginManager registers from config dict Given a fresh PluginManager instance When I register a plugin from config with custom_module "cleveragents.domain.models.acms.stubs" and custom_class "InMemoryTextBackend" Then the registered plugin descriptor should not be None And the plugin should be in the manager registry @plugin_manager @config Scenario: PluginManager ignores config without plugin keys Given a fresh PluginManager instance When I register a plugin from config with no custom_module or custom_class Then the registered plugin descriptor should be None @plugin_manager @config Scenario: PluginManager returns existing for duplicate config Given a fresh PluginManager instance When I register a plugin from config with custom_module "cleveragents.domain.models.acms.stubs" and custom_class "InMemoryTextBackend" And I register the same plugin from config again Then list_plugins should return 1 descriptor @plugin_manager @config Scenario: PluginManager batch registers from config list Given a fresh PluginManager instance When I register plugins from a config list of 3 entries Then list_plugins should return 3 descriptors # --------------------------------------------------------------------------- # Extension point management # --------------------------------------------------------------------------- @plugin_manager @extension_points Scenario: PluginManager registers and lists extension points Given a fresh PluginManager instance When I register an extension point named "text-backend" Then list_extension_points should return 1 entry # --------------------------------------------------------------------------- # Clear / cleanup # --------------------------------------------------------------------------- @plugin_manager @clear Scenario: PluginManager clear removes all plugins Given a fresh PluginManager instance And I register plugins named "a", "b", "c" When I clear the plugin manager Then list_plugins should return 0 descriptors # --------------------------------------------------------------------------- # Thread safety # --------------------------------------------------------------------------- @thread_safety Scenario: PluginManager supports concurrent registration Given a fresh PluginManager instance When 10 threads register plugins concurrently Then list_plugins should return 10 descriptors @thread_safety Scenario: PluginManager supports concurrent activate and deactivate Given a fresh PluginManager with 5 registered plugins When 5 threads activate plugins concurrently Then all 5 plugins should be in "activated" state # --------------------------------------------------------------------------- # DI container integration # --------------------------------------------------------------------------- @di_container Scenario: DI container provides PluginManager singleton When I resolve PluginManager from the DI container Then the resolved PluginManager should not be None And resolving PluginManager again should return the same instance # --------------------------------------------------------------------------- # Discovery integration # --------------------------------------------------------------------------- @discover Scenario: PluginManager discover with no entry points Given a fresh PluginManager instance When I call discover with group "cleveragents.nonexistent_ep_group_xyz" Then the discovered list should be empty @discover Scenario: PluginManager discover registers newly found plugins Given a fresh PluginManager instance And a mocked entry point group for discover When I call discover through the manager Then newly discovered plugins should be in the registry