mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-02 19:58:46 +00:00
The split left every full-book reader pointing at what is now a membership page. `load_governance_doc` returned 1,744 chars of orientation where the triad, preflight, scope and plan-review prompts believed they had the architecture map, and nothing caught it: those surfaces are covered on synthetic `tmp_path` corpora, so the loss only shows in production packs. `load_governance_doc` now composes a book entrypoint, and an unassemblable book is a named omission rather than a page that reads like a delivered book. The canonical-five list had four hand-maintained copies. It has one owner now (`review_helpers.CANONICAL_GOVERNANCE_DOCS`) with two predicates beside it: `is_canonical_governance_path`, which answers for a chapter exactly as it answers for its entrypoint, and `canonical_governance_sources`, which resolves the population of an actual tree so a pack that inlined a composed book does not also owe its chapters as separate snapshots. `reference_books.book_path_role` and `book_entrypoint_for` are the pure path-shape question underneath, so no consumer carries a list of 27 filenames that goes stale the next time a book gains or splits a chapter. Without this, a commit touching `docs/development/06-rules-by-change-class.md` would have stopped being canonical-doc work: degradable to diff-only in the scope ladder, and an ordinary changed file to the atlas. Three more consequences of the same fact: * the triad pack's duplicate suppression asks the byte-identity question of a MEMBER — a touched chapter is withheld when its current bytes already sit in the composed copy the prefix carries, and a chapter edited after the prefix was rendered keeps its full text; * the new-module documentation gate accepts any staged source of the Architecture book. A new module is documented in the chapter that owns its subsystem, and demanding an entrypoint edit would only buy a membership-list touch that documents nothing; * untruncated repository reads take `UNTRUNCATED_REPO_READ_PREFIXES` (prompts plus the two chapter directories) rather than a chapter list. Four chapters exceed the 80,000-char `read_file` result cap, and a silently capped chapter is a partial governance source that reads like a complete one. Test side: `tests/_governance_docs_shared.py` is the one reader tests use for a governance document's complete text. Sixteen suites read an entrypoint with `Path.read_text()` and asserted body prose; two of them PASSED while testing nothing, because their pins are negatives. The atlas drift guard over three synchronized canonical lists becomes an identity guard over the one owner, and the retired-timeout grep allowlists a book rather than a filename. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
573 lines
24 KiB
Python
573 lines
24 KiB
Python
"""Skill-widget surface regressions: preflight entry truth, reconcile receipts, catalogue liveness.
|
|
|
|
Single home for this stream's Python pins so no existing oversized test module
|
|
grows. No network, no real LLM calls; every case runs against an isolated
|
|
tmp_path drive root.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import pathlib
|
|
|
|
import pytest
|
|
|
|
from ouroboros.tools.registry import ToolContext
|
|
from tests._governance_docs_shared import architecture_text
|
|
|
|
|
|
def _make_ctx(tmp_path: pathlib.Path) -> ToolContext:
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
return ToolContext(repo_dir=repo_dir, drive_root=drive_root)
|
|
|
|
|
|
def _extension_manifest(name: str = "alpha", *, ui_tab: str = "") -> str:
|
|
return (
|
|
"---\n"
|
|
f"name: {name}\n"
|
|
"description: widget surface test\n"
|
|
"version: 0.1.0\n"
|
|
"type: extension\n"
|
|
"entry: plugin.py\n"
|
|
"permissions: [widget]\n"
|
|
f"{ui_tab}"
|
|
"---\n"
|
|
"body\n"
|
|
)
|
|
|
|
|
|
def _module_ui_tab(entry: str = "widget.js") -> str:
|
|
return (
|
|
"ui_tab:\n"
|
|
" id: main\n"
|
|
" title: Main\n"
|
|
" render:\n"
|
|
" kind: module\n"
|
|
f" entry: {entry}\n"
|
|
" height: 480\n"
|
|
)
|
|
|
|
|
|
def _make_skill(tmp_path: pathlib.Path, monkeypatch, manifest: str, plugin: str) -> pathlib.Path:
|
|
skills_root = tmp_path / "skills"
|
|
skills_root.mkdir(exist_ok=True)
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(skills_root))
|
|
skill_dir = skills_root / "alpha"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(manifest, encoding="utf-8")
|
|
(skill_dir / "plugin.py").write_text(plugin, encoding="utf-8")
|
|
return skill_dir
|
|
|
|
|
|
_TRIVIAL_PLUGIN = "def register(api):\n pass\n"
|
|
|
|
|
|
# --------------------------------------------------------------------------- F12
|
|
|
|
|
|
def test_skill_preflight_flags_missing_module_widget_entry(tmp_path, monkeypatch):
|
|
"""A declared module entry that is not on disk must not read as verified ok."""
|
|
ctx = _make_ctx(tmp_path)
|
|
skill_dir = _make_skill(
|
|
tmp_path,
|
|
monkeypatch,
|
|
_extension_manifest(ui_tab=_module_ui_tab()),
|
|
_TRIVIAL_PLUGIN,
|
|
)
|
|
|
|
from ouroboros.tools import skill_preflight as sp
|
|
|
|
result = json.loads(sp._handle_skill_preflight(ctx, skill="alpha"))
|
|
entry_rows = [row for row in result["widgets"] if row["item"] == "widget_entry_exists"]
|
|
assert entry_rows, result["widgets"]
|
|
assert result["ok"] is False
|
|
assert entry_rows[0]["ok"] is False
|
|
assert "widget.js" in entry_rows[0]["detail"]
|
|
assert entry_rows[0]["source"] == "manifest.ui_tab.render"
|
|
|
|
# Same declaration, file now present: the row flips and names the entry.
|
|
(skill_dir / "widget.js").write_text("const a = 1;\n", encoding="utf-8")
|
|
result = json.loads(sp._handle_skill_preflight(ctx, skill="alpha"))
|
|
entry_rows = [row for row in result["widgets"] if row["item"] == "widget_entry_exists"]
|
|
assert entry_rows[0]["ok"] is True
|
|
assert entry_rows[0]["detail"] == "widget.js"
|
|
|
|
|
|
def test_skill_preflight_checks_plugin_registered_module_entry(tmp_path, monkeypatch):
|
|
"""The plugin.py register_ui_tab path is covered without touching the AST walker."""
|
|
ctx = _make_ctx(tmp_path)
|
|
_make_skill(
|
|
tmp_path,
|
|
monkeypatch,
|
|
_extension_manifest(),
|
|
"_UI_RENDER = {'kind': 'module', 'entry': 'missing.js', 'height': 400}\n"
|
|
"def register(api):\n"
|
|
" api.register_ui_tab('main', 'Main', render=_UI_RENDER)\n",
|
|
)
|
|
|
|
from ouroboros.tools import skill_preflight as sp
|
|
|
|
result = json.loads(sp._handle_skill_preflight(ctx, skill="alpha"))
|
|
entry_rows = [row for row in result["widgets"] if row["item"] == "widget_entry_exists"]
|
|
assert entry_rows, result["widgets"]
|
|
assert result["ok"] is False
|
|
assert entry_rows[0]["ok"] is False
|
|
assert "missing.js" in entry_rows[0]["detail"]
|
|
assert entry_rows[0]["source"].startswith("plugin.py:")
|
|
|
|
|
|
def test_noncanonical_module_entry_fails_preflight_and_runtime_validation(
|
|
tmp_path, monkeypatch
|
|
):
|
|
from ouroboros.contracts.plugin_api import ExtensionRegistrationError
|
|
from ouroboros.extension_ui_validation import validate_ui_render
|
|
from ouroboros.tools import skill_preflight as sp
|
|
|
|
ctx = _make_ctx(tmp_path)
|
|
_make_skill(
|
|
tmp_path,
|
|
monkeypatch,
|
|
_extension_manifest(ui_tab=_module_ui_tab("widget space.js")),
|
|
_TRIVIAL_PLUGIN,
|
|
)
|
|
|
|
result = json.loads(sp._handle_skill_preflight(ctx, skill="alpha"))
|
|
rows = [row for row in result["widgets"] if row["item"] == "widget_schema"]
|
|
assert result["ok"] is False
|
|
assert rows[0]["ok"] is False
|
|
assert rows[0]["verified"] is True
|
|
assert "browser-safe" in rows[0]["detail"]
|
|
with pytest.raises(ExtensionRegistrationError, match="browser-safe"):
|
|
validate_ui_render({"kind": "module", "entry": "widget space.js"})
|
|
|
|
|
|
# --------------------------------------------------------------------------- F13
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "expect_block"),
|
|
[("export const a = 1;\n", True), ("const a = 1;\n", False)],
|
|
)
|
|
def test_skill_preflight_parses_module_entry_as_classic_script(
|
|
tmp_path, monkeypatch, source, expect_block
|
|
):
|
|
"""The declared entry is checked in the grammar the frame actually runs it in."""
|
|
ctx = _make_ctx(tmp_path)
|
|
skill_dir = _make_skill(
|
|
tmp_path,
|
|
monkeypatch,
|
|
_extension_manifest(ui_tab=_module_ui_tab()),
|
|
_TRIVIAL_PLUGIN,
|
|
)
|
|
(skill_dir / "widget.js").write_text(source, encoding="utf-8")
|
|
|
|
from ouroboros.tools import skill_preflight as sp
|
|
|
|
result = json.loads(sp._handle_skill_preflight(ctx, skill="alpha"))
|
|
rows = [row for row in result["files"] if row["path"] == "widget.js"]
|
|
assert rows, result["files"]
|
|
row = rows[0]
|
|
assert row["grammar"] == "classic_script"
|
|
if row.get("skipped"):
|
|
# No usable node runtime: the honest skip branch, never a verdict. The
|
|
# grammar assertion above still held, so the selection is covered.
|
|
assert row["skip_reason"] in {"runtime_unavailable", "validator_killed", "validator_timeout"}
|
|
assert result["degraded"] is True
|
|
pytest.skip(f"no usable node runtime for the classic-script check ({row['skip_reason']})")
|
|
if expect_block:
|
|
assert row["ok"] is False
|
|
assert result["ok"] is False
|
|
else:
|
|
assert row["ok"] is True
|
|
|
|
|
|
def test_skill_preflight_validator_env_keeps_windows_process_base_keys(tmp_path, monkeypatch):
|
|
"""The scrubbed validator env still lets a Windows child start.
|
|
|
|
A node started without SystemRoot aborts before it reads the script, so the
|
|
valid entry above read as a syntax error on windows-latest (7.0.0-rc.9).
|
|
Pinned on every host by simulating that environment: the process-base keys
|
|
are forwarded, everything else stays scrubbed, a POSIX env is byte-identical
|
|
to before, and the validator's pipes stay BYTES decoded as UTF-8 with
|
|
replacement -- the 0x8f that kills a locale-decoded (cp1252) reader thread
|
|
is inert here.
|
|
"""
|
|
from ouroboros.tools import skill_preflight as sp
|
|
|
|
seen: dict = {}
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
pid = 4242
|
|
|
|
def communicate(self, timeout=None):
|
|
return b"", b"\x8f\xff not utf-8"
|
|
|
|
def _fake_popen(cmd, **kwargs):
|
|
seen["cmd"] = list(cmd)
|
|
seen["kwargs"] = kwargs
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(sp, "Popen", _fake_popen)
|
|
for key in sp._WINDOWS_BASE_ENV_KEYS:
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "must-not-reach-the-validator")
|
|
|
|
result = sp._run_check(["node", "--check", "widget.js"], cwd=tmp_path)
|
|
posix_env = seen["kwargs"]["env"]
|
|
assert set(posix_env) == {"PATH", "HOME", "LANG"}
|
|
assert posix_env["LANG"] == "C.UTF-8"
|
|
assert not any(key in seen["kwargs"] for key in ("text", "universal_newlines", "encoding"))
|
|
assert result["returncode"] == 0
|
|
assert result["stderr"] == "\ufffd\ufffd not utf-8"
|
|
|
|
monkeypatch.setenv("SYSTEMROOT", "C:\\Windows")
|
|
monkeypatch.setenv("TEMP", "C:\\Users\\runneradmin\\AppData\\Local\\Temp")
|
|
sp._run_check(["node", "--check", "widget.js"], cwd=tmp_path)
|
|
windows_env = seen["kwargs"]["env"]
|
|
assert windows_env["SYSTEMROOT"] == "C:\\Windows"
|
|
assert windows_env["TEMP"] == "C:\\Users\\runneradmin\\AppData\\Local\\Temp"
|
|
assert set(windows_env) == {"PATH", "HOME", "LANG", "SYSTEMROOT", "TEMP"}
|
|
assert "OPENROUTER_API_KEY" not in windows_env
|
|
|
|
|
|
# ------------------------------------------------------------- S1-07 / S1-02 / F14
|
|
|
|
|
|
def _prepare_live_extension(tmp_path: pathlib.Path, name: str = "extlive"):
|
|
"""Write, enable and PASS-review one extension so the loader will accept it."""
|
|
from ouroboros.skill_loader import SkillReviewState, find_skill, save_enabled, save_review_state
|
|
|
|
repo_root = tmp_path / "skills"
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir(exist_ok=True)
|
|
skill_dir = repo_root / name
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
f"name: {name}\n"
|
|
"description: Live extension.\n"
|
|
"version: 0.1.0\n"
|
|
"type: extension\n"
|
|
"entry: plugin.py\n"
|
|
'permissions: ["tool"]\n'
|
|
"---\n"
|
|
"body\n",
|
|
encoding="utf-8",
|
|
)
|
|
(skill_dir / "plugin.py").write_text(
|
|
"def register(api):\n"
|
|
" api.register_tool('ping', lambda ctx: 'pong', description='Ping.', schema={})\n",
|
|
encoding="utf-8",
|
|
)
|
|
loaded = find_skill(drive_root, name, repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
save_enabled(drive_root, name, True, actor="test_fixture")
|
|
save_review_state(drive_root, name, SkillReviewState(status="pass", content_hash=loaded.content_hash))
|
|
loaded = find_skill(drive_root, name, repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
return loaded, repo_root, drive_root
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_loader_state(monkeypatch):
|
|
from tests._shared import clean_extension_runtime_state
|
|
|
|
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
|
clean_extension_runtime_state()
|
|
yield
|
|
clean_extension_runtime_state()
|
|
|
|
|
|
|
|
def _set_process_role(monkeypatch, server: bool) -> None:
|
|
"""Pin which process "answers" a reconcile.
|
|
|
|
v7 split: the loader stamps its own receipts through its imported binding,
|
|
while the reconcile queue and the liveness projection read the OWNER
|
|
(``extension_companion.is_server_process``) at call time — so the pin must
|
|
land on both, or the receipt vocabulary (``requested`` / ``request_failed``)
|
|
is decided by whatever process-pid state an earlier test left behind."""
|
|
import ouroboros.extension_companion as extension_companion
|
|
import ouroboros.extension_loader as extension_loader
|
|
|
|
monkeypatch.setattr(extension_companion, "is_server_process", lambda: server)
|
|
monkeypatch.setattr(extension_loader, "is_server_process", lambda: server)
|
|
|
|
def test_reconcile_receipt_names_the_answering_process(tmp_path, monkeypatch):
|
|
"""A reconcile receipt says which process answered and whether the marker landed."""
|
|
from ouroboros import extension_loader
|
|
|
|
loaded, repo_root, drive_root = _prepare_live_extension(tmp_path)
|
|
|
|
_set_process_role(monkeypatch, True)
|
|
state = extension_loader.reconcile_extension(
|
|
loaded.name, drive_root, lambda: {}, repo_path=str(repo_root)
|
|
)
|
|
assert state["process"] == "server"
|
|
assert state["server_reconcile"] == ""
|
|
|
|
extension_loader.unload_extension(loaded.name)
|
|
_set_process_role(monkeypatch, False)
|
|
state = extension_loader.reconcile_extension(
|
|
loaded.name, drive_root, lambda: {}, repo_path=str(repo_root)
|
|
)
|
|
assert state["process"] == "worker"
|
|
assert state["server_reconcile"] == "requested"
|
|
assert list((drive_root / "state" / "extension_reconcile").glob("*")), "no marker written"
|
|
|
|
|
|
def test_reconcile_records_health_for_the_resulting_runtime_state(tmp_path, monkeypatch):
|
|
"""Each reconcile receipt updates the durable health projection."""
|
|
from ouroboros import extension_health, extension_loader
|
|
from ouroboros.skill_loader import save_enabled
|
|
|
|
loaded, repo_root, drive_root = _prepare_live_extension(tmp_path)
|
|
_set_process_role(monkeypatch, True)
|
|
|
|
state = extension_loader.reconcile_extension(
|
|
loaded.name, drive_root, lambda: {}, repo_path=str(repo_root)
|
|
)
|
|
assert state["live_loaded"] is True
|
|
assert (extension_health.read_extension_health(drive_root, loaded.name) or {})["status"] == "live"
|
|
|
|
save_enabled(drive_root, loaded.name, False, actor="test_fixture")
|
|
state = extension_loader.reconcile_extension(
|
|
loaded.name, drive_root, lambda: {}, repo_path=str(repo_root)
|
|
)
|
|
assert state["desired_live"] is False
|
|
assert (extension_health.read_extension_health(drive_root, loaded.name) or {})["status"] == "inactive"
|
|
|
|
|
|
def test_reconcile_receipt_reports_a_failed_marker_request(tmp_path, monkeypatch):
|
|
"""A failed worker handoff cannot replace the authoritative server health."""
|
|
from ouroboros import extension_health, extension_loader
|
|
from ouroboros import extension_reconcile_queue
|
|
|
|
loaded, repo_root, drive_root = _prepare_live_extension(tmp_path)
|
|
extension_health.record_extension_health(
|
|
drive_root, loaded.name, status="live", version="0.0.1", sha="server-good",
|
|
)
|
|
extension_health.record_extension_health(
|
|
drive_root, loaded.name, status="broken", version="0.0.2", sha="shared-sha",
|
|
reason="load_error", load_error="server import failed",
|
|
)
|
|
_set_process_role(monkeypatch, False)
|
|
|
|
def boom(*_a, **_k):
|
|
raise OSError("marker directory is read-only")
|
|
|
|
monkeypatch.setattr(extension_reconcile_queue, "request_extension_reconcile", boom)
|
|
state = extension_loader.reconcile_extension(
|
|
loaded.name, drive_root, lambda: {}, repo_path=str(repo_root),
|
|
health_stamp=("0.0.2", "shared-sha"),
|
|
)
|
|
health = extension_health.read_extension_health(drive_root, loaded.name) or {}
|
|
server = health.get("last_observed") or {}
|
|
worker = (health.get("observations") or {}).get("worker") or {}
|
|
|
|
assert state["process"] == "worker"
|
|
assert state["server_reconcile"] == "request_failed"
|
|
assert state["action"] == "extension_loaded"
|
|
assert health["status"] == "broken"
|
|
assert health["regressed"] is True
|
|
assert health["last_known_good"]["sha"] == "server-good"
|
|
assert server["status"] == "broken"
|
|
assert server["sha"] == "shared-sha"
|
|
assert worker["status"] == "live"
|
|
assert worker["sha"] == "shared-sha"
|
|
assert worker["server_reconcile"] == "request_failed"
|
|
monkeypatch.setattr("ouroboros.skill_loader.find_skill", lambda *_a, **_k: object())
|
|
monkeypatch.setattr("ouroboros.skill_loader.load_enabled", lambda *_a, **_k: True)
|
|
assert [row["skill"] for row in extension_health.regressed_extensions(drive_root)] == [
|
|
loaded.name
|
|
]
|
|
|
|
|
|
def test_toggle_skill_receipt_carries_process_and_marker_outcome(tmp_path, monkeypatch):
|
|
"""The agent-facing toggle receipt is where the 'already_live/ready' misreading happened."""
|
|
from ouroboros.tools import skill_exec as skill_exec_mod
|
|
|
|
loaded, repo_root, drive_root = _prepare_live_extension(tmp_path)
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
ctx = ToolContext(repo_dir=tmp_path / "repo2", drive_root=drive_root)
|
|
(tmp_path / "repo2").mkdir()
|
|
|
|
payload = json.loads(skill_exec_mod._handle_toggle_skill(ctx, skill=loaded.name, enabled=True))
|
|
assert payload["process"] in {"server", "worker"}
|
|
assert payload["server_reconcile"] in {"", "requested", "request_failed"}
|
|
|
|
|
|
def test_save_enabled_appends_one_typed_actor_row(tmp_path):
|
|
"""Enablement changes leave a durable, non-rotating, actor-attributed record."""
|
|
from ouroboros.skill_loader import save_enabled
|
|
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
save_enabled(drive_root, "alpha", True, actor="owner_ui", reason="client_host=127.0.0.1")
|
|
save_enabled(drive_root, "alpha", False, actor="agent_tool", reason="task-7")
|
|
save_enabled(drive_root, "alpha", True)
|
|
|
|
rows = [
|
|
json.loads(line)
|
|
for line in (drive_root / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
rows = [row for row in rows if row.get("type") == "skill_enabled_changed"]
|
|
assert [(r["enabled"], r["previous"], r["actor"]) for r in rows] == [
|
|
(True, False, "owner_ui"),
|
|
(False, True, "agent_tool"),
|
|
(True, False, ""),
|
|
]
|
|
assert rows[0]["reason"] == "client_host=127.0.0.1"
|
|
|
|
|
|
def test_save_enabled_row_is_disclosure_never_a_gate(tmp_path):
|
|
"""An unwritable logs path must not fail the enablement write."""
|
|
from ouroboros.skill_loader import load_enabled, save_enabled
|
|
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
(drive_root / "logs").write_text("not a directory", encoding="utf-8")
|
|
save_enabled(drive_root, "alpha", True, actor="owner_ui")
|
|
assert load_enabled(drive_root, "alpha") is True
|
|
|
|
|
|
def test_save_enabled_best_effort_disclosure_contract_is_documented():
|
|
from ouroboros.skill_loader import save_enabled
|
|
|
|
architecture = architecture_text()
|
|
assert "best-effort" in str(save_enabled.__doc__)
|
|
assert "append failure is logged and never blocks the enablement change" in architecture
|
|
|
|
|
|
def test_api_skill_toggle_records_the_owner_ui_actor(tmp_path, monkeypatch):
|
|
"""The HTTP owner toggle labels itself, so the incident class is reconstructible."""
|
|
from ouroboros.skill_loader import SkillReviewState, compute_content_hash, save_review_state
|
|
from tests.test_extensions_api import _make_client, _stop_patches, _write_ext
|
|
|
|
skills_root = tmp_path / "skills"
|
|
skill_dir = _write_ext(
|
|
skills_root,
|
|
"ext_actor",
|
|
permissions=["tool"],
|
|
plugin="def register(api):\n api.register_tool('t', lambda ctx: 'ok', description='', schema={})\n",
|
|
)
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(skills_root))
|
|
client, drive_root, _patches = _make_client(tmp_path, monkeypatch)
|
|
try:
|
|
save_review_state(
|
|
drive_root,
|
|
"ext_actor",
|
|
SkillReviewState(
|
|
status="pass",
|
|
content_hash=compute_content_hash(skill_dir, manifest_entry="plugin.py"),
|
|
),
|
|
)
|
|
resp = client.post("/api/skills/ext_actor/toggle", json={"enabled": True})
|
|
assert resp.status_code == 200, resp.text
|
|
finally:
|
|
client.close()
|
|
_stop_patches(_patches) # the started patches (the password resolver among them) must not outlive the test
|
|
|
|
rows = [
|
|
json.loads(line)
|
|
for line in (drive_root / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
rows = [r for r in rows if r.get("type") == "skill_enabled_changed" and r.get("skill") == "ext_actor"]
|
|
assert rows, "the owner toggle left no enablement row"
|
|
assert rows[-1]["actor"] == "owner_ui"
|
|
assert rows[-1]["enabled"] is True
|
|
# Shared lifecycle owner keeps transport provenance in its structured
|
|
# owner_api_action row; skill_enabled_changed remains the state-change fact.
|
|
action_rows = [json.loads(line) for line in (drive_root / "logs" / "events.jsonl").read_text().splitlines()
|
|
if line.strip()]
|
|
action_rows = [row for row in action_rows if row.get("type") == "owner_api_action"
|
|
and row.get("skill") == "ext_actor" and row.get("action") == "skill_enable"]
|
|
assert len(action_rows) == 1
|
|
assert {key: action_rows[0][key] for key in ("actor", "skill", "action", "client_host", "ok")} == {
|
|
"actor": "owner_ui", "skill": "ext_actor", "action": "skill_enable",
|
|
"client_host": "testclient", "ok": True,
|
|
}
|
|
|
|
|
|
def test_summarize_skills_projects_live_extension_facts(tmp_path, monkeypatch):
|
|
"""The catalogue reports the same live facts as /api/extensions, not worker guesses."""
|
|
from ouroboros import skill_loader
|
|
from ouroboros.skill_loader import summarize_skills
|
|
|
|
loaded, repo_root, drive_root = _prepare_live_extension(tmp_path)
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
|
|
calls = {"n": 0}
|
|
real_discover = skill_loader.discover_skills
|
|
|
|
def counting(*a, **k):
|
|
calls["n"] += 1
|
|
return real_discover(*a, **k)
|
|
|
|
monkeypatch.setattr(skill_loader, "discover_skills", counting)
|
|
summary = summarize_skills(drive_root)
|
|
# One walk for the catalogue itself; `skills=` must stop the per-row re-walk.
|
|
assert calls["n"] == 1, f"discover_skills ran {calls['n']} times"
|
|
|
|
row = next(r for r in summary["skills"] if r["name"] == loaded.name)
|
|
assert row["desired_live"] is True
|
|
assert row["live_loaded"] is False
|
|
assert row["live_reason"]
|
|
assert row["process"] in {"server", "worker"}
|
|
assert row["available_for_execution"] is False
|
|
|
|
|
|
def test_skill_exec_extension_message_reports_typed_liveness(tmp_path, monkeypatch):
|
|
"""skill_exec no longer asserts register(api) ran for an extension that never loaded."""
|
|
from ouroboros.tools import skill_exec as skill_exec_mod
|
|
|
|
loaded, repo_root, drive_root = _prepare_live_extension(tmp_path)
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
ctx = ToolContext(repo_dir=tmp_path / "repo3", drive_root=drive_root)
|
|
(tmp_path / "repo3").mkdir()
|
|
|
|
out = skill_exec_mod._handle_skill_exec(ctx, skill=loaded.name, script="x.py")
|
|
assert "SKILL_EXEC_EXTENSION" in out
|
|
assert "live_loaded=False" in out
|
|
assert "has already been called" not in out
|
|
|
|
|
|
@pytest.mark.parametrize('registration', [True, False])
|
|
def test_documented_module_widget_recipe_creates_the_registered_tab(tmp_path, registration):
|
|
"""Execute the documentation example; manifest metadata alone stays metadata."""
|
|
import re
|
|
from tests._extension_loader_shared import _write_ext_skill
|
|
from ouroboros import extension_loader
|
|
from ouroboros.skill_loader import find_skill, save_enabled, save_review_state, SkillReviewState
|
|
from tests._shared import clean_extension_runtime_state
|
|
|
|
text = (pathlib.Path(__file__).resolve().parents[1] / 'docs/CREATING_SKILLS.md').read_text(encoding='utf-8')
|
|
section = text.split('### `kind: "module"` widgets', 1)[1].split('#### The in-frame bridge', 1)[0]
|
|
declaration = re.search(r'```yaml\n(.*?)```', section, re.S).group(1)
|
|
plugin = re.search(r'```python\n(.*?)```', section, re.S).group(1)
|
|
root, skills = tmp_path / 'drive', tmp_path / 'skills'
|
|
skill_dir = _write_ext_skill(skills, 'recipe', permissions=['widget'],
|
|
plugin_body=plugin if registration else 'def register(api):\n pass\n',
|
|
extra_frontmatter=declaration)
|
|
widget = "document.getElementById('root').textContent = 'Recipe widget ✓';"
|
|
(skill_dir / 'widget.js').write_text(widget, encoding='utf-8')
|
|
assert (skill_dir / 'widget.js').read_bytes() == widget.encode('utf-8')
|
|
loaded = find_skill(root, 'recipe', repo_path=str(skills))
|
|
save_enabled(root, 'recipe', True)
|
|
save_review_state(root, 'recipe', SkillReviewState(status='pass', content_hash=loaded.content_hash))
|
|
loaded = find_skill(root, 'recipe', repo_path=str(skills))
|
|
clean_extension_runtime_state()
|
|
try:
|
|
assert extension_loader.load_extension(loaded, lambda: {}, drive_root=root, repo_path=str(skills)) is None
|
|
rows = extension_loader.live_widget_projection('recipe')
|
|
assert len(rows) == (1 if registration else 0)
|
|
if registration:
|
|
assert rows[0]['tab']['render']['entry'] == 'widget.js'
|
|
assert rows[0]['tab']['title'] == 'Editor'
|
|
finally:
|
|
clean_extension_runtime_state()
|