diff --git a/test/test_graph_unit.py b/test/test_graph_unit.py index 4c3ed86f..f2fb6af0 100644 --- a/test/test_graph_unit.py +++ b/test/test_graph_unit.py @@ -6,7 +6,7 @@ import sys # Add project root to path sys.path.append(os.getcwd()) -from src.knowledge.services.upload_graph_service import UploadGraphService +from src.knowledge.graphs.upload_graph_service import UploadGraphService # For backward compatibility with the existing test GraphDatabase = UploadGraphService @@ -40,64 +40,68 @@ async def test_txt_add_vector_entity_parsing(): mock_embed_model = MagicMock() mock_select_model.return_value = mock_embed_model - # Instantiate GraphDatabase with mocked driver - # We also need to patch Neo4jConnectionManager in the init - with patch("src.knowledge.services.upload_graph_service.Neo4jConnectionManager") as mock_connection_manager: - # Mock the connection manager to return our mocked driver - mock_connection_manager.return_value.driver = mock_driver - mock_connection_manager.return_value.status = "open" + # Create a mock connection object with driver and status as attributes + # (not a ConnectionManager class, just a simple mock object) + mock_connection = MagicMock() + mock_connection.driver = mock_driver + mock_connection.status = "open" - gd = GraphDatabase(mock_connection_manager.return_value) - # Manually set properties just in case init didn't work as expected due to other mocks - gd.driver = mock_driver - gd.status = "open" - gd.embed_model_name = "test_model" # avoid config check issues if possible + # Instantiate GraphDatabase with mocked connection + gd = GraphDatabase(mock_connection) + # Set embed_model_name directly (this is a settable attribute) + gd.embed_model_name = "test_model" - # Mock config to match - with patch("src.config.embed_model", "test_model"): - with patch("src.config.embed_model_names", {"test_model": MagicMock(dimension=1024)}): - # Test data: Mixed format - triples = [ - # Legacy format - {"h": "A", "r": "KNOWS", "t": "B"}, - # Extended format - { - "h": {"name": "C", "age": 30}, - "r": {"type": "LIKES", "weight": 0.8}, - "t": {"name": "D", "role": "User"}, - }, - ] + # Mock config where it's imported in upload_graph_service.py + # The import is `from src import config`, so patch at the usage location + mock_config = MagicMock() + mock_config.embed_model = "test_model" + mock_embed_info = MagicMock() + mock_embed_info.dimension = 1024 + mock_config.embed_model_names = {"test_model": mock_embed_info} - # Run the method - await gd.txt_add_vector_entity(triples) + with patch("src.knowledge.graphs.upload_graph_service.config", mock_config): + # Test data: Mixed format + triples = [ + # Legacy format + {"h": "A", "r": "KNOWS", "t": "B"}, + # Extended format + { + "h": {"name": "C", "age": 30}, + "r": {"type": "LIKES", "weight": 0.8}, + "t": {"name": "D", "role": "User"}, + }, + ] - # Verify calls to mock_tx.run - merge_calls = [] - for call in mock_tx.run.call_args_list: - args, kwargs = call - query = args[0] if args else kwargs.get("query", "") - if "MERGE (h:Entity:Upload" in query: - # The args are passed as kwargs to run: h_name=..., etc. - merge_calls.append(kwargs) + # Run the method + await gd.txt_add_vector_entity(triples) - assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}" + # Verify calls to mock_tx.run + merge_calls = [] + for call in mock_tx.run.call_args_list: + args, kwargs = call + query = args[0] if args else kwargs.get("query", "") + if "MERGE (h:Entity:Upload" in query: + # The args are passed as kwargs to run: h_name=..., etc. + merge_calls.append(kwargs) - # Call 1 (Legacy) - call1 = merge_calls[0] - assert call1["h_name"] == "A" - assert call1["h_props"] == {} - assert call1["t_name"] == "B" - assert call1["t_props"] == {} - assert call1["r_type"] == "KNOWS" - assert call1["r_props"] == {} + assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}" - # Call 2 (Extended) - call2 = merge_calls[1] - assert call2["h_name"] == "C" - assert call2["h_props"] == {"age": 30} - assert call2["t_name"] == "D" - assert call2["t_props"] == {"role": "User"} - assert call2["r_type"] == "LIKES" - assert call2["r_props"] == {"weight": 0.8} + # Call 1 (Legacy) + call1 = merge_calls[0] + assert call1["h_name"] == "A" + assert call1["h_props"] == {} + assert call1["t_name"] == "B" + assert call1["t_props"] == {} + assert call1["r_type"] == "KNOWS" + assert call1["r_props"] == {} - print("Verification passed!") + # Call 2 (Extended) + call2 = merge_calls[1] + assert call2["h_name"] == "C" + assert call2["h_props"] == {"age": 30} + assert call2["t_name"] == "D" + assert call2["t_props"] == {"role": "User"} + assert call2["r_type"] == "LIKES" + assert call2["r_props"] == {"weight": 0.8} + + print("Verification passed!") \ No newline at end of file diff --git a/test/test_runtime_config_middleware_skills.py b/test/test_runtime_config_middleware_skills.py deleted file mode 100644 index 1f7332a9..00000000 --- a/test/test_runtime_config_middleware_skills.py +++ /dev/null @@ -1,286 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from types import SimpleNamespace -from typing import Any - -import pytest -from langchain_core.messages import SystemMessage, ToolMessage -from langgraph.types import Command - -import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware -from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware - - -@dataclass -class _FakeTool: - name: str - - -@dataclass -class _FakeRequest: - runtime: Any - tools: list[Any] - system_message: SystemMessage - state: dict[str, Any] - - def override(self, **kwargs): - return _FakeRequest( - runtime=kwargs.get("runtime", self.runtime), - tools=kwargs.get("tools", self.tools), - system_message=kwargs.get("system_message", self.system_message), - state=kwargs.get("state", self.state), - ) - - -@dataclass -class _FakeToolCallRequest: - tool_call: dict[str, Any] - runtime: Any - state: dict[str, Any] - - -async def _echo_handler(request): - return request - - -def _build_request( - *, - skills: list[str], - tools: list[str], - system_prompt: str = "你是助手", - state: dict[str, Any] | None = None, - skill_snapshot: dict[str, Any] | None = None, -) -> _FakeRequest: - context = SimpleNamespace( - system_prompt=system_prompt, - skills=skills, - tools=[], - knowledges=[], - mcps=[], - ) - if skill_snapshot is not None: - context.skill_session_snapshot = skill_snapshot - runtime = SimpleNamespace(context=context) - return _FakeRequest( - runtime=runtime, - tools=[_FakeTool(name=name) for name in tools], - system_message=SystemMessage(content=[{"type": "text", "text": "base"}]), - state=state or {}, - ) - - -def _build_tool_request(*, skills: list[str], visible_skills: list[str], file_path: str) -> _FakeToolCallRequest: - return _FakeToolCallRequest( - tool_call={"name": "read_file", "args": {"file_path": file_path}}, - runtime=SimpleNamespace( - context=SimpleNamespace( - skills=skills, - skill_session_snapshot=_build_snapshot(selected=skills, visible=visible_skills), - ) - ), - state={}, - ) - - -def _extract_appended_prompt(request: _FakeRequest) -> str: - return request.system_message.content_blocks[-1]["text"] - - -def _build_middleware() -> RuntimeConfigMiddleware: - return RuntimeConfigMiddleware( - enable_model_override=False, - enable_tools_override=False, - enable_system_prompt_override=True, - enable_skills_prompt_override=True, - ) - - -def _build_snapshot( - selected: list[str], - visible: list[str] | None = None, - metadata: dict[str, dict[str, str]] | None = None, - dependency_map: dict[str, dict[str, list[str]]] | None = None, -) -> dict[str, Any]: - return { - "selected_skills": selected, - "visible_skills": visible if visible is not None else selected, - "prompt_metadata": metadata or {}, - "dependency_map": dependency_map or {}, - } - - -@pytest.mark.asyncio -async def test_abefore_agent_resolves_visible_skills_and_preinjects_prompt(monkeypatch: pytest.MonkeyPatch): - async def fake_resolve(selected): - assert selected == ["deliver-prd"] - return _build_snapshot( - selected=["deliver-prd"], - visible=["deliver-prd", "brainstorming"], - metadata={ - "deliver-prd": { - "name": "deliver-prd", - "description": "deliver prd", - "path": "/skills/deliver-prd/SKILL.md", - }, - "brainstorming": { - "name": "brainstorming", - "description": "brainstorming desc", - "path": "/skills/brainstorming/SKILL.md", - }, - }, - dependency_map={ - "deliver-prd": {"tools": [], "mcps": [], "skills": ["brainstorming"]}, - "brainstorming": {"tools": [], "mcps": [], "skills": []}, - }, - ) - - monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve) - middleware = _build_middleware() - request = _build_request(skills=["deliver-prd"], tools=["read_file"], system_prompt="你是助手") - - await middleware.abefore_agent(request.state, request.runtime) - - snapshot = request.runtime.context.skill_session_snapshot - assert snapshot["selected_skills"] == ["deliver-prd"] - assert snapshot["visible_skills"] == ["deliver-prd", "brainstorming"] - assert snapshot["dependency_map"]["deliver-prd"]["skills"] == ["brainstorming"] - assert request.runtime.context._skills_prompt_injected is True - assert "## Skills System" in request.runtime.context.system_prompt - assert "- **deliver-prd**: deliver prd" in request.runtime.context.system_prompt - assert "- **brainstorming**: brainstorming desc" in request.runtime.context.system_prompt - - -@pytest.mark.asyncio -async def test_abefore_agent_injection_is_idempotent(monkeypatch: pytest.MonkeyPatch): - async def fake_resolve(_selected): - return _build_snapshot( - selected=["alpha"], - metadata={"alpha": {"name": "alpha", "description": "alpha desc", "path": "/skills/alpha/SKILL.md"}}, - ) - - monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve) - middleware = _build_middleware() - request = _build_request(skills=["alpha"], tools=["read_file"], system_prompt="base prompt") - - await middleware.abefore_agent(request.state, request.runtime) - await middleware.abefore_agent(request.state, request.runtime) - - assert request.runtime.context.system_prompt.count("## Skills System") == 1 - - -@pytest.mark.asyncio -async def test_awrap_model_call_keeps_preinjected_skills_prompt(monkeypatch: pytest.MonkeyPatch): - middleware = _build_middleware() - request = _build_request( - skills=["alpha"], - tools=["read_file"], - system_prompt="base prompt\n\n## Skills System\n- **alpha**: alpha desc", - ) - - def raise_if_called(_skills_meta): - raise AssertionError("_build_skills_section should not be called in awrap_model_call") - - monkeypatch.setattr(middleware, "_build_skills_section", raise_if_called) - result = await middleware.awrap_model_call(request, _echo_handler) - prompt = _extract_appended_prompt(result) - - assert "当前时间:" in prompt - assert "## Skills System" in prompt - assert "- **alpha**: alpha desc" in prompt - - -@pytest.mark.asyncio -async def test_abefore_agent_degrades_when_resolver_fails(monkeypatch: pytest.MonkeyPatch): - async def fake_resolve(_selected): - raise RuntimeError("boom") - - monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve) - middleware = _build_middleware() - request = _build_request(skills=["alpha"], tools=["read_file"], system_prompt="base prompt") - - await middleware.abefore_agent(request.state, request.runtime) - - snapshot = request.runtime.context.skill_session_snapshot - assert snapshot["selected_skills"] == ["alpha"] - assert snapshot["visible_skills"] == [] - assert "## Skills System" not in request.runtime.context.system_prompt - - -@pytest.mark.asyncio -async def test_awrap_tool_call_activates_skill_when_visible_in_context(): - middleware = _build_middleware() - request = _build_tool_request( - skills=["deliver-prd"], - visible_skills=["deliver-prd", "brainstorming"], - file_path="/skills/brainstorming/SKILL.md", - ) - - async def _handler(_request): - return ToolMessage(content="ok", tool_call_id="tc-1") - - result = await middleware.awrap_tool_call(request, _handler) - assert isinstance(result, Command) - assert result.update["activated_skills"] == ["brainstorming"] - - -@pytest.mark.asyncio -async def test_awrap_tool_call_denies_invisible_skill(): - middleware = _build_middleware() - request = _build_tool_request( - skills=["deliver-prd"], - visible_skills=["deliver-prd"], - file_path="/skills/brainstorming/SKILL.md", - ) - - async def _handler(_request): - return ToolMessage(content="ok", tool_call_id="tc-1") - - result = await middleware.awrap_tool_call(request, _handler) - assert isinstance(result, ToolMessage) - - -@pytest.mark.asyncio -async def test_model_call_injects_dependency_tools_and_mcps_after_activation(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(runtime_middleware, "get_kb_based_tools", lambda db_names=None: []) - - snapshot = _build_snapshot( - selected=["alpha"], - visible=["alpha", "beta"], - dependency_map={"alpha": {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": ["beta"]}}, - ) - - def fake_build_dependency_bundle(received_snapshot, activated): - assert received_snapshot == snapshot - assert activated == ["alpha"] - return {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": ["alpha", "beta"]} - - monkeypatch.setattr(runtime_middleware, "build_dependency_bundle", fake_build_dependency_bundle) - - async def fake_get_enabled_mcp_tools(server_name: str): - if server_name == "mcp-a": - return [_FakeTool(name="mcp_tool")] - return [] - - monkeypatch.setattr(runtime_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools) - - middleware = RuntimeConfigMiddleware( - extra_tools=[_FakeTool(name="mcp_tool")], - enable_model_override=False, - enable_tools_override=True, - enable_system_prompt_override=False, - enable_skills_prompt_override=False, - ) - - request = _build_request( - skills=["alpha"], - tools=["calculator", "dep-tool", "mcp_tool", "read_file"], - state={"activated_skills": ["alpha"]}, - skill_snapshot=snapshot, - ) - - result = await middleware.awrap_model_call(request, _echo_handler) - tool_names = [t.name for t in result.tools] - assert "dep-tool" in tool_names - assert "mcp_tool" in tool_names - assert "calculator" not in tool_names diff --git a/test/test_skill_resolver.py b/test/test_skill_resolver.py deleted file mode 100644 index aef7585a..00000000 --- a/test/test_skill_resolver.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import pytest - -from src.services import skill_resolver as resolver -from src.storage.postgres.models_business import Skill - - -def test_expand_skill_closure_and_dependency_bundle(): - dependency_map = { - "alpha": {"tools": ["t1"], "mcps": ["m1"], "skills": ["beta"]}, - "beta": {"tools": ["t2"], "mcps": ["m2"], "skills": ["gamma"]}, - "gamma": {"tools": ["t3"], "mcps": [], "skills": []}, - } - snapshot: resolver.SkillSessionSnapshot = { - "selected_skills": ["alpha"], - "visible_skills": ["alpha", "beta", "gamma"], - "prompt_metadata": {}, - "dependency_map": dependency_map, - } - - closure = resolver.expand_skill_closure(["alpha"], dependency_map) - assert closure == ["alpha", "beta", "gamma"] - - bundle = resolver.build_dependency_bundle(snapshot, ["alpha"]) - assert bundle["skills"] == ["alpha", "beta", "gamma"] - assert bundle["tools"] == ["t1", "t2", "t3"] - assert bundle["mcps"] == ["m1", "m2"] - - -def test_expand_skill_closure_cycle(): - dependency_map = { - "alpha": {"tools": [], "mcps": [], "skills": ["beta"]}, - "beta": {"tools": [], "mcps": [], "skills": ["alpha"]}, - } - assert resolver.expand_skill_closure(["alpha"], dependency_map) == ["alpha", "beta"] - - -def test_collect_prompt_metadata_order_and_dedup(): - snapshot: resolver.SkillSessionSnapshot = { - "selected_skills": ["beta", "alpha"], - "visible_skills": ["beta", "alpha"], - "prompt_metadata": { - "beta": {"name": "beta", "description": "beta skill", "path": "/skills/beta/SKILL.md"}, - "alpha": {"name": "alpha", "description": "alpha skill", "path": "/skills/alpha/SKILL.md"}, - }, - "dependency_map": {}, - } - result = resolver.collect_prompt_metadata(snapshot, ["beta", "missing", "alpha", "beta"]) - assert [item["name"] for item in result] == ["beta", "alpha"] - assert [item["path"] for item in result] == ["/skills/beta/SKILL.md", "/skills/alpha/SKILL.md"] - - -@pytest.mark.asyncio -async def test_resolve_session_snapshot_and_selected_change(monkeypatch: pytest.MonkeyPatch): - async def fake_list_skills(_db=None): - return [ - Skill( - slug="alpha", - name="alpha", - description="a", - tool_dependencies=[], - mcp_dependencies=[], - skill_dependencies=["beta"], - dir_path="skills/alpha", - ), - Skill( - slug="beta", - name="beta", - description="b", - tool_dependencies=[], - mcp_dependencies=[], - skill_dependencies=[], - dir_path="skills/beta", - ), - ] - - monkeypatch.setattr(resolver, "_list_skills_from_db", fake_list_skills) - - snapshot = await resolver.resolve_session_snapshot([" alpha ", "alpha"]) - assert snapshot["selected_skills"] == ["alpha"] - assert snapshot["visible_skills"] == ["alpha", "beta"] - - assert resolver.is_snapshot_match_selected_skills(snapshot, ["alpha"]) is True - assert resolver.is_snapshot_match_selected_skills(snapshot, ["beta"]) is False diff --git a/test/test_skill_service.py b/test/test_skill_service.py index b693be24..c264c310 100644 --- a/test/test_skill_service.py +++ b/test/test_skill_service.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest from src.services import skill_service as svc +from src.services import tool_service from src.storage.postgres.models_business import Skill @@ -31,15 +32,26 @@ def test_parse_skill_markdown_requires_frontmatter(): svc._parse_skill_markdown("# missing") -def test_validate_skill_slug(): - assert svc.validate_skill_slug("demo-skill") == "demo-skill" - with pytest.raises(ValueError, match="无效 skill slug"): - svc.validate_skill_slug("../bad") +def test_is_valid_skill_slug(): + # Test valid slugs + assert svc.is_valid_skill_slug("demo-skill") is True + assert svc.is_valid_skill_slug("valid-name-123") is True + # Test invalid slugs + assert svc.is_valid_skill_slug("../bad") is False + assert svc.is_valid_skill_slug("Invalid") is False # uppercase not allowed + assert svc.is_valid_skill_slug("") is False @pytest.mark.asyncio async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator", "search"]) + # Mock get_tool_metadata to return tool list + def fake_get_tool_metadata(category=None): + return [ + {"id": "calculator", "name": "Calculator"}, + {"id": "search", "name": "Search"}, + ] + + monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata) monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"]) class FakeRepo: @@ -55,7 +67,7 @@ async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc, "SkillRepository", FakeRepo) result = await svc.get_skill_dependency_options(None) - assert result["tools"] == ["calculator", "search"] + assert result["tools"] == [{"id": "calculator", "name": "Calculator"}, {"id": "search", "name": "Search"}] assert result["mcps"] == ["mcp-a", "mcp-b"] assert result["skills"] == ["alpha", "beta"] @@ -202,7 +214,12 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch): mcp_dependencies=[], skill_dependencies=[], ) - monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"]) + + # Mock get_tool_metadata to return tool list + def fake_get_tool_metadata(category=None): + return [{"id": "calculator", "name": "Calculator"}] + + monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata) monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"]) async def fake_get_skill_or_raise(_db, slug: str): @@ -262,4 +279,4 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch): assert captured["mcp_dependencies"] == ["mcp-a"] assert captured["skill_dependencies"] == ["beta"] assert captured["updated_by"] == "root" - assert updated.skill_dependencies == ["beta"] + assert updated.skill_dependencies == ["beta"] \ No newline at end of file diff --git a/test/test_skills_backend.py b/test/test_skills_backend.py index 0631cc53..26375cab 100644 --- a/test/test_skills_backend.py +++ b/test/test_skills_backend.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path -from types import SimpleNamespace from src.agents.common.backends import skills_backend @@ -43,70 +42,4 @@ def test_selected_skills_backend_readonly_and_visible_only_selected(tmp_path, mo upload_result = backend.upload_files([("/alpha/a.txt", b"a")]) assert len(upload_result) == 1 - assert upload_result[0].error == "permission_denied" - - -def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch): - _prepare_skills_dir(tmp_path) - monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path) - - runtime = SimpleNamespace( - context=SimpleNamespace( - skills=["alpha"], - skill_session_snapshot={ - "selected_skills": ["alpha"], - "visible_skills": ["alpha", "beta"], - "prompt_metadata": {}, - "dependency_map": {}, - }, - ), - state={}, - ) - composite = skills_backend.create_agent_composite_backend(runtime) - - root = composite.ls_info("/") - all_paths = [entry.get("path") for entry in root] - assert "/skills/" in all_paths - - skills_root = composite.ls_info("/skills/") - skill_paths = sorted(entry.get("path") for entry in skills_root) - assert skill_paths == ["/skills/alpha/", "/skills/beta/"] - - denied = composite.write("/skills/alpha/new.md", "x") - assert denied.error and "read-only" in denied.error - - -def test_composite_backend_fallbacks_to_context_skills_when_snapshot_missing(tmp_path, monkeypatch): - _prepare_skills_dir(tmp_path) - monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path) - - runtime = SimpleNamespace( - context=SimpleNamespace(skills=["alpha"]), - state={}, - ) - composite = skills_backend.create_agent_composite_backend(runtime) - skills_root = composite.ls_info("/skills/") - skill_paths = sorted(entry.get("path") for entry in skills_root) - assert skill_paths == ["/skills/alpha/"] - - -def test_composite_backend_reads_visible_skills_from_runtime_context(tmp_path, monkeypatch): - _prepare_skills_dir(tmp_path) - monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path) - - runtime = SimpleNamespace( - context=SimpleNamespace( - skills=["alpha"], - skill_session_snapshot={ - "selected_skills": ["alpha"], - "visible_skills": ["alpha", "beta"], - "prompt_metadata": {}, - "dependency_map": {}, - }, - ), - state=None, - ) - composite = skills_backend.create_agent_composite_backend(runtime) - skills_root = composite.ls_info("/skills/") - skill_paths = sorted(entry.get("path") for entry in skills_root) - assert skill_paths == ["/skills/alpha/", "/skills/beta/"] + assert upload_result[0].error == "permission_denied" \ No newline at end of file