diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 29d9abc683..c2fcf90a50 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -784,7 +784,10 @@ manifest declarations and admits a factory only when the extension is enabled, doctor-ready, and authorized for every declared adapter permission. The factory must return exactly the declared callable ports. Import, activation, and factory failures become content-free optional-adapter failures; they do not -execute or replace kernel logic. +execute or replace kernel logic. A matching declaration of a disabled or +doctor-stale extension is also reported, as `extension_disabled` or +`extension_doctor_not_ready`, without importing its factory or running its +doctor. `phase = "capability_action"` is for an explicit typed action whose semantics have already been decided by the Agent or caller. The adapter may bind and diff --git a/loopx/capabilities/periodic_report/README.md b/loopx/capabilities/periodic_report/README.md index 8004ea1ee3..e703980ab6 100644 --- a/loopx/capabilities/periodic_report/README.md +++ b/loopx/capabilities/periodic_report/README.md @@ -74,7 +74,16 @@ automatically. With multiple active providers, the Agent selects the provider explicitly with `--source-adapter-id `. The journal retains that adapter identity, and later settlement resolves only that owner regardless of extension discovery order. A temporarily unavailable owner leaves the request -pending; LoopX never falls through to another provider. +pending; LoopX never falls through to another provider. A declared adapter +that was not activated is reported by adapter id with a content-free code and +the `loopx extension doctor` next step instead of being silently dropped: +`extension_disabled` or `extension_doctor_not_ready` for a disabled or +doctor-stale extension, whose factory is never imported, and +`extension_hook_adapter_unavailable` (or an incomplete/ambiguous port code) +when a ready extension fails its permission check, factory import or port +contract. An unreadable extension state is reported as +`extension_state_unavailable`, with or without `--source-adapter-id`, rather +than as "no provider installed". The adapter id is part of the request idempotency namespace together with the Goal, Agent, and provider-local source reference. Two providers may therefore diff --git a/loopx/capabilities/periodic_report/request_action.py b/loopx/capabilities/periodic_report/request_action.py index 564b3d1de1..b4013bd1d5 100644 --- a/loopx/capabilities/periodic_report/request_action.py +++ b/loopx/capabilities/periodic_report/request_action.py @@ -5,7 +5,7 @@ import re import sys from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timezone from importlib import import_module from pathlib import Path @@ -72,11 +72,44 @@ class PeriodicReportRequestAdapter: @dataclass(frozen=True, slots=True) class PeriodicReportRequestPorts: adapters: Mapping[str, PeriodicReportRequestAdapter] + # Declared adapters that matched this hook but were not activated, keyed + # by adapter id with a content-free code. Selection names them instead of + # reporting a merely absent provider. + failed_adapters: Mapping[str, str] = field(default_factory=dict) + state_error_code: str | None = None @property def adapter_ids(self) -> tuple[str, ...]: return tuple(sorted(self.adapters)) + def _unavailable_detail(self, selected_id: str | None = None) -> str: + if selected_id and selected_id in self.failed_adapters: + return ( + f": {selected_id} is declared but was not activated " + f"({self.failed_adapters[selected_id]}); inspect " + "`loopx extension doctor --execute`" + ) + if self.state_error_code: + # An unreadable state hides every declaration, so an explicit id + # can be neither confirmed nor ruled out: report the loss itself. + selected = f" {selected_id};" if selected_id else "" + return ( + f":{selected} extension state could not be read " + f"({self.state_error_code})" + ) + if selected_id: + return f": {selected_id}" + if self.failed_adapters: + failed = ", ".join( + f"{adapter_id} ({code})" + for adapter_id, code in sorted(self.failed_adapters.items()) + ) + return ( + f": declared adapters were not activated: {failed}; inspect " + "`loopx extension doctor --execute`" + ) + return "" + def select_source_adapter( self, adapter_id: str | None ) -> PeriodicReportRequestAdapter: @@ -85,14 +118,17 @@ def select_source_adapter( selected = self.adapters.get(selected_id) if selected is None: raise ValueError( - "periodic-report request source adapter is unavailable: " - f"{selected_id}" + "periodic-report request source adapter is unavailable" + + self._unavailable_detail(selected_id) ) return selected if len(self.adapters) == 1: return next(iter(self.adapters.values())) if not self.adapters: - raise ValueError("periodic-report request source adapter is unavailable") + raise ValueError( + "periodic-report request source adapter is unavailable" + + self._unavailable_detail() + ) raise ValueError( "periodic-report request source adapter is ambiguous; " "select one with --source-adapter-id" @@ -187,7 +223,24 @@ def discover_periodic_report_request_ports( if adapter_id not in ambiguous_adapter_ids and set(ports) == {REQUEST_BIND_PORT, REQUEST_SETTLE_PORT} } - return PeriodicReportRequestPorts(adapters=complete) + failed_adapters = { + failure.adapter_id: failure.error_code + for failure in discovery.failures + if failure.adapter_id not in complete + } + for adapter_id, ports in sorted(adapter_ports.items()): + if adapter_id in complete or adapter_id in failed_adapters: + continue + failed_adapters[adapter_id] = ( + "ambiguous_adapter_ports" + if adapter_id in ambiguous_adapter_ids + else "incomplete_adapter_ports" + ) + return PeriodicReportRequestPorts( + adapters=complete, + failed_adapters=failed_adapters, + state_error_code=getattr(discovery, "state_error_code", None), + ) def _normalize_source_receipt( diff --git a/loopx/extensions/hook_adapters.py b/loopx/extensions/hook_adapters.py index 5b321d5841..75429bae09 100644 --- a/loopx/extensions/hook_adapters.py +++ b/loopx/extensions/hook_adapters.py @@ -6,12 +6,18 @@ from pathlib import Path from typing import Any, cast -from .runtime import extension_catalog_entries, resolve_extension_activation +from .runtime import ( + EXTENSION_DISABLED_STATUS, + EXTENSION_DOCTOR_NOT_READY_STATUS, + extension_catalog_entries, + resolve_extension_activation, +) HOOK_ADAPTER_FACTORY_CONTEXT_SCHEMA_VERSION = ( "loopx_extension_hook_adapter_factory_context_v0" ) +HOOK_ADAPTER_UNAVAILABLE = "extension_hook_adapter_unavailable" @dataclass(frozen=True, slots=True) @@ -27,22 +33,37 @@ class ExtensionHookAdapterPortBinding: @dataclass(frozen=True, slots=True) class ExtensionHookAdapterFailure: - """Content-free failure for one optional manifest adapter.""" + """Content-free failure for one optional manifest adapter. + + `error_code` is the extension readiness status when lifecycle refused + admission (disabled or doctor-stale), otherwise + `extension_hook_adapter_unavailable` for a ready extension whose + activation, factory or ports failed. + """ extension_id: str adapter_id: str capability_id: str target_hook_id: str phase: str - error_code: str = "extension_hook_adapter_unavailable" + error_code: str = HOOK_ADAPTER_UNAVAILABLE @dataclass(frozen=True, slots=True) class ExtensionHookAdapterDiscovery: - """Activated provider ports plus isolated content-free failures.""" + """Activated provider ports plus isolated content-free failures. + + `failures` names each declared adapter that matched the requested hook but + was not activated: a disabled or doctor-stale extension (never imported), + or a ready one whose permission, factory import or ports failed. + `state_error_code` is set when the extension state itself could + not be read, so an empty `ports` is distinguishable from "no provider is + installed". Neither carries provider output or exception text. + """ ports: tuple[ExtensionHookAdapterPortBinding, ...] failures: tuple[ExtensionHookAdapterFailure, ...] + state_error_code: str | None = None def _load_factory(reference: str) -> Callable[..., Mapping[str, Callable[..., Any]]]: @@ -56,7 +77,10 @@ def _load_factory(reference: str) -> Callable[..., Mapping[str, Callable[..., An def _failure( - *, extension_id: str, adapter: Mapping[str, Any] + *, + extension_id: str, + adapter: Mapping[str, Any], + error_code: str = HOOK_ADAPTER_UNAVAILABLE, ) -> ExtensionHookAdapterFailure: return ExtensionHookAdapterFailure( extension_id=extension_id, @@ -64,9 +88,20 @@ def _failure( capability_id=str(adapter["capability_id"]), target_hook_id=str(adapter["target_hook_id"]), phase=str(adapter["phase"]), + error_code=error_code, ) +def _lifecycle_non_admission(provider: Mapping[str, Any]) -> str | None: + """Readiness status that keeps an installed extension from activating.""" + + if provider.get("ready") is True: + return None + if provider.get("enabled") is not True: + return EXTENSION_DISABLED_STATUS + return EXTENSION_DOCTOR_NOT_READY_STATUS + + def discover_extension_hook_adapters( *, state_file: str | Path, @@ -78,9 +113,12 @@ def discover_extension_hook_adapters( goal_id: str, agent_id: str | None, ) -> ExtensionHookAdapterDiscovery: - """Discover ready manifest-declared adapters without provider imports. + """Discover manifest-declared adapters, activating only ready extensions. Factories are imported only after lifecycle and permission activation. + A matching declaration of a disabled or doctor-stale extension is named + as a failure without resolving activation, importing its factory or + running its doctor. Factory/import/activation failures remain ordinary data so an optional provider cannot alter the capability or control-plane kernel. """ @@ -88,18 +126,23 @@ def discover_extension_hook_adapters( try: catalog = extension_catalog_entries(state_file=state_file) except (OSError, TypeError, ValueError): - return ExtensionHookAdapterDiscovery(ports=(), failures=()) + return ExtensionHookAdapterDiscovery( + ports=(), failures=(), state_error_code="extension_state_unavailable" + ) ports: list[ExtensionHookAdapterPortBinding] = [] failures: list[ExtensionHookAdapterFailure] = [] for manifest in catalog: provider = manifest.get("provider") - if not isinstance(provider, Mapping) or provider.get("ready") is not True: - continue - extension_id = str(provider.get("id") or "") adapters = manifest.get("hook_adapters") - if not isinstance(adapters, list): + if not isinstance(provider, Mapping) or not isinstance(adapters, list): + continue + if provider.get("installed") is not True: + # A declared-only manifest is "no provider installed", not a + # disabled one; lifecycle codes below describe installed copies. continue + extension_id = str(provider.get("id") or "") + non_admission = _lifecycle_non_admission(provider) for adapter in adapters: if ( not isinstance(adapter, Mapping) @@ -108,6 +151,15 @@ def discover_extension_hook_adapters( or adapter.get("target_hook_id") != target_hook_id ): continue + if non_admission is not None: + failures.append( + _failure( + extension_id=extension_id, + adapter=adapter, + error_code=non_admission, + ) + ) + continue try: activation = resolve_extension_activation( extension_id, diff --git a/loopx/extensions/runtime.py b/loopx/extensions/runtime.py index dd0d2d5e00..59039a8dba 100644 --- a/loopx/extensions/runtime.py +++ b/loopx/extensions/runtime.py @@ -32,6 +32,11 @@ EXTENSION_ACTIVATION_SCHEMA_VERSION = "loopx_extension_activation_v0" EXTENSION_RUN_SCHEMA_VERSION = "loopx_extension_run_receipt_v0" EXTENSION_DOCTOR_BATCH_SCHEMA_VERSION = "loopx_extension_doctor_batch_v0" +# Content-free lifecycle non-admission codes. Optional capability resolution +# reports them as `ExtensionCapabilityResolution.status`; hook-adapter discovery +# reuses them for declared adapters whose extension it does not activate. +EXTENSION_DISABLED_STATUS = "extension_disabled" +EXTENSION_DOCTOR_NOT_READY_STATUS = "extension_doctor_not_ready" MAX_REVISIONS = 5 # Requalification is required on eviction; unprobed artifacts never activate. MAX_VERIFIED_ENTRYPOINTS = 32 @@ -895,7 +900,7 @@ def _optional_binding_for_entry( ) if not entry.get("enabled"): return _optional_binding_resolution( - status="extension_disabled", + status=EXTENSION_DISABLED_STATUS, extension_id=extension_id, installed=True, enabled=False, @@ -904,7 +909,7 @@ def _optional_binding_for_entry( ) if verified_entrypoint is None: return _optional_binding_resolution( - status="extension_doctor_not_ready", + status=EXTENSION_DOCTOR_NOT_READY_STATUS, extension_id=extension_id, installed=True, enabled=True, diff --git a/tests/extensions/test_extension_runtime.py b/tests/extensions/test_extension_runtime.py index 90d8f81e60..f10aee3332 100644 --- a/tests/extensions/test_extension_runtime.py +++ b/tests/extensions/test_extension_runtime.py @@ -281,10 +281,133 @@ def test_capability_action_factory_failure_is_content_free_and_kernel_independen ) assert discovery.ports == () + assert discovery.state_error_code is None assert len(discovery.failures) == 1 assert discovery.failures[0].adapter_id == "sample-report-source" assert discovery.failures[0].error_code == "extension_hook_adapter_unavailable" + # An unreadable state file is a typed discovery-level condition, not an + # empty catalog: callers can tell "no provider installed" from "state broken". + state_file.write_text("{not json", encoding="utf-8") + broken = discover_extension_hook_adapters( + state_file=state_file, + phase="capability_action", + capability_id="sample-report", + target_hook_id="sample_report.request", + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="sample-goal", + agent_id="sample-agent", + ) + assert broken.ports == () + assert broken.failures == () + assert broken.state_error_code == "extension_state_unavailable" + + +def test_capability_action_lifecycle_non_admission_is_named_without_factory_import( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx.extensions import hook_adapters + + provider = _provider(tmp_path / "provider") + manifest_path = _standalone_manifest( + tmp_path / "extension.toml", + entrypoint=provider, + permission="semantic_preference.read", + ) + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8") + + """ + +[[hook_adapters]] +id = "sample-report-source" +capability_id = "sample-report" +target_hook_id = "sample_report.request" +phase = "capability_action" +factory = "sample_extension.hooks:build_adapter" +required_permissions = ["semantic_preference.read"] +ports = ["sample_report.request.bind_source"] + +[[hook_adapters]] +id = "other-hook-source" +capability_id = "sample-report" +target_hook_id = "sample_report.other" +phase = "capability_action" +factory = "sample_extension.hooks:build_adapter" +required_permissions = ["semantic_preference.read"] +ports = ["sample_report.other.bind_source"] +""", + encoding="utf-8", + ) + state_file = tmp_path / "runtime" / "extensions" / "state.json" + install_extension(manifest_path, state_file=state_file, execute=True) + factory_loads: list[str] = [] + + def record_factory_load(reference: str) -> object: + factory_loads.append(reference) + raise ValueError("synthetic factory is not importable") + + monkeypatch.setattr(hook_adapters, "_load_factory", record_factory_load) + + def discover() -> object: + return discover_extension_hook_adapters( + state_file=state_file, + phase="capability_action", + capability_id="sample-report", + target_hook_id="sample_report.request", + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="sample-goal", + agent_id="sample-agent", + ) + + def named_failures(discovery: object) -> list[tuple[str, str, str]]: + return [ + (failure.extension_id, failure.adapter_id, failure.error_code) + for failure in discovery.failures + ] + + # Positive control: a ready extension reaches the factory loader. + ready = discover() + assert named_failures(ready) == [ + ( + "test-standalone-extension", + "sample-report-source", + "extension_hook_adapter_unavailable", + ) + ] + assert factory_loads == ["sample_extension.hooks:build_adapter"] + factory_loads.clear() + + disable_extension("test-standalone-extension", state_file=state_file, execute=True) + disabled_state = state_file.read_bytes() + disabled = discover() + assert disabled.ports == () + assert disabled.state_error_code is None + assert named_failures(disabled) == [ + ("test-standalone-extension", "sample-report-source", "extension_disabled") + ] + assert factory_loads == [] + assert state_file.read_bytes() == disabled_state + + enable_extension("test-standalone-extension", state_file=state_file, execute=True) + # Changing the doctor-verified executable invalidates its runtime identity. + _provider(provider, doctor_exit=2) + stale_state = state_file.read_bytes() + stale = discover() + assert stale.ports == () + assert stale.state_error_code is None + assert named_failures(stale) == [ + ( + "test-standalone-extension", + "sample-report-source", + "extension_doctor_not_ready", + ) + ] + assert factory_loads == [] + assert state_file.read_bytes() == stale_state + @pytest.mark.parametrize( "extension_id", diff --git a/tests/extensions/test_lark_periodic_report_request.py b/tests/extensions/test_lark_periodic_report_request.py index 7b8c2a77a9..c89cd88052 100644 --- a/tests/extensions/test_lark_periodic_report_request.py +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -1,6 +1,8 @@ from __future__ import annotations +import hashlib import json +import sys from pathlib import Path import pytest @@ -23,7 +25,7 @@ ) from loopx.extensions.lark.goal_channel_targets import add_lark_goal_channel_target from loopx.extensions.lark.event_inbox import _event_from_payload -from loopx.extensions.runtime import install_extension +from loopx.extensions.runtime import disable_extension, install_extension from loopx.extensions.lark import periodic_report_request @@ -270,6 +272,303 @@ def settle_source(**_kwargs: object) -> dict[str, object]: ports.select_source_adapter(None) +def test_request_ports_name_declared_adapters_that_failed_activation( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A declared adapter that did not activate is reported, not dropped. + + Discovery already isolates factory/lifecycle failures as content-free + records; the request path used to discard them, so an operator saw only + "source adapter is unavailable" with no adapter id or next action. + """ + from loopx.extensions.hook_adapters import ExtensionHookAdapterFailure + + def bind_source(**_kwargs: object) -> dict[str, object]: + return {} + + def settle_source(**_kwargs: object) -> dict[str, object]: + return {} + + def port(adapter_id: str, port_name: str) -> ExtensionHookAdapterPortBinding: + return ExtensionHookAdapterPortBinding( + extension_id=f"fixture-{adapter_id}", + adapter_id=adapter_id, + capability_id="periodic-report", + target_hook_id=REQUEST_HOOK_ID, + phase=REQUEST_ADAPTER_PHASE, + port_name=port_name, + handler=bind_source if port_name == REQUEST_BIND_PORT else settle_source, + ) + + failure = ExtensionHookAdapterFailure( + extension_id="fixture-source-broken", + adapter_id="source-broken", + capability_id="periodic-report", + target_hook_id=REQUEST_HOOK_ID, + phase=REQUEST_ADAPTER_PHASE, + ) + monkeypatch.setattr( + request_action, + "discover_extension_hook_adapters", + lambda **_kwargs: ExtensionHookAdapterDiscovery( + ports=( + port("source-partial", REQUEST_BIND_PORT), + port("source-dup", REQUEST_BIND_PORT), + port("source-dup", REQUEST_BIND_PORT), + port("source-dup", REQUEST_SETTLE_PORT), + ), + failures=(failure,), + ), + ) + ports = discover_periodic_report_request_ports( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + ) + + assert ports.adapter_ids == () + assert ports.failed_adapters == { + "source-broken": "extension_hook_adapter_unavailable", + "source-dup": "ambiguous_adapter_ports", + "source-partial": "incomplete_adapter_ports", + } + assert ports.state_error_code is None + with pytest.raises(ValueError) as named: + ports.select_source_adapter("source-broken") + assert "source-broken is declared but was not activated" in str(named.value) + assert "extension_hook_adapter_unavailable" in str(named.value) + assert "loopx extension doctor" in str(named.value) + with pytest.raises(ValueError) as unknown: + ports.select_source_adapter("never-declared") + assert str(unknown.value).endswith(": never-declared") + with pytest.raises(ValueError) as unselected: + ports.select_source_adapter(None) + assert "declared adapters were not activated" in str(unselected.value) + assert "source-broken (extension_hook_adapter_unavailable)" in str(unselected.value) + assert "source-dup (ambiguous_adapter_ports)" in str(unselected.value) + + +def test_request_ports_name_unreadable_extension_state( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + request_action, + "discover_extension_hook_adapters", + lambda **_kwargs: ExtensionHookAdapterDiscovery( + ports=(), failures=(), state_error_code="extension_state_unavailable" + ), + ) + ports = discover_periodic_report_request_ports( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + ) + assert ports.state_error_code == "extension_state_unavailable" + with pytest.raises(ValueError, match="extension state could not be read"): + ports.select_source_adapter(None) + + +_PROBE_FACTORY_MODULE = "loopx_probe_periodic_report_source_hooks" + + +def _install_probe_source_extension( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> tuple[Path, Path]: + """Install an effect-free source extension through the real lifecycle.""" + + package_root = tmp_path / "probe-package" + package_root.mkdir() + (package_root / f"{_PROBE_FACTORY_MODULE}.py").write_text( + "def build_adapter(context):\n" + " def refuse(**_kwargs):\n" + " raise AssertionError('probe ports must not be invoked')\n" + f" return {{{REQUEST_BIND_PORT!r}: refuse, {REQUEST_SETTLE_PORT!r}: refuse}}\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(package_root)) + monkeypatch.delitem(sys.modules, _PROBE_FACTORY_MODULE, raising=False) + provider = tmp_path / "probe-provider" + provider.write_text(f"#!{sys.executable}\nraise SystemExit(0)\n", encoding="utf-8") + provider.chmod(0o755) + manifest = tmp_path / "extension.toml" + adapter = """ +[[hook_adapters]] +id = "{adapter_id}" +capability_id = "{capability_id}" +target_hook_id = "{hook_id}" +phase = "{phase}" +factory = "{module}:build_adapter" +required_permissions = ["probe.source.read"] +ports = [{ports}] +""" + manifest.write_text( + f"""\ +schema_version = "loopx_extension_manifest_v0" +id = "probe-extension" +version = "1.0.0" +requires_loopx_api = ">=1,<2" +permissions = ["probe.source.read"] + +[runtime] +protocol = "probe_extension_lifecycle_v0" +entrypoint = {json.dumps(str(provider))} +doctor_args = ["--doctor"] +required_permissions = [] +timeout_seconds = 5 +""" + + adapter.format( + adapter_id="probe-source", + capability_id="periodic-report", + hook_id=REQUEST_HOOK_ID, + phase=REQUEST_ADAPTER_PHASE, + module=_PROBE_FACTORY_MODULE, + ports=f'"{REQUEST_BIND_PORT}", "{REQUEST_SETTLE_PORT}"', + ) + # Same extension, another capability's hook: outside this request scope. + + adapter.format( + adapter_id="other-hook-source", + capability_id="sample-report", + hook_id="sample_report.request", + phase=REQUEST_ADAPTER_PHASE, + module=_PROBE_FACTORY_MODULE, + ports='"sample_report.request.bind_source"', + ), + encoding="utf-8", + ) + state_file = tmp_path / "runtime" / "extensions" / "state.json" + installed = install_extension(manifest, state_file=state_file, execute=True) + assert installed["doctor"]["verified"] is True + return provider, state_file + + +def _tree_digest(root: Path, *, exclude: Path) -> dict[str, str]: + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) + if path.is_file() and exclude not in path.parents + } + + +def _request_cli( + capsys: pytest.CaptureFixture[str], + *, + registry: Path, + runtime_root: Path, + selector: str | None, +) -> tuple[int, dict[str, object]]: + from loopx.cli import main + + code = main( + [ + "--registry", + str(registry), + "--runtime-root", + str(runtime_root), + "--format", + "json", + "periodic-report", + "request", + "--goal-id", + "goal-alpha", + "--agent-id", + "agent-alpha", + "--source-ref", + "om_probe_request", + *(["--source-adapter-id", selector] if selector else []), + "--execute", + ] + ) + return code, json.loads(capsys.readouterr().out) + + +def test_request_cli_without_installed_extensions_keeps_the_plain_refusal( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": []}), encoding="utf-8") + for selector, suffix in ((None, ""), ("never-declared", ": never-declared")): + code, payload = _request_cli( + capsys, + registry=registry, + runtime_root=tmp_path / "runtime", + selector=selector, + ) + assert (code, payload["ok"]) == (1, False) + assert payload["error"] == ( + "periodic-report request source adapter is unavailable" + suffix + ) + assert sorted(path.name for path in tmp_path.iterdir()) == ["registry.json"] + + +@pytest.mark.parametrize( + ("lifecycle", "expected_code"), + [ + ("disabled", "extension_disabled"), + ("doctor_stale", "extension_doctor_not_ready"), + ("state_unreadable", "extension_state_unavailable"), + ], +) +def test_request_cli_names_real_lifecycle_loss_for_every_selector( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + lifecycle: str, + expected_code: str, +) -> None: + provider, state_file = _install_probe_source_extension(tmp_path, monkeypatch) + runtime_root = state_file.parent.parent + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": []}), encoding="utf-8") + + # Positive control: while ready, the probe is one complete adapter and its + # factory is imported, so "not imported" below is not vacuous. + ready = discover_periodic_report_request_ports( + registry_path=registry, + runtime_root=runtime_root, + goal_id="goal-alpha", + agent_id="agent-alpha", + ) + assert ready.adapter_ids == ("probe-source",) + assert ready.failed_adapters == {} + assert _PROBE_FACTORY_MODULE in sys.modules + del sys.modules[_PROBE_FACTORY_MODULE] + + if lifecycle == "disabled": + disable_extension("probe-extension", state_file=state_file, execute=True) + elif lifecycle == "doctor_stale": + # Changing the doctor-verified executable invalidates its identity. + provider.write_text( + provider.read_text(encoding="utf-8") + "# changed\n", encoding="utf-8" + ) + else: + state_file.write_text("{not json", encoding="utf-8") + before = _tree_digest(tmp_path, exclude=tmp_path / "probe-package") + + for selector in (None, "probe-source", "never-declared"): + code, payload = _request_cli( + capsys, registry=registry, runtime_root=runtime_root, selector=selector + ) + error = str(payload["error"]) + assert (code, payload["ok"]) == (1, False), (selector, payload) + assert error.startswith("periodic-report request source adapter is unavailable") + if selector: + assert selector in error + if lifecycle == "state_unreadable": + # Unreadable state hides every declaration, known or not. + assert f"extension state could not be read ({expected_code})" in error + elif selector == "never-declared": + assert "probe-source" not in error and expected_code not in error + else: + assert "probe-source" in error and f"({expected_code})" in error + assert "loopx extension doctor" in error + assert "other-hook-source" not in error + assert _PROBE_FACTORY_MODULE not in sys.modules + assert _tree_digest(tmp_path, exclude=tmp_path / "probe-package") == before + assert not (runtime_root / "goals").exists() + + def test_lark_inbox_rejects_same_name_mention_with_different_provider_identity() -> ( None ):