d105376b21
* refactor(tools): discovery also scans tools/<pkg>/tool.py
A tool family that is a whole package had no way to register: discovery
globbed tools/*.py only and derived the module name from the filename.
Now the candidate list is tools/*.py plus tools/*/tool.py, merged and
sorted once so import order does not depend on depth (register() lets a
same-name duplicate overwrite silently), and the module name comes from
the path relative to tools/. Only tool.py is scanned inside a package,
so its siblings are libraries by construction. A package without an
__init__.py is skipped with a warning rather than registering from a
checkout and vanishing from the installed wheel.
The AST prefilter and the (mtime, size) disk cache are per absolute path
and work unchanged. The two hand-rolled tools/*.py enumerators in tests
now use the same candidate helper.
* refactor(connectors): one package for the connector domain, tools/connectors/
The connector code was spread across six flat files and a root-level
module that was a sibling of model_tools.py only by address:
tools/connections_tool.py -> tools/connectors/tool.py (schema, register, dispatcher)
tools/connectors/managed.py (the managed leg, split out)
tools/connections_tool_mcp.py -> tools/connectors/mcp.py (validation split out ->)
tools/connectors/targets.py (normalize_targets, validate_action)
tools/connections_tool_operation.py -> tools/connectors/operation.py
tools/connector_search.py -> tools/connectors/search.py
model_tools_connectors.py -> tools/connectors/dispatch.py
tools/tool_gateway/ -> tools/connectors/gateway/
Move only; every function body is unchanged. tools/connectors/__init__.py
is the door: nine names, the whole cross-package surface. model_tools and
tool_search deep-import a few helpers past it on purpose and the docstring
says so. The two split files make the import graph one-directional
(tool -> mcp -> targets, tool -> managed) where the old layout had
connections_tool importing validation out of the MCP file.
One behaviour-neutral seam change: the _connectors_available try/except
wrapper is gone. connectors_available() already fails closed, and both
the registry handler and the inline executor now read it as a module
attribute (gateway.config.connectors_available), so tests patch it in
one place instead of two. _default_client lives in managed.py, the only
module that calls it.
tools/managed_tool_gateway.py and tools/managed_gateway_auth.py stay:
they are gateway identity shared by tts, transcription, image and modal.
Test files follow their modules. No docs referenced the old paths; no
compat pointer is added (in-tree moves get none).
* ci: retrigger (zero-job dispatch on 142466de6b)
92 lines
4.0 KiB
Python
92 lines
4.0 KiB
Python
"""The committed TypeScript + OpenRPC contract files are exactly what ``tui_gateway/contracts``
|
|
renders, and the contract catalog covers the whole wire.
|
|
|
|
Regenerate with ``.venv/bin/python scripts/gen_gateway_contracts.py`` when a model changes. The
|
|
two files are listed in ``scripts/ci/classify_changes.py::_PY_RELEVANT_CONTRACT_FILES`` so a
|
|
TS-only PR that edits them still runs this test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parents[3]
|
|
GEN = REPO / "scripts" / "gen_gateway_contracts.py"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def gen():
|
|
spec = importlib.util.spec_from_file_location("gen_gateway_contracts", GEN)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_generated_files_are_current(gen):
|
|
"""Both committed artefacts equal an in-memory regeneration (byte-for-byte)."""
|
|
stale = [path.relative_to(REPO) for path, text in gen.render_all().items()
|
|
if (path.read_text(encoding="utf-8") if path.exists() else None) != text]
|
|
assert not stale, f"stale generated contract files {stale}: run scripts/gen_gateway_contracts.py"
|
|
|
|
|
|
# The emitter inventory the old gateway-events.json scan used, kept as the completeness oracle:
|
|
# names must come from CODE the gateway runs, never from the contract tables themselves.
|
|
_EMIT_HELPERS = ("_emit", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle")
|
|
_LITERAL_EMIT = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_EMIT_HELPERS))
|
|
_REQUEST_HELPERS = ("server_requests\\.send", "server_requests\\.send_async", "_ask", "_read_block")
|
|
_LITERAL_REQUEST = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_REQUEST_HELPERS))
|
|
_LITERAL_FRAME = re.compile(r"\"method\":\s*\"event\".{0,120}?\"type\":\s*\"([a-z_][a-z0-9_.]*)\"", re.S)
|
|
_SIDE_AGENT = re.compile(r"_spawn_side_agent\((?:[^()]|\([^()]*\))*?\"([a-z_][a-z0-9_.]*\.complete)\"", re.S)
|
|
_SUBAGENT_RELAY = re.compile(r"\"(subagent\.[a-z_]+)\"")
|
|
_DESKTOP_UI_EMIT = re.compile(r"desktop_ui\.(?:emit|emit_or_error)\(\s*\"([a-z_][a-z0-9_.]*)\"")
|
|
_BROKER_FRAME = re.compile(r"^FRAME_[A-Z_]+ = \"(browser\.controller\.[a-z_]+)\"", re.M)
|
|
_SETUP_READY = re.compile(r"^SETUP_READY_EVENT = \"([a-z_.]+)\"", re.M)
|
|
|
|
|
|
def _read(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def emitted_event_names() -> set[str]:
|
|
names: set[str] = set()
|
|
for src in (REPO / "tui_gateway").glob("*.py"):
|
|
text = _read(src)
|
|
names.update(_LITERAL_EMIT.findall(text))
|
|
names.update(_LITERAL_FRAME.findall(text))
|
|
names.update(_SIDE_AGENT.findall(text))
|
|
from tui_gateway.agent_callbacks import _CHILD_DELTA_EVENTS
|
|
from tui_gateway.change_watcher import _CHANGE_WATCHES
|
|
|
|
names.update(_CHANGE_WATCHES)
|
|
names.update(_CHILD_DELTA_EVENTS.values())
|
|
for src in (REPO / "tools").glob("delegate_tool*.py"):
|
|
names.update(_SUBAGENT_RELAY.findall(_read(src)))
|
|
names.discard("subagent.text") # mirrored into the watch window as message.delta, never emitted
|
|
from tools.registry import _tool_module_candidates
|
|
|
|
for src in _tool_module_candidates(REPO / "tools"):
|
|
names.update(_DESKTOP_UI_EMIT.findall(_read(src)))
|
|
names.update(_BROKER_FRAME.findall(_read(REPO / "gateway" / "browser_control_broker.py")))
|
|
names.update(_SETUP_READY.findall(_read(REPO / "hermes_cli" / "free_tier_bootstrap.py")))
|
|
return names
|
|
|
|
|
|
def sent_server_requests() -> set[str]:
|
|
names: set[str] = set()
|
|
for src in (REPO / "tui_gateway").glob("*.py"):
|
|
names.update(_LITERAL_REQUEST.findall(_read(src)))
|
|
return names
|
|
|
|
|
|
def test_catalog_covers_the_whole_wire():
|
|
"""Every registered method, every emitted event and every sent server request has a contract,
|
|
and no contract is orphaned (a deleted handler must take its contract with it)."""
|
|
from tui_gateway import server
|
|
from tui_gateway.contracts import registry
|
|
|
|
registry.assert_complete(server._methods, emitted_event_names(), sent_server_requests())
|