test_extensions.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """业务扩展发现、注册、组装和启动失败路径。"""
  2. from concurrent.futures import ThreadPoolExecutor
  3. from pathlib import Path
  4. import sys
  5. from types import SimpleNamespace
  6. import pytest
  7. from extensions import ExtensionRegistry, define_tool
  8. from extensions import registry as extensions
  9. from tools import registry as tools
  10. @define_tool(name="airflow", group="通风", label="计算风量")
  11. def airflow_impl(area: float, speed: float) -> float:
  12. """计算断面积与风速对应的风量。"""
  13. return area * speed
  14. def test_declaration_has_no_import_registration_and_alias_is_preserved():
  15. assert "airflow" not in tools.get_tool_names()
  16. catalog = ExtensionRegistry()
  17. catalog.register_agent(name="ventilation", description="处理通风计算", prompt="使用工具计算风量。", tools=[airflow_impl])
  18. catalog.validate()
  19. tools.install_specs(catalog.tools.values())
  20. tool = tools.get_all_tools()[0]
  21. assert tool.name == "airflow"
  22. assert tool.invoke({"area": 2, "speed": 3}) == 6
  23. from agents.prompts import build_system_prompt
  24. agents = catalog.subagents(set())
  25. assert agents[0]["system_prompt"] == "使用工具计算风量。"
  26. assert "处理通风计算" in build_system_prompt(agents)
  27. @pytest.mark.asyncio
  28. async def test_async_tool_alias():
  29. @define_tool(name="async_airflow")
  30. async def compute(area: float) -> float:
  31. """异步计算。"""
  32. return area * 2
  33. catalog = ExtensionRegistry()
  34. catalog.register_tool(compute)
  35. catalog.validate()
  36. tools.install_specs(catalog.tools.values())
  37. tool = tools.get_all_tools()[0]
  38. assert tool.name == "async_airflow"
  39. assert await tool.ainvoke({"area": 3}) == 6
  40. def test_missing_reference_fails_and_forward_reference_works():
  41. catalog = ExtensionRegistry()
  42. catalog.register_agent(name="ventilation", description="通风", prompt="计算", tools=["airflow"])
  43. with pytest.raises(ValueError, match="未注册工具"):
  44. catalog.validate()
  45. catalog.register_tool(airflow_impl)
  46. catalog.validate()
  47. @pytest.mark.parametrize("name", ["bad name", "../escape", "1abc", "", "a" * 65])
  48. def test_invalid_names(name):
  49. with pytest.raises(ValueError, match="名称非法"):
  50. ExtensionRegistry().register_tool(airflow_impl, name=name)
  51. def test_duplicate_and_reserved_names():
  52. catalog = ExtensionRegistry()
  53. catalog.register_tool(airflow_impl)
  54. with pytest.raises(ValueError, match="工具名冲突"):
  55. catalog.register_tool(airflow_impl)
  56. with pytest.raises(ValueError, match="内置工具冲突"):
  57. catalog.register_tool(airflow_impl, name="write_file")
  58. args = dict(name="ventilation", description="通风", prompt="计算", tools=["airflow"])
  59. catalog.register_agent(**args)
  60. with pytest.raises(ValueError, match="Agent 名称冲突"):
  61. catalog.register_agent(**args)
  62. with pytest.raises(ValueError, match="Agent 名称冲突"):
  63. catalog.register_agent(**{**args, "name": "general-purpose"})
  64. @pytest.mark.parametrize("field", ["description", "prompt"])
  65. def test_empty_prompts_and_descriptions(field):
  66. args = dict(name="ventilation", description="通风", prompt="计算", tools=[])
  67. args[field] = " "
  68. with pytest.raises(ValueError, match="不能为空"):
  69. ExtensionRegistry().register_agent(**args)
  70. def test_approval_must_be_bound_and_configuration_must_exist():
  71. catalog = ExtensionRegistry()
  72. catalog.register_tool(airflow_impl)
  73. catalog.register_agent(name="ventilation", description="通风", prompt="计算", tools=[], approval_tools=["airflow"])
  74. with pytest.raises(ValueError, match="审批工具未绑定"):
  75. catalog.validate()
  76. with pytest.raises(ValueError, match="未注册工具"):
  77. ExtensionRegistry().validate(["typo"])
  78. def test_shared_approval_applies_to_every_agent_using_tool():
  79. catalog = ExtensionRegistry()
  80. catalog.register_agent(name="one", description="一", prompt="一", tools=[airflow_impl], approval_tools=[airflow_impl])
  81. catalog.register_agent(name="two", description="二", prompt="二", tools=[airflow_impl])
  82. approvals = catalog.validate()
  83. tools.install_specs(catalog.tools.values())
  84. assert approvals == {"airflow"}
  85. assert all(agent["interrupt_on"] == {"airflow": True} for agent in catalog.subagents(approvals))
  86. def test_failed_load_does_not_publish_partial_tools(monkeypatch):
  87. baseline = tools.get_tool_names()
  88. extensions.reset_for_tests()
  89. def setup(catalog):
  90. catalog.register_tool(airflow_impl)
  91. raise ValueError("broken extension")
  92. monkeypatch.setitem(sys.modules, "extensions.broken.extension", SimpleNamespace(setup=setup))
  93. monkeypatch.setattr(extensions, "discover_modules", lambda: ["extensions.broken.extension"])
  94. with pytest.raises(ValueError, match="extensions.broken.extension.*broken extension"):
  95. extensions.load_extensions()
  96. assert tools.get_tool_names() == baseline
  97. assert extensions._loaded is None
  98. def test_missing_setup_fails_with_module_name(monkeypatch):
  99. extensions.reset_for_tests()
  100. monkeypatch.setitem(sys.modules, "extensions.broken.extension", SimpleNamespace())
  101. monkeypatch.setattr(extensions, "discover_modules", lambda: ["extensions.broken.extension"])
  102. with pytest.raises(ValueError, match="extensions.broken.extension.*setup"):
  103. extensions.load_extensions()
  104. def test_multiple_cached_modules_reset_and_concurrent_load_are_idempotent(monkeypatch):
  105. base = extensions.discover_modules()
  106. calls = []
  107. def setup(catalog):
  108. calls.append(1)
  109. catalog.register_tool(airflow_impl)
  110. monkeypatch.setitem(sys.modules, "extensions.air.extension", SimpleNamespace(setup=setup))
  111. monkeypatch.setattr(extensions, "discover_modules", lambda: [*base, "extensions.air.extension"])
  112. for _ in range(2):
  113. tools.reset_for_tests()
  114. with ThreadPoolExecutor(max_workers=4) as pool:
  115. results = list(pool.map(lambda _: extensions.load_extensions(), range(8)))
  116. assert all(result is results[0] for result in results)
  117. assert len(tools.get_tool_names()) == 6
  118. assert "ask_user" in tools.get_tool_names() and "airflow" in tools.get_tool_names()
  119. assert len(calls) == 2
  120. with pytest.raises(ValueError, match="重启"):
  121. results[0].register_tool(airflow_impl)
  122. def test_discovery_is_sorted_and_ignores_helpers(tmp_path, monkeypatch):
  123. monkeypatch.setattr(extensions, "__file__", str(tmp_path / "registry.py"))
  124. for name in ("zeta", "alpha", "_hidden"):
  125. folder = tmp_path / name
  126. folder.mkdir()
  127. (folder / "extension.py").write_text("def setup(registry): pass")
  128. (tmp_path / "helper.py").write_text("raise RuntimeError('must not import')")
  129. assert extensions.discover_modules() == ["extensions.alpha.extension", "extensions.zeta.extension"]
  130. (tmp_path / "linked").symlink_to(tmp_path / "alpha", target_is_directory=True)
  131. with pytest.raises(ValueError, match="符号链接"):
  132. extensions.discover_modules()
  133. @pytest.mark.asyncio
  134. async def test_invalid_extension_blocks_startup_before_database(monkeypatch):
  135. import main
  136. tools.reset_for_tests()
  137. monkeypatch.setenv("HITL_TOOLS", "unknown_tool")
  138. def forbidden():
  139. pytest.fail("扩展校验失败后不应初始化数据库")
  140. monkeypatch.setattr(main.db_conn, "init_schema", forbidden)
  141. with pytest.raises(ValueError, match="未注册工具"):
  142. await main._lifespan_startup()
  143. def test_release_includes_extension_code_and_prompt_files():
  144. root = Path(__file__).resolve().parents[1]
  145. sys.path.insert(0, str(root / "release"))
  146. import build
  147. files = {p.relative_to(root).as_posix() for p in build.source_files(root)}
  148. assert "extensions/demo/extension.py" in files
  149. assert "extensions/core/extension.py" in files
  150. assert {".py", ".md", ".txt"} <= build.TREE_SUFFIXES["extensions"]