This commit is contained in:
2026-07-01 09:22:26 +00:00
parent 4aa1dab283
commit aa47a19e98
53 changed files with 4721 additions and 77 deletions
+86
View File
@@ -0,0 +1,86 @@
"""``IndividualDatabase`` —— plugin_owned slot 路径单元测试。"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock
from kilostar.core.postgres_database.module.individual import IndividualDatabase
from kilostar.utils.error import BusinessError
def _make_db():
session = AsyncMock()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=False)
session_maker = MagicMock(return_value=session)
return IndividualDatabase(session_maker), session
@pytest.mark.anyio
async def test_delete_blocks_plugin_owned_row():
db, session = _make_db()
fake = MagicMock()
fake.plugin_owned = "data_analytics"
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = fake
session.execute = AsyncMock(return_value=execute_result)
with pytest.raises(BusinessError, match="不可删除"):
await db.delete_worker_individual("agent-x")
@pytest.mark.anyio
async def test_delete_allows_user_owned_row():
db, session = _make_db()
fake = MagicMock()
fake.plugin_owned = None
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = fake
session.execute = AsyncMock(return_value=execute_result)
session.delete = AsyncMock()
session.commit = AsyncMock()
ok = await db.delete_worker_individual("agent-x")
assert ok is True
@pytest.mark.anyio
async def test_upsert_plugin_slot_inserts_when_missing():
db, session = _make_db()
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = None # 不存在
session.execute = AsyncMock(return_value=execute_result)
session.add = MagicMock()
session.commit = AsyncMock()
session.refresh = AsyncMock()
row = await db.upsert_plugin_slot(
plugin_name="data_analytics",
slot_name="analyst",
description="数据分析师",
)
session.add.assert_called_once()
added = session.add.call_args[0][0]
assert added.plugin_owned == "data_analytics"
assert added.agent_name == "analyst"
assert added.provider_title == "" # 等用户装配
assert row is added
@pytest.mark.anyio
async def test_upsert_plugin_slot_refreshes_when_exists():
db, session = _make_db()
fake = MagicMock()
fake.description = "old"
fake.node_affinity = "cpu"
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = fake
session.execute = AsyncMock(return_value=execute_result)
session.add = MagicMock()
session.commit = AsyncMock()
session.refresh = AsyncMock()
await db.upsert_plugin_slot("data_analytics", "analyst", "新描述", node_affinity="gpu")
assert fake.description == "新描述"
assert fake.node_affinity == "gpu"
+206 -1
View File
@@ -3,9 +3,19 @@
import json
from pathlib import Path
import pytest
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import DeclarativeBase
from kilostar.plugin_runtime.base_organization import BaseOrganization
from kilostar.plugin_runtime.manifest import OrgManifest
from kilostar.plugin_runtime.agents_config import AgentsConfig
from kilostar.plugin_runtime.loader import discover_plugins, load_plugin
from kilostar.plugin_runtime.loader import (
collect_plugin_routers,
discover_plugin_api,
discover_plugins,
load_plugin,
)
from kilostar.plugin_runtime.tool_bridge import make_dispatch_tool
_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "data" / "plugin"
@@ -61,3 +71,198 @@ def test_make_dispatch_tool_signature():
assert tool.__name__ == "dispatch_to_example_dept"
assert callable(tool)
assert "演示用" in tool.__doc__
# ─── 框架层:SQLite 隔离基础设施 ─────────────────────────────────
class _PluginBase(DeclarativeBase):
pass
class _PluginThing(_PluginBase):
__tablename__ = "plugin_thing"
id = Column(Integer, primary_key=True)
name = Column(String(50))
@pytest.mark.anyio
async def test_init_local_db_creates_sqlite_with_metadata(tmp_path, monkeypatch):
"""init_local_db 在插件 _data 目录下落 SQLitecreate_all 建表,session_maker 可用。"""
from kilostar.utils import settings as _settings_mod
monkeypatch.setenv("KILOSTAR_PLUGIN_DIR", str(tmp_path))
_settings_mod.get_settings.cache_clear()
manifest = {"name": "tplug", "version": "0.1.0", "display_name": "测试插件"}
agents = {
"agents": [
{"name": "a", "role": "r", "model": {"provider_title": "p", "model_id": "m"}}
],
"orchestration": {"type": "react", "entry": "a"},
}
org = BaseOrganization(manifest, agents, str(tmp_path / "tplug"))
try:
await org.init_local_db([_PluginBase])
db_path = tmp_path / "tplug" / "_data" / "tplug.db"
assert db_path.exists()
assert org._engine is not None and org._session_maker is not None
async with org._session_maker() as session:
session.add(_PluginThing(name="hello"))
await session.commit()
from sqlalchemy import select
async with org._session_maker() as session:
row = (await session.execute(select(_PluginThing))).scalar_one()
assert row.name == "hello"
finally:
if org._engine is not None:
await org._engine.dispose()
_settings_mod.get_settings.cache_clear()
@pytest.mark.anyio
async def test_on_first_install_default_is_noop():
"""默认 on_first_install 是空实现,子类可覆盖;调用不抛错。"""
manifest = {"name": "noop", "version": "0.1.0"}
agents = {
"agents": [{"name": "a", "role": "r", "model": {"provider_title": "p", "model_id": "m"}}],
"orchestration": {"type": "react", "entry": "a"},
}
org = BaseOrganization(manifest, agents, "/tmp/x")
result = await org.on_first_install()
assert result is None
@pytest.mark.anyio
async def test_install_marker_drives_on_first_install(tmp_path, monkeypatch):
"""首次装载触发 on_first_install + 写 marker;二次装载不再触发。"""
from kilostar.utils import settings as _settings_mod
from kilostar.utils import ray_compat
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
monkeypatch.setenv("KILOSTAR_PLUGIN_DIR", str(tmp_path))
_settings_mod.get_settings.cache_clear()
# 强制走 standalone 分支,重新 import plugin_manager 以 re-decorate
import importlib
import kilostar.plugin_runtime.plugin_manager as pm_mod
importlib.reload(pm_mod)
GlobalPluginManager = pm_mod.GlobalPluginManager
plugin_dir = tmp_path / "demo_plug"
(plugin_dir / "core").mkdir(parents=True)
(plugin_dir / "manifest.json").write_text(json.dumps({
"name": "demo_plug",
"version": "0.1.0",
"display_name": "demo",
}))
(plugin_dir / "agents.json").write_text(json.dumps({
"agents": [{"name": "a", "role": "r", "model": {"provider_title": "p", "model_id": "m"}}],
"orchestration": {"type": "react", "entry": "a"},
}))
install_count = {"n": 0}
async def _no_setup(self_):
return None
async def _count_install(self_):
install_count["n"] += 1
monkeypatch.setattr(BaseOrganization, "setup", _no_setup)
monkeypatch.setattr(BaseOrganization, "on_first_install", _count_install)
try:
pm = GlobalPluginManager()
await pm._install_from_path(plugin_dir)
assert install_count["n"] == 1
marker = tmp_path / "demo_plug" / "_data" / ".installed"
assert marker.exists()
# 模拟"二次装载":清掉内存态后重装
pm._orgs.pop("demo_plug", None)
await pm._install_from_path(plugin_dir)
assert install_count["n"] == 1 # 不应再触发
finally:
_settings_mod.get_settings.cache_clear()
# ─── 框架层:插件 API router 自动挂载 ────────────────────────────
def _write_plugin_skeleton(root: Path, name: str, *, with_api: bool, api_prefix: str | None) -> Path:
plugin_dir = root / name
(plugin_dir / "core").mkdir(parents=True)
manifest = {
"name": name,
"version": "0.1.0",
"display_name": name,
}
if api_prefix:
manifest["api_prefix"] = api_prefix
(plugin_dir / "manifest.json").write_text(json.dumps(manifest))
(plugin_dir / "agents.json").write_text(json.dumps({
"agents": [{"name": "a", "role": "r", "model": {"provider_title": "p", "model_id": "m"}}],
"orchestration": {"type": "react", "entry": "a"},
}))
if with_api:
(plugin_dir / "api.py").write_text(
"from fastapi import APIRouter\n"
"router = APIRouter()\n"
"@router.get('/ping')\n"
"async def _ping():\n"
" return {'ok': True}\n"
)
return plugin_dir
def test_discover_plugin_api_returns_router_when_present(tmp_path):
plugin_dir = _write_plugin_skeleton(tmp_path, "p_with_api", with_api=True, api_prefix="/x")
router = discover_plugin_api(plugin_dir, "p_with_api")
assert router is not None
assert any(r.path == "/ping" for r in router.routes)
def test_discover_plugin_api_returns_none_when_missing(tmp_path):
plugin_dir = _write_plugin_skeleton(tmp_path, "p_no_api", with_api=False, api_prefix="/x")
assert discover_plugin_api(plugin_dir, "p_no_api") is None
def test_collect_plugin_routers_filters_by_api_prefix_and_api_py(tmp_path):
"""没 api_prefix 的不挂;没 api.py 的也不挂;都满足才返回。"""
_write_plugin_skeleton(tmp_path, "ok", with_api=True, api_prefix="/api/v1/plugin/ok")
_write_plugin_skeleton(tmp_path, "no_prefix", with_api=True, api_prefix=None)
_write_plugin_skeleton(tmp_path, "no_api", with_api=False, api_prefix="/api/v1/plugin/no_api")
routers = collect_plugin_routers(tmp_path)
prefixes = [p for p, _r in routers]
assert prefixes == ["/api/v1/plugin/ok"]
# ─── 框架层:plugin_owned slot ────────────────────────────
def test_agent_def_model_is_optional():
"""slot 形态:agents.json 里 model 可省略,等用户在前端装配。"""
cfg = AgentsConfig.model_validate({
"agents": [{"name": "analyst", "role": "数据分析师"}],
"orchestration": {"type": "react", "entry": "analyst"},
})
assert cfg.agents[0].model is None
def test_agent_def_model_static_still_works():
"""老插件留一手:写死 model 仍然合法。"""
cfg = AgentsConfig.model_validate({
"agents": [{
"name": "x", "role": "r",
"model": {"provider_title": "p", "model_id": "m"},
}],
"orchestration": {"type": "react", "entry": "x"},
})
assert cfg.agents[0].model is not None
assert cfg.agents[0].model.provider_title == "p"