| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190 |
- """业务扩展发现、注册、组装和启动失败路径。"""
- from concurrent.futures import ThreadPoolExecutor
- from pathlib import Path
- import sys
- from types import SimpleNamespace
- import pytest
- from extensions import ExtensionRegistry, define_tool
- from extensions import registry as extensions
- from tools import registry as tools
- @define_tool(name="airflow", group="通风", label="计算风量")
- def airflow_impl(area: float, speed: float) -> float:
- """计算断面积与风速对应的风量。"""
- return area * speed
- def test_declaration_has_no_import_registration_and_alias_is_preserved():
- assert "airflow" not in tools.get_tool_names()
- catalog = ExtensionRegistry()
- catalog.register_agent(name="ventilation", description="处理通风计算", prompt="使用工具计算风量。", tools=[airflow_impl])
- catalog.validate()
- tools.install_specs(catalog.tools.values())
- tool = tools.get_all_tools()[0]
- assert tool.name == "airflow"
- assert tool.invoke({"area": 2, "speed": 3}) == 6
- from agents.prompts import build_system_prompt
- agents = catalog.subagents(set())
- assert agents[0]["system_prompt"] == "使用工具计算风量。"
- assert "处理通风计算" in build_system_prompt(agents)
- @pytest.mark.asyncio
- async def test_async_tool_alias():
- @define_tool(name="async_airflow")
- async def compute(area: float) -> float:
- """异步计算。"""
- return area * 2
- catalog = ExtensionRegistry()
- catalog.register_tool(compute)
- catalog.validate()
- tools.install_specs(catalog.tools.values())
- tool = tools.get_all_tools()[0]
- assert tool.name == "async_airflow"
- assert await tool.ainvoke({"area": 3}) == 6
- def test_missing_reference_fails_and_forward_reference_works():
- catalog = ExtensionRegistry()
- catalog.register_agent(name="ventilation", description="通风", prompt="计算", tools=["airflow"])
- with pytest.raises(ValueError, match="未注册工具"):
- catalog.validate()
- catalog.register_tool(airflow_impl)
- catalog.validate()
- @pytest.mark.parametrize("name", ["bad name", "../escape", "1abc", "", "a" * 65])
- def test_invalid_names(name):
- with pytest.raises(ValueError, match="名称非法"):
- ExtensionRegistry().register_tool(airflow_impl, name=name)
- def test_duplicate_and_reserved_names():
- catalog = ExtensionRegistry()
- catalog.register_tool(airflow_impl)
- with pytest.raises(ValueError, match="工具名冲突"):
- catalog.register_tool(airflow_impl)
- with pytest.raises(ValueError, match="内置工具冲突"):
- catalog.register_tool(airflow_impl, name="write_file")
- args = dict(name="ventilation", description="通风", prompt="计算", tools=["airflow"])
- catalog.register_agent(**args)
- with pytest.raises(ValueError, match="Agent 名称冲突"):
- catalog.register_agent(**args)
- with pytest.raises(ValueError, match="Agent 名称冲突"):
- catalog.register_agent(**{**args, "name": "general-purpose"})
- @pytest.mark.parametrize("field", ["description", "prompt"])
- def test_empty_prompts_and_descriptions(field):
- args = dict(name="ventilation", description="通风", prompt="计算", tools=[])
- args[field] = " "
- with pytest.raises(ValueError, match="不能为空"):
- ExtensionRegistry().register_agent(**args)
- def test_approval_must_be_bound_and_configuration_must_exist():
- catalog = ExtensionRegistry()
- catalog.register_tool(airflow_impl)
- catalog.register_agent(name="ventilation", description="通风", prompt="计算", tools=[], approval_tools=["airflow"])
- with pytest.raises(ValueError, match="审批工具未绑定"):
- catalog.validate()
- with pytest.raises(ValueError, match="未注册工具"):
- ExtensionRegistry().validate(["typo"])
- def test_shared_approval_applies_to_every_agent_using_tool():
- catalog = ExtensionRegistry()
- catalog.register_agent(name="one", description="一", prompt="一", tools=[airflow_impl], approval_tools=[airflow_impl])
- catalog.register_agent(name="two", description="二", prompt="二", tools=[airflow_impl])
- approvals = catalog.validate()
- tools.install_specs(catalog.tools.values())
- assert approvals == {"airflow"}
- assert all(agent["interrupt_on"] == {"airflow": True} for agent in catalog.subagents(approvals))
- def test_failed_load_does_not_publish_partial_tools(monkeypatch):
- baseline = tools.get_tool_names()
- extensions.reset_for_tests()
- def setup(catalog):
- catalog.register_tool(airflow_impl)
- raise ValueError("broken extension")
- monkeypatch.setitem(sys.modules, "extensions.broken.extension", SimpleNamespace(setup=setup))
- monkeypatch.setattr(extensions, "discover_modules", lambda: ["extensions.broken.extension"])
- with pytest.raises(ValueError, match="extensions.broken.extension.*broken extension"):
- extensions.load_extensions()
- assert tools.get_tool_names() == baseline
- assert extensions._loaded is None
- def test_missing_setup_fails_with_module_name(monkeypatch):
- extensions.reset_for_tests()
- monkeypatch.setitem(sys.modules, "extensions.broken.extension", SimpleNamespace())
- monkeypatch.setattr(extensions, "discover_modules", lambda: ["extensions.broken.extension"])
- with pytest.raises(ValueError, match="extensions.broken.extension.*setup"):
- extensions.load_extensions()
- def test_multiple_cached_modules_reset_and_concurrent_load_are_idempotent(monkeypatch):
- base = extensions.discover_modules()
- baseline=set(tools.get_tool_names())
- calls = []
- def setup(catalog):
- calls.append(1)
- catalog.register_tool(airflow_impl)
- monkeypatch.setitem(sys.modules, "extensions.air.extension", SimpleNamespace(setup=setup))
- monkeypatch.setattr(extensions, "discover_modules", lambda: [*base, "extensions.air.extension"])
- for _ in range(2):
- tools.reset_for_tests()
- with ThreadPoolExecutor(max_workers=4) as pool:
- results = list(pool.map(lambda _: extensions.load_extensions(), range(8)))
- assert all(result is results[0] for result in results)
- assert set(tools.get_tool_names()) == baseline | {'airflow'}
- assert len(tools.get_tool_names()) == len(baseline)+1
- assert "main_fan" in results[0].business_agents
- assert "ask_user" in tools.get_tool_names() and "airflow" in tools.get_tool_names()
- assert len(calls) == 2
- with pytest.raises(ValueError, match="重启"):
- results[0].register_tool(airflow_impl)
- def test_discovery_is_sorted_and_ignores_helpers(tmp_path, monkeypatch):
- monkeypatch.setattr(extensions, "__file__", str(tmp_path / "registry.py"))
- for name in ("zeta", "alpha", "_hidden"):
- folder = tmp_path / name
- folder.mkdir()
- (folder / "extension.py").write_text("def setup(registry): pass")
- (tmp_path / "helper.py").write_text("raise RuntimeError('must not import')")
- assert extensions.discover_modules() == ["extensions.alpha.extension", "extensions.zeta.extension"]
- (tmp_path / "linked").symlink_to(tmp_path / "alpha", target_is_directory=True)
- with pytest.raises(ValueError, match="符号链接"):
- extensions.discover_modules()
- @pytest.mark.asyncio
- async def test_invalid_extension_blocks_startup_before_database(monkeypatch):
- import main
- tools.reset_for_tests()
- monkeypatch.setenv("HITL_TOOLS", "unknown_tool")
- def forbidden():
- pytest.fail("扩展校验失败后不应初始化数据库")
- monkeypatch.setattr(main.db_conn, "init_schema", forbidden)
- with pytest.raises(ValueError, match="未注册工具"):
- async with main.lifespan(main.app):
- pytest.fail("无效扩展不应启动服务")
- def test_release_includes_extension_code_and_prompt_files():
- root = Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(root / "release"))
- import build
- files = {p.relative_to(root).as_posix() for p in build.source_files(root)}
- assert "extensions/demo/extension.py" in files
- assert "extensions/core/extension.py" in files
- assert "extensions/result_platform/client.py" in files
- assert "runtime/result_models.py" in files
- assert "mock_result_platform/app.py" in files
- assert "scripts/mock_result_platform.py" in files
- assert {".py", ".md", ".txt"} <= build.TREE_SUFFIXES["extensions"]
|