deer-flow/backend/tests/test_extension_loader.py
Nan Gao 1f792d0f4b
feat(extensions): add middleware plugin foundation (#4636)
* feat(extensions): add middleware plugin foundation

* fix(extensions): stop config resolution from masking extension loading

`create_app()` resolved the configured plugin list inside the fail-open
guard around `load_extensions()`. CI has no `config.yaml` (gitignored and
never generated by the workflow), so `get_app_config()` raised
`FileNotFoundError` there and was swallowed as an extension failure --
`load_extensions()` never ran at all, and the four `create_app()` tests in
`test_extension_app_loading.py` passed locally but failed on every runner.

Resolve the plugin list before the guard. Only an absent `config.yaml` is
tolerated, mirroring `_resolve_trace_enabled_for_app_construction()`:
`create_app()` runs at import time, and lifespan still performs strict
config loading before serving. A `config.yaml` that exists but fails to
parse or validate now propagates instead of being reported as an extension
failure -- reporting it as the latter silently dropped a `required: true`
extension rather than failing the boot.

Make the tests config-independent with an autouse `stub_app_config`
fixture, following the existing pattern in `test_gateway_lifespan_shutdown.py`,
and cover both new branches of the config-resolution boundary.

* fix(extensions): bind the run's extension snapshot through subagent delegation

The lead-agent path resolves one immutable loaded-extension snapshot per run
and binds it through task-store allocation and graph construction, but the
subagent path re-read the process-wide singleton at execution time. In
production both are the same object, yet a `set_loaded_extensions()` between
the lead run's start and a subagent's execution (test teardown, a future
hot-reload path) would let one run mix two extension generations — exactly what
the documented invariant exists to prevent.

The graph-build binding is a ContextVar scoped to synchronous construction, so
it has already exited by the time a tool delegates; the snapshot has to travel
through runtime context instead. The run worker publishes it under the
host-internal `EXTENSION_SNAPSHOT_CONTEXT_KEY` (written after the caller merge,
popped when the run has none, so a caller-supplied value is never
authoritative), `task_tool` reads it back through the type-checking
`resolve_run_extensions()`, and `SubagentExecutor` binds it at construction.

Callers outside the Gateway run path — embedded `DeerFlowClient`, standalone
LangGraph Server — install no snapshot and keep the existing
`get_loaded_extensions()` fallback.

* refactor(extensions): defer the ordering table by call, not by a lying tuple

`CORE_ORDERING_CONSTRAINTS` was a `tuple` subclass that overrode only
`__iter__` and resolved into a class-level `_resolved` side channel. A tuple
cannot populate its own storage after construction, so the instance stayed the
empty tuple it was built as: `len()` was 0, `bool()` was False, `in` was always
False, indexing raised, slicing and `reversed()` came back empty, and it
compared unequal to the plain tuples tests substitute for it — all while
iteration yielded the real constraints. Only `assert_ordering` consumed it, and
only by iterating, so the split went unnoticed.

The sibling `_AnchorTable(dict)` uses the same idea soundly because dict is
mutable: `self.update()` fills the real storage, making every inherited
operation correct. That trick does not survive the port to an immutable type.

Replace it with `core_ordering_constraints()`, matching how `stack.py` defers
the same kind of table via `_anchors()`. The deferral is kept — it is about
dependency direction, not just cycles: `extensions/` is the layer the
middleware layer calls into, so a module-scope `agents.middlewares` import here
points the dependency backwards and closes a cycle as soon as any middleware
imports something under `extensions/` at module level. Resolution stays at
`assert_ordering` time, which already runs inside the middleware builder.

Tests pin both halves: the returned value is a plain tuple whose len/bool/
membership/indexing/reversal/equality agree with iteration, and a subprocess
probe asserts importing `extensions.ordering` does not load the middleware
layer while calling the function does.
2026-08-04 22:33:26 +08:00

382 lines
14 KiB
Python

"""Tests for config-driven extension loading."""
from __future__ import annotations
import pytest
from deerflow.extensions.loader import (
Diagnostic,
ExtensionLoadError,
ExtensionSpec,
load_extensions,
)
from extension_test_fixtures import demo_extensions
_FIXTURE = "extension_test_fixtures.demo_extensions"
@pytest.fixture(autouse=True)
def _reset_fixture_state():
demo_extensions.INSTALLED.clear()
yield
demo_extensions.INSTALLED.clear()
def test_no_specs_yields_empty_result():
loaded, diagnostics = load_extensions([])
assert diagnostics == []
assert loaded.has_middleware_contributors is False
def test_successful_install_registers_and_attributes():
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok")
loaded, diagnostics = load_extensions([spec])
assert diagnostics == []
assert demo_extensions.INSTALLED == ["ok"]
assert loaded.middleware_contributors[0][0] == f"{_FIXTURE}:install_ok"
def test_config_block_is_passed_through_verbatim():
spec = ExtensionSpec(use=f"{_FIXTURE}:install_reads_config", config={"mode": "fast"})
load_extensions([spec])
assert demo_extensions.INSTALLED == ["config:fast"]
def test_disabled_extension_registers_nothing():
spec = ExtensionSpec(use=f"{_FIXTURE}:install_disabled", config={"enabled": False})
loaded, diagnostics = load_extensions([spec])
assert diagnostics == []
assert loaded.has_middleware_contributors is False
def test_load_order_follows_config_order():
specs = [
ExtensionSpec(use=f"{_FIXTURE}:install_ok"),
ExtensionSpec(use=f"{_FIXTURE}:install_stamped"),
]
load_extensions(specs)
assert demo_extensions.INSTALLED == ["ok", "stamped"]
def test_unresolvable_entry_point_is_skipped_with_an_error_diagnostic():
specs = [
ExtensionSpec(use="extension_test_fixtures.demo_extensions:does_not_exist"),
ExtensionSpec(use=f"{_FIXTURE}:install_ok"),
]
loaded, diagnostics = load_extensions(specs)
assert [d.level for d in diagnostics] == ["error"]
assert "does_not_exist" in diagnostics[0].source
assert demo_extensions.INSTALLED == ["ok"], "a broken extension must not stop the rest"
def test_non_callable_entry_point_is_rejected():
spec = ExtensionSpec(use=f"{_FIXTURE}:NOT_CALLABLE")
loaded, diagnostics = load_extensions([spec])
assert diagnostics[0].level == "error"
assert "callable" in diagnostics[0].message
def test_install_failure_rolls_back_partial_registration():
specs = [
ExtensionSpec(use=f"{_FIXTURE}:install_partial_then_raise"),
ExtensionSpec(use=f"{_FIXTURE}:install_ok"),
]
loaded, diagnostics = load_extensions(specs)
assert diagnostics[0].level == "error"
assert "boom" in diagnostics[0].message
sources = {source for source, _ in loaded.middleware_contributors}
assert sources == {f"{_FIXTURE}:install_ok"}
assert len(loaded.middleware_contributors) == 1, "rollback must clear every partial registration"
def test_rollback_does_not_remove_a_different_specs_registrations_sharing_the_same_use():
"""Two specs may legitimately share `use` with different config (e.g. the
same extension mounted twice with different settings). Rollback on the
second's install failure must be positional, not keyed by `use` — it must
not erase the first instance's already-successful registrations just
because they share a source string."""
specs = [
ExtensionSpec(use=f"{_FIXTURE}:install_shared_use", config={"label": "first"}),
ExtensionSpec(use=f"{_FIXTURE}:install_shared_use", config={"label": "second", "fail": True}),
]
loaded, diagnostics = load_extensions(specs)
assert [d.level for d in diagnostics] == ["error"]
assert "boom-shared" in diagnostics[0].message
assert len(loaded.middleware_contributors) == 1
source, contributor = loaded.middleware_contributors[0]
assert source == f"{_FIXTURE}:install_shared_use"
assert contributor.tag == "shared:first"
def test_required_extension_failure_aborts_startup():
spec = ExtensionSpec(use=f"{_FIXTURE}:install_partial_then_raise", required=True)
with pytest.raises(ExtensionLoadError):
load_extensions([spec])
def test_required_unresolvable_extension_aborts_startup():
spec = ExtensionSpec(use="nope.nothing:here", required=True)
with pytest.raises(ExtensionLoadError):
load_extensions([spec])
def test_incompatible_declared_api_is_refused_with_actionable_message():
spec = ExtensionSpec(use=f"{_FIXTURE}:install_future_api")
loaded, diagnostics = load_extensions([spec])
assert diagnostics[0].level == "error"
assert "99.0" in diagnostics[0].message
assert "pip install" in diagnostics[0].message
assert demo_extensions.INSTALLED == [], "an incompatible extension must not run"
def test_optional_extension_with_non_string_api_marker_is_skipped_with_a_diagnostic(monkeypatch):
monkeypatch.setattr(demo_extensions.install_ok, "__deerflow_api__", 101, raising=False)
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok")
loaded, diagnostics = load_extensions([spec])
assert loaded.has_middleware_contributors is False
assert demo_extensions.INSTALLED == [], "an invalid API marker must be rejected before install()"
assert len(diagnostics) == 1
assert diagnostics[0].level == "error"
assert diagnostics[0].source == spec.use
assert "invalid extension-api version marker" in diagnostics[0].message
assert "int" in diagnostics[0].message
def test_required_extension_with_non_string_iterable_api_marker_fails_closed(monkeypatch):
class _IterableAPIMarker:
def split(self, separator: str) -> list[object]:
return [object()]
def __str__(self) -> str:
return "non-string iterable marker"
monkeypatch.setattr(
demo_extensions.install_ok,
"__deerflow_api__",
_IterableAPIMarker(),
raising=False,
)
spec = ExtensionSpec(
use=f"{_FIXTURE}:install_ok",
required=True,
)
with pytest.raises(ExtensionLoadError, match="declares invalid api marker"):
load_extensions([spec])
assert demo_extensions.INSTALLED == [], "an invalid API marker must be rejected before install()"
def test_optional_extension_with_unrenderable_api_marker_still_returns_a_diagnostic(monkeypatch):
class _UnrenderableAPIMarker:
def __str__(self) -> str:
raise RuntimeError("API marker string rendering exploded")
monkeypatch.setattr(
demo_extensions.install_ok,
"__deerflow_api__",
_UnrenderableAPIMarker(),
raising=False,
)
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok")
loaded, diagnostics = load_extensions([spec])
assert loaded.has_middleware_contributors is False
assert demo_extensions.INSTALLED == []
assert len(diagnostics) == 1
assert diagnostics[0].level == "error"
assert "invalid extension-api version marker" in diagnostics[0].message
assert "_UnrenderableAPIMarker" in diagnostics[0].message
@pytest.mark.parametrize("required", [False, True])
def test_extension_api_marker_getter_failure_obeys_required_policy(monkeypatch, required):
class _ExplodingMarkerInstall:
@property
def __deerflow_api__(self):
raise RuntimeError("API marker getter exploded")
def __call__(self, registry, config):
raise AssertionError("install must not run after marker inspection fails")
monkeypatch.setattr(
"deerflow.extensions.loader.resolve_variable",
lambda path: _ExplodingMarkerInstall(),
)
spec = ExtensionSpec(use="hostile_extension:install", required=required)
if required:
with pytest.raises(ExtensionLoadError, match="could not inspect api marker"):
load_extensions([spec])
return
loaded, diagnostics = load_extensions([spec])
assert loaded.has_middleware_contributors is False
assert len(diagnostics) == 1
assert "could not inspect extension-api version marker" in diagnostics[0].message
def test_string_subclass_api_marker_cannot_break_incompatibility_diagnostics(monkeypatch):
class _HostileString(str):
def split(self, separator: str):
raise RuntimeError("API marker split exploded")
def __str__(self) -> str:
raise RuntimeError("API marker string rendering exploded")
def __format__(self, format_spec: str) -> str:
raise RuntimeError("API marker formatting exploded")
monkeypatch.setattr(
demo_extensions.install_ok,
"__deerflow_api__",
_HostileString("99.0"),
raising=False,
)
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok")
loaded, diagnostics = load_extensions([spec])
assert loaded.has_middleware_contributors is False
assert demo_extensions.INSTALLED == []
assert len(diagnostics) == 1
assert "99.0" in diagnostics[0].message
def test_compatible_string_subclass_api_marker_can_load(monkeypatch):
class _HostileString(str):
def split(self, separator: str):
raise RuntimeError("API marker split exploded")
def __str__(self) -> str:
raise RuntimeError("API marker string rendering exploded")
def __format__(self, format_spec: str) -> str:
raise RuntimeError("API marker formatting exploded")
monkeypatch.setattr(
demo_extensions.install_ok,
"__deerflow_api__",
_HostileString("0.1.0"),
raising=False,
)
loaded, diagnostics = load_extensions([ExtensionSpec(use=f"{_FIXTURE}:install_ok")])
assert diagnostics == []
assert loaded.has_middleware_contributors is True
assert demo_extensions.INSTALLED == ["ok"]
def test_newer_minor_declared_api_is_refused():
"""Before 1.0, minors carry no compatibility promise: an extension written
against 0.2 may use contracts a 0.1 host does not implement, and the host
must refuse it with an actionable message."""
spec = ExtensionSpec(use=f"{_FIXTURE}:install_newer_minor_api")
loaded, diagnostics = load_extensions([spec])
assert diagnostics[0].level == "error"
assert "0.2" in diagnostics[0].message
assert "pip install" in diagnostics[0].message
assert demo_extensions.INSTALLED == [], "a newer-minor extension must not run on an older host"
def test_newer_minor_required_extension_aborts_startup():
spec = ExtensionSpec(use=f"{_FIXTURE}:install_newer_minor_api", required=True)
with pytest.raises(ExtensionLoadError):
load_extensions([spec])
def test_compatible_declared_api_loads():
spec = ExtensionSpec(use=f"{_FIXTURE}:install_stamped")
loaded, diagnostics = load_extensions([spec])
assert diagnostics == []
assert demo_extensions.INSTALLED == ["stamped"]
def test_compatible_follows_semver_windows():
"""0.x: minors may break — the window is same major.minor with patches
additive (host >= declared). From 1.0 on: contracts only grow within a
major. Comparisons are numeric (1.10 > 1.9), not lexicographic."""
from deerflow.extensions.loader import _compatible
# 0.x window: same major.minor, patch-level growth only.
assert _compatible("0.1", "0.1")
assert _compatible("0.1", "0.1.1"), "patch growth stays compatible"
assert not _compatible("0.1.1", "0.1"), "a newer patch declaration exceeds what the host provides"
assert not _compatible("0.2", "0.1"), "0.x minors may break: a 0.1 host must refuse 0.2 extensions"
assert not _compatible("0.1", "0.2"), "0.x minors promise nothing in the other direction either"
# 1.x+ window: same major, contracts only grow.
assert _compatible("1.0", "1.0")
assert _compatible("1.0", "1.1"), "a newer host still provides everything a 1.0 extension declared"
assert _compatible("1.9", "1.10"), "minor comparison is numeric, not lexicographic"
assert not _compatible("1.1", "1.0"), "the 1.0 host lacks the 1.1 contract additions"
assert not _compatible("1.10", "1.9")
assert not _compatible("1.0.1", "1.0"), "even a newer patch declaration exceeds what the host provides"
assert not _compatible("2.0", "1.5"), "major mismatch"
assert not _compatible("1.0", "2.0"), "major mismatch"
assert not _compatible("not-a-version", "1.0"), "unparseable versions are refused, not waved through"
def test_undeclared_api_is_allowed():
"""The decorator is optional; pip constraints remain the primary gate."""
spec = ExtensionSpec(use=f"{_FIXTURE}:install_ok")
_, diagnostics = load_extensions([spec])
assert diagnostics == []
def test_a_successful_load_is_reported(caplog):
"""Every other branch is failure-only, so without this line an operator has
no way to tell a clean load from a `plugins:` block the host never read."""
with caplog.at_level("INFO", logger="deerflow.extensions.loader"):
load_extensions([ExtensionSpec(use=f"{_FIXTURE}:install_ok")])
assert f"Extensions loaded: 1/1 ({_FIXTURE}:install_ok)" in caplog.text
def test_the_report_counts_skipped_extensions_apart_from_loaded_ones(caplog):
specs = [
ExtensionSpec(use=f"{_FIXTURE}:install_ok"),
ExtensionSpec(use="does.not.exist:install"),
]
with caplog.at_level("INFO", logger="deerflow.extensions.loader"):
load_extensions(specs)
assert f"Extensions loaded: 1/2 ({_FIXTURE}:install_ok)" in caplog.text
def test_an_all_failed_load_reports_none_rather_than_an_empty_list(caplog):
with caplog.at_level("INFO", logger="deerflow.extensions.loader"):
load_extensions([ExtensionSpec(use="does.not.exist:install")])
assert "Extensions loaded: 0/1 (none)" in caplog.text
def test_no_configured_plugins_stays_off_the_info_log(caplog):
"""The default state for nearly every deployment; a line here is boot noise."""
with caplog.at_level("INFO", logger="deerflow.extensions.loader"):
load_extensions([])
assert "Extensions loaded" not in caplog.text
def test_diagnostic_helpers_set_level():
assert Diagnostic.error("s", "m").level == "error"
assert Diagnostic.warning("s", "m").level == "warning"
assert Diagnostic.info("s", "m").level == "info"
assert Diagnostic.debug("s", "m").level == "debug"
def test_host_registry_satisfies_the_public_contract():
"""Extensions annotate install(registry: ExtensionRegistry, ...) against
the contract package alone; the host's concrete registry must satisfy that
Protocol, or every correctly-annotated extension is lying about its types."""
from deerflow_extension_api import ExtensionRegistry as ContractRegistry
from deerflow.extensions.registry import ExtensionRegistry as HostRegistry
assert isinstance(HostRegistry(), ContractRegistry)