"""Tests for ``jarvis ask --agent`` CLI integration.""" from __future__ import annotations import importlib from dataclasses import dataclass from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent from openjarvis.cli import cli from openjarvis.core.types import ToolCall, ToolResult from openjarvis.tools._stubs import BaseTool, ToolSpec _ask_mod = importlib.import_module("openjarvis.cli.ask") def _mock_engine(content="Hello from engine"): """Create a mock engine that returns content.""" engine = MagicMock() engine.engine_id = "mock" engine.health.return_value = True engine.list_models.return_value = ["test-model"] engine.generate.return_value = { "content": content, "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, "model": "test-model", "finish_reason": "stop", } return engine def _register_agents(): """Re-register agents after registry clear.""" from openjarvis.agents.orchestrator import OrchestratorAgent from openjarvis.agents.simple import SimpleAgent from openjarvis.core.registry import AgentRegistry for name, cls in [ ("simple", SimpleAgent), ("orchestrator", OrchestratorAgent), ]: if not AgentRegistry.contains(name): AgentRegistry.register_value(name, cls) def _register_tools(): """Re-register tools after registry clear.""" from openjarvis.core.registry import ToolRegistry from openjarvis.tools.calculator import CalculatorTool from openjarvis.tools.file_read import FileReadTool from openjarvis.tools.llm_tool import LLMTool from openjarvis.tools.retrieval import RetrievalTool from openjarvis.tools.think import ThinkTool for name, cls in [ ("calculator", CalculatorTool), ("think", ThinkTool), ("retrieval", RetrievalTool), ("llm", LLMTool), ("file_read", FileReadTool), ]: if not ToolRegistry.contains(name): ToolRegistry.register_value(name, cls) class _DangerousTool(BaseTool): tool_id = "dangerous" @property def spec(self) -> ToolSpec: return ToolSpec( name="dangerous", description="Confirmation-gated test tool.", requires_confirmation=True, ) def execute(self, **params) -> ToolResult: return ToolResult( tool_name="dangerous", content="executed!", success=True, ) class _ConfirmingAgent(ToolUsingAgent): agent_id = "confirming_agent" def run(self, input, context: AgentContext | None = None, **kwargs): result = self._executor.execute( ToolCall(id="confirm", name="dangerous", arguments="{}") ) return AgentResult( content=result.content, tool_results=[result], turns=1, ) @dataclass class _EngineSetup: engine: MagicMock config: object @pytest.fixture def agent_setup(): from openjarvis.core.config import JarvisConfig from openjarvis.core.registry import AgentRegistry, ToolRegistry engine = _mock_engine("unused") config = JarvisConfig() config.intelligence.default_model = "test-model" config.agent.max_turns = 3 AgentRegistry.register_value("confirming_agent", _ConfirmingAgent) ToolRegistry.register_value("dangerous", _DangerousTool) with ( patch.object(_ask_mod, "load_config", return_value=config), patch.object(_ask_mod, "get_engine", return_value=("mock", engine)), patch.object(_ask_mod, "discover_engines", return_value=[("mock", engine)]), patch.object( _ask_mod, "discover_models", return_value={"mock": ["test-model"]}, ), patch.object(_ask_mod, "register_builtin_models"), patch.object(_ask_mod, "merge_discovered_models"), ): yield _EngineSetup(engine=engine, config=config) @pytest.fixture def runner(): return CliRunner() @pytest.fixture def mock_setup(): """Patch engine discovery to avoid needing a running engine.""" engine = _mock_engine() _register_agents() _register_tools() with ( patch.object(_ask_mod, "load_config") as mock_cfg, patch.object(_ask_mod, "get_engine") as mock_ge, patch.object(_ask_mod, "discover_engines") as mock_de, patch.object(_ask_mod, "discover_models") as mock_dm, patch.object(_ask_mod, "register_builtin_models"), patch.object(_ask_mod, "merge_discovered_models"), ): from openjarvis.core.config import JarvisConfig mock_cfg.return_value = JarvisConfig() mock_ge.return_value = ("mock", engine) mock_de.return_value = [("mock", engine)] mock_dm.return_value = {"mock": ["test-model"]} yield engine class TestAskAgentOption: def test_help_shows_agent_option(self, runner): result = runner.invoke(cli, ["ask", "--help"]) assert "--agent" in result.output or "-a" in result.output def test_help_shows_tools_option(self, runner): result = runner.invoke(cli, ["ask", "--help"]) assert "--tools" in result.output def test_agent_simple(self, runner, mock_setup): result = runner.invoke(cli, ["ask", "--agent", "simple", "Hello"]) assert result.exit_code == 0 assert "Hello from engine" in result.output def test_agent_orchestrator_no_tools(self, runner, mock_setup): result = runner.invoke( cli, ["ask", "--agent", "orchestrator", "Hello"], ) assert result.exit_code == 0 def test_agent_orchestrator_with_tools(self, runner, mock_setup): result = runner.invoke( cli, [ "ask", "--agent", "orchestrator", "--tools", "calculator,think", "What is 2+2?", ], ) assert result.exit_code == 0 def test_agent_json_output(self, runner, mock_setup): result = runner.invoke( cli, ["ask", "--agent", "simple", "--json", "Hello"], ) assert result.exit_code == 0 assert '"content"' in result.output assert '"turns"' in result.output def test_unknown_agent(self, runner, mock_setup): result = runner.invoke( cli, ["ask", "--agent", "nonexistent", "Hello"], ) assert result.exit_code != 0 def test_no_agent_flag_falls_back_to_config_default_agent(self, runner, mock_setup): """When --agent is omitted, ``config.agent.default_agent`` is used. The default ``JarvisConfig`` sets ``default_agent = "simple"``, so ``jarvis ask "..."`` should route through SimpleAgent rather than the direct-to-engine path. Without this fallback, persona settings (``default_system_prompt`` and SOUL.md/MEMORY.md/USER.md) would be silently bypassed. """ result = runner.invoke(cli, ["ask", "Hello"]) assert result.exit_code == 0 assert "Hello from engine" in result.output def test_explicit_empty_agent_opts_out_of_agent_mode(self, runner, mock_setup): """``--agent ""`` is the explicit opt-out: use direct-to-engine.""" result = runner.invoke(cli, ["ask", "--agent", "", "Hello"]) assert result.exit_code == 0 assert "Hello from engine" in result.output def test_no_agent_with_blank_config_default_uses_direct_mode( self, runner, mock_setup, monkeypatch ): """When config's ``default_agent`` is blank and --agent is omitted, the original direct-to-engine path is preserved.""" from openjarvis.core.config import JarvisConfig cfg = JarvisConfig() cfg.agent.default_agent = "" monkeypatch.setattr(_ask_mod, "load_config", lambda *a, **kw: cfg) result = runner.invoke(cli, ["ask", "Hello"]) assert result.exit_code == 0 assert "Hello from engine" in result.output def test_agent_simple_with_model(self, runner, mock_setup): result = runner.invoke( cli, ["ask", "--agent", "simple", "-m", "test-model", "Hello"], ) assert result.exit_code == 0 def test_agent_simple_with_temperature(self, runner, mock_setup): result = runner.invoke( cli, ["ask", "--agent", "simple", "-t", "0.1", "Hello"], ) assert result.exit_code == 0 @pytest.mark.parametrize( ("tools_enabled", "agent_tools"), [ (["dangerous"], ""), ("dangerous", ""), ("", "dangerous"), ], ) def test_agent_uses_configured_tools_by_default( self, runner, agent_setup, tools_enabled, agent_tools, ): agent_setup.config.tools.enabled = tools_enabled agent_setup.config.agent.tools = agent_tools result = runner.invoke( cli, ["ask", "--agent", "confirming_agent", "Hello"], ) assert result.exit_code == 0 assert "executed!" in result.output agent_setup.engine.generate.assert_not_called() class TestBuildTools: def test_build_calculator(self, mock_setup): from openjarvis.cli.ask import _build_tools from openjarvis.core.config import JarvisConfig _register_tools() config = JarvisConfig() tools = _build_tools(["calculator"], config, mock_setup, "test-model") assert len(tools) == 1 assert tools[0].tool_id == "calculator" def test_build_think(self, mock_setup): from openjarvis.cli.ask import _build_tools from openjarvis.core.config import JarvisConfig _register_tools() config = JarvisConfig() tools = _build_tools(["think"], config, mock_setup, "test-model") assert len(tools) == 1 assert tools[0].tool_id == "think" def test_build_unknown_tool_skipped(self, mock_setup): from openjarvis.cli.ask import _build_tools from openjarvis.core.config import JarvisConfig config = JarvisConfig() tools = _build_tools(["nonexistent"], config, mock_setup, "test-model") assert len(tools) == 0 def test_build_empty_names(self, mock_setup): from openjarvis.cli.ask import _build_tools from openjarvis.core.config import JarvisConfig config = JarvisConfig() tools = _build_tools(["", " "], config, mock_setup, "test-model") assert len(tools) == 0 def test_build_multiple_tools(self, mock_setup): from openjarvis.cli.ask import _build_tools from openjarvis.core.config import JarvisConfig _register_tools() config = JarvisConfig() tools = _build_tools(["calculator", "think"], config, mock_setup, "test-model") assert len(tools) == 2 class TestPersonaFilesReachModel: """End-to-end coverage for SOUL.md / MEMORY.md / USER.md integration. Without these tests, the ``SystemPromptBuilder`` and the persona files documented in the README are present in the codebase but never reach the model — the bug this suite is meant to prevent regressing into. """ def test_soul_md_content_reaches_engine_in_simple_agent( self, runner, monkeypatch, tmp_path ): """SOUL.md content must appear in the system message sent to the engine.""" from openjarvis.core.config import JarvisConfig # Write a SOUL.md with a unique sentinel string we can grep for soul = tmp_path / "SOUL.md" soul.write_text("PERSONA_SENTINEL_zh_jarvis", encoding="utf-8") memory = tmp_path / "MEMORY.md" memory.write_text("MEMORY_SENTINEL", encoding="utf-8") user = tmp_path / "USER.md" user.write_text("USER_SENTINEL", encoding="utf-8") cfg = JarvisConfig() cfg.memory_files.soul_path = str(soul) cfg.memory_files.memory_path = str(memory) cfg.memory_files.user_path = str(user) cfg.agent.default_system_prompt = "BASELINE_TEMPLATE" # Disable memory context injection so it doesn't add another SYSTEM # message and confuse the assertion. cfg.agent.context_from_memory = False engine = _mock_engine() _register_agents() _register_tools() with ( patch.object(_ask_mod, "load_config", return_value=cfg), patch.object(_ask_mod, "get_engine", return_value=("mock", engine)), patch.object(_ask_mod, "discover_engines", return_value=[("mock", engine)]), patch.object( _ask_mod, "discover_models", return_value={"mock": ["test-model"]} ), patch.object(_ask_mod, "register_builtin_models"), patch.object(_ask_mod, "merge_discovered_models"), ): result = runner.invoke(cli, ["ask", "--agent", "simple", "Hello"]) assert result.exit_code == 0, result.output # Grab the messages passed to engine.generate engine.generate.assert_called() call_args = engine.generate.call_args messages = ( call_args.args[0] if call_args.args else call_args.kwargs.get("messages") ) assert messages is not None and len(messages) >= 2 system_messages = [m for m in messages if str(m.role).endswith("SYSTEM")] assert system_messages, f"No SYSTEM message in {messages!r}" joined = "\n".join(m.content for m in system_messages) assert "BASELINE_TEMPLATE" in joined assert "PERSONA_SENTINEL_zh_jarvis" in joined assert "MEMORY_SENTINEL" in joined assert "USER_SENTINEL" in joined def test_orchestrator_keeps_its_own_system_prompt( self, runner, monkeypatch, tmp_path ): """OrchestratorAgent's __init__ doesn't accept ``prompt_builder``; the wiring must skip it silently rather than crash.""" from openjarvis.core.config import JarvisConfig soul = tmp_path / "SOUL.md" soul.write_text("ORCH_PERSONA_SENTINEL", encoding="utf-8") cfg = JarvisConfig() cfg.memory_files.soul_path = str(soul) cfg.agent.context_from_memory = False engine = _mock_engine() _register_agents() _register_tools() with ( patch.object(_ask_mod, "load_config", return_value=cfg), patch.object(_ask_mod, "get_engine", return_value=("mock", engine)), patch.object(_ask_mod, "discover_engines", return_value=[("mock", engine)]), patch.object( _ask_mod, "discover_models", return_value={"mock": ["test-model"]} ), patch.object(_ask_mod, "register_builtin_models"), patch.object(_ask_mod, "merge_discovered_models"), ): result = runner.invoke(cli, ["ask", "--agent", "orchestrator", "Hello"]) # Pass condition: doesn't crash with TypeError on prompt_builder kwarg. assert result.exit_code == 0, result.output