diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index a2476e18..3e0b91ed 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -5,13 +5,10 @@ """ import logging +from typing import Optional import urllib.request import json as _json -from openai import OpenAI -from anthropic import Anthropic -from typing import Optional - try: import boto3 # type: ignore[import] except ImportError: # pragma: no cover — boto3 is an optional extra @@ -53,6 +50,51 @@ _OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" +_PROVIDER_DISPLAY = { + "openai": "OpenAI", + "deepseek": "DeepSeek", + "grok": "Grok", + "moonshot": "Moonshot", + "minimax": "MiniMax", + "openrouter": "OpenRouter", +} + + +def _create_openai_client( + *, + provider: str, + api_key: str, + base_url: Optional[str] = None, +): + """Create an OpenAI SDK client for OpenAI-compatible providers.""" + try: + from openai import OpenAI + except ImportError as exc: + display = _PROVIDER_DISPLAY.get(provider, provider) + raise ImportError( + f"The openai package is required for {display} because CraftBot " + "uses the OpenAI-compatible SDK client for this provider. " + "Install it with the Python that launches CraftBot: " + "`python -m pip install 'openai>=2.0.0'`." + ) from exc + + if base_url: + return OpenAI(api_key=api_key, base_url=base_url) + return OpenAI(api_key=api_key) + + +def _create_anthropic_client(*, api_key: str): + try: + from anthropic import Anthropic + except ImportError as exc: + raise ImportError( + "The anthropic package is required for the Anthropic provider. " + "Install it with the Python that launches CraftBot: " + "`python -m pip install 'anthropic>=0.97.0'`." + ) from exc + return Anthropic(api_key=api_key) + + def _to_openrouter_slug(provider: str, model: str) -> str: """Convert a provider-native model ID to its OpenRouter slug.""" if "/" in model: @@ -120,7 +162,7 @@ def create( Returns: Dictionary with provider context including client instances """ - # OpenAI-compatible providers that use OpenAI client with a custom base_url + # OpenAI-compatible providers that use chat-completions with a custom base_url. _OPENAI_COMPAT = {"minimax", "deepseek", "moonshot", "grok", "openrouter"} if provider not in PROVIDER_CONFIG: @@ -175,7 +217,10 @@ def create( return { "provider": provider, "model": model, - "client": OpenAI(api_key=api_key), + "client": _create_openai_client( + provider=provider, + api_key=api_key, + ), "gemini_client": None, "remote_url": None, "byteplus": None, @@ -215,7 +260,7 @@ def create( "gemini_client": None, "remote_url": None, "byteplus": None, - "anthropic_client": Anthropic(api_key=api_key), + "anthropic_client": _create_anthropic_client(api_key=api_key), "bedrock_client": None, "initialized": True, } @@ -273,7 +318,11 @@ def create( return { "provider": "openrouter", "model": or_model, - "client": OpenAI(api_key=or_key, base_url=_OPENROUTER_BASE_URL), + "client": _create_openai_client( + provider="openrouter", + api_key=or_key, + base_url=_OPENROUTER_BASE_URL, + ), "gemini_client": None, "remote_url": None, "byteplus": None, @@ -290,7 +339,11 @@ def create( return { "provider": provider, "model": model, - "client": OpenAI(api_key=api_key, base_url=resolved_base_url), + "client": _create_openai_client( + provider=provider, + api_key=api_key, + base_url=resolved_base_url, + ), "gemini_client": None, "remote_url": None, "byteplus": None, diff --git a/app/main.py b/app/main.py index 02455d5b..ca05dfd1 100644 --- a/app/main.py +++ b/app/main.py @@ -51,6 +51,10 @@ def _suppress_console_logging_early() -> None: import argparse import asyncio +from app.runtime_preflight import ensure_current_runtime_dependencies + +ensure_current_runtime_dependencies() + # Register agent_core state provider and config before importing AgentBase # This ensures shared code can access state via get_state() from agent_core import StateRegistry, ConfigRegistry diff --git a/app/runtime_preflight.py b/app/runtime_preflight.py new file mode 100644 index 00000000..6925b971 --- /dev/null +++ b/app/runtime_preflight.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""Runtime dependency checks that can run before dependency-heavy imports.""" + +import json +import os +import subprocess +import sys +from typing import Dict, List, Optional, Tuple + + +_PREFLIGHT_OK_ENV = "CRAFTBOT_RUNTIME_PREFLIGHT_OK" +_MISSING_SENTINEL = "__CRAFTBOT_MISSING_RUNTIME_IMPORTS__" + +RUNTIME_IMPORT_CHECKS = { + # Packages imported during backend startup. Provider SDKs stay deferred so + # DeepSeek/OpenRouter users are not forced to install unrelated SDKs. + "requests": "requests", + "pyyaml": "yaml", + "loguru": "loguru", + "nest-asyncio": "nest_asyncio", + "pymongo": "pymongo", + "tzlocal": "tzlocal", + "aiohttp": "aiohttp", + "chromadb": "chromadb", + "tiktoken": "tiktoken", + "mss": "mss", + "httpx": "httpx", + "websockets": "websockets", + "tenacity": "tenacity", + "gradio_client": "gradio_client", + "python-dotenv": "dotenv", + "scikit-learn": "sklearn", + "watchdog": "watchdog", + "croniter": "croniter", +} + + +def _runtime_import_script(checks: Dict[str, str]) -> str: + return ( + "import importlib\n" + "import json\n" + f"checks = {list(checks.items())!r}\n" + "missing = []\n" + "for package_name, import_name in checks:\n" + " try:\n" + " importlib.import_module(import_name)\n" + " except Exception:\n" + " missing.append(package_name)\n" + f"print({_MISSING_SENTINEL!r} + json.dumps(missing))\n" + ) + + +def _runtime_import_command( + use_conda: bool, + env_name: Optional[str], + checks: Dict[str, str], + conda_command: str, +) -> Tuple[List[str], str]: + script = _runtime_import_script(checks) + if use_conda and env_name: + return ( + [ + conda_command, + "run", + "-n", + env_name, + "python", + "-c", + script, + ], + f"conda environment '{env_name}'", + ) + return ([sys.executable, "-c", script], sys.executable) + + +def find_missing_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + checks: Optional[Dict[str, str]] = None, + conda_command: str = "conda", +) -> Tuple[List[str], str]: + """Return missing core imports for the Python runtime that will run the agent.""" + if checks is None: + checks = RUNTIME_IMPORT_CHECKS + cmd, runtime_label = _runtime_import_command( + use_conda, env_name, checks, conda_command + ) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except Exception: + return list(checks), runtime_label + if result.returncode != 0: + return list(checks), runtime_label + + for line in reversed(result.stdout.splitlines()): + if line.startswith(_MISSING_SENTINEL): + try: + return json.loads(line[len(_MISSING_SENTINEL) :]), runtime_label + except json.JSONDecodeError: + return list(checks), runtime_label + return list(checks), runtime_label + + +def print_missing_runtime_dependencies( + *, + missing: List[str], + runtime_label: str, + use_conda: bool, + env_name: Optional[str], +) -> None: + print("\nError: CraftBot Python dependencies are missing.") + print(f"Runtime checked: {runtime_label}") + print("\nMissing imports:") + for package_name in missing: + print(f" - {package_name}") + + print( + "\nThis usually means CraftBot is running with a different Python " + "than the one used during install." + ) + print("\nFix:") + if use_conda and env_name: + print(" python install.py --conda") + print(f" conda run -n {env_name} python run.py") + else: + print(f" {sys.executable} install.py") + print(f" {sys.executable} run.py") + print("\nIf you installed CraftBot with another Python, start it with that Python.") + + +def ensure_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + conda_command: str = "conda", +) -> None: + if getattr(sys, "frozen", False): + return + + missing, runtime_label = find_missing_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + conda_command=conda_command, + ) + if missing: + print_missing_runtime_dependencies( + missing=missing, + runtime_label=runtime_label, + use_conda=use_conda, + env_name=env_name, + ) + sys.exit(1) + + +def mark_runtime_dependencies_checked() -> None: + os.environ[_PREFLIGHT_OK_ENV] = "1" + + +def ensure_current_runtime_dependencies() -> None: + """Check imports for direct app.main usage with the current interpreter.""" + if os.environ.get(_PREFLIGHT_OK_ENV) == "1": + return + ensure_runtime_dependencies(use_conda=False, env_name=None) diff --git a/craftbot.py b/craftbot.py index 8a3eeb7e..aa7f77aa 100644 --- a/craftbot.py +++ b/craftbot.py @@ -63,6 +63,7 @@ def flush(self) -> None: sys.stderr = _NullIO() import os +import shlex import shutil import signal import subprocess @@ -183,6 +184,7 @@ def installed_exe_path() -> Optional[str]: SYSTEMD_SERVICE = "craftbot" # Linux systemd service name LAUNCHD_LABEL = "com.craftbot.agent" # macOS launchd label BROWSER_URL = "http://localhost:7925" +BACKEND_URL = "http://localhost:7926" SHORTCUT_NAME = "CraftBot.lnk" # Bundled icons live in sys._MEIPASS in frozen mode (PyInstaller's runtime # extract dir) and alongside craftbot.py in source mode. _ensure_ico() copies @@ -322,6 +324,32 @@ def _remove_pid() -> None: pass +def _tail_log_lines(n: int = 30, start_offset: int = 0) -> str: + if not os.path.isfile(LOG_FILE): + return "" + try: + with open(LOG_FILE, "r", errors="replace") as f: + if start_offset: + f.seek(start_offset) + lines = f.readlines() + except Exception: + return "" + return "".join(lines[-n:]) + + +def _wait_for_startup_exit( + proc, timeout: float = 8.0, ready_log_offset: int = 0 +) -> Optional[int]: + deadline = time.time() + timeout + while time.time() < deadline: + try: + return proc.wait(timeout=0.1) + except subprocess.TimeoutExpired: + if "CRAFTBOT IS READY" in _tail_log_lines(80, ready_log_offset): + return None + return None + + def _is_running(pid: int) -> bool: """Return True if a process with the given PID is currently alive.""" if _PLATFORM == "win32": @@ -442,8 +470,12 @@ def _poll_and_open() -> None: subprocess.Popen([python, "-c", poll_script], **kwargs) -def cmd_start(extra_args: List[str]) -> None: - """Start CraftBot as a detached background process.""" +def cmd_start(extra_args: List[str]) -> bool: + """Start CraftBot as a detached background process. + + Returns True once the service survives the early startup check; False when + launch fails before CraftBot can be used. + """ pid = _read_pid() if pid and _is_running(pid): cmd_stop() @@ -462,7 +494,7 @@ def cmd_start(extra_args: List[str]) -> None: installed = installed_exe_path() if not installed: print("Error: no installed agent found — run install first.") - return + return False cmd = [installed] + run_args else: python = _python_exe() @@ -479,6 +511,7 @@ def cmd_start(extra_args: List[str]) -> None: log_fh.write(f"Command: {' '.join(cmd)}\n") log_fh.write(f"{'=' * 60}\n") log_fh.flush() + ready_log_offset = log_fh.tell() env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" @@ -498,11 +531,27 @@ def cmd_start(extra_args: List[str]) -> None: except FileNotFoundError as e: log_fh.close() print(f" {RED}✗{RESET} {WHITE}Could not launch CraftBot — {e}{RESET}") - return + return False # Parent closes its copy — the child process (run.py) keeps the fd open log_fh.close() _write_pid(proc.pid) + + # Catch immediate startup failures before reporting success. This surfaces + # wrong-Python dependency errors from run.py instead of leaving a stale PID. + exit_code = _wait_for_startup_exit(proc, ready_log_offset=ready_log_offset) + + if exit_code is not None: + _remove_pid() + print( + f" {RED}✗{RESET} {WHITE}CraftBot failed to start{RESET} {DIM}exit {exit_code}{RESET}" + ) + log_tail = _tail_log_lines() + if log_tail: + print(f"\n{DIM}Last log lines:{RESET}\n{log_tail}", end="") + print(f"\nCheck logs: {sys.executable} craftbot.py logs") + return False + print( f" {GREEN}▸{RESET} {WHITE}CRAFTBOT STARTED{RESET} {DIM}PID {proc.pid}{RESET}" ) @@ -518,6 +567,7 @@ def cmd_start(extra_args: List[str]) -> None: if open_browser: print(f" {DIM}░░{RESET} {ORANGE}{BROWSER_URL}{RESET}") _open_browser_detached(BROWSER_URL) + return True def cmd_stop() -> None: @@ -613,10 +663,11 @@ def cmd_logs(n: int = 50) -> None: print(f" {RED}✗{RESET} {WHITE}Error reading log: {e}{RESET}") -def cmd_restart(extra_args: List[str]) -> None: +def cmd_restart(extra_args: List[str]) -> bool: + """Restart CraftBot and return whether the new service started.""" cmd_stop() time.sleep(1) - cmd_start(extra_args) + return cmd_start(extra_args) # ─── Desktop shortcut ───────────────────────────────────────────────────────── @@ -766,9 +817,23 @@ def _create_desktop_shortcut_unix() -> None: return try: if _PLATFORM == "darwin": - # macOS does not support XDG .desktop files — create a double-clickable .command script + # macOS does not support XDG .desktop files — create a double-clickable + # .command script. In source mode, start the service instead of only + # opening the URL so the shortcut works after CraftBot is stopped. shortcut_path = os.path.join(desktop, "CraftBot.command") - content = f"#!/bin/sh\nopen '{BROWSER_URL}'\n" + if IS_FROZEN: + content = f"#!/bin/sh\nopen {shlex.quote(BROWSER_URL)}\n" + else: + content = ( + "#!/bin/sh\n" + f"cd {shlex.quote(BASE_DIR)} || exit 1\n" + f"if curl -fsS {shlex.quote(BROWSER_URL)} >/dev/null 2>&1 " + f"&& curl -fsS {shlex.quote(BACKEND_URL)} >/dev/null 2>&1; then\n" + f" open {shlex.quote(BROWSER_URL)}\n" + "else\n" + f" exec {shlex.quote(_python_exe())} craftbot.py start\n" + "fi\n" + ) with open(shortcut_path, "w") as f: f.write(content) os.chmod(shortcut_path, 0o755) @@ -1205,10 +1270,11 @@ def _full_install_frozen( )(run_args) # 6. Start the service via the extracted agent EXE - cmd_start(extra_args) + if not cmd_start(extra_args): + raise RuntimeError("CraftBot installed but failed to start.") -def cmd_install(extra_args: List[str]) -> None: +def cmd_install(extra_args: List[str]) -> bool: """Install dependencies (source mode) or copy-and-register (frozen mode), then start the service.""" if IS_FROZEN: @@ -1219,7 +1285,7 @@ def cmd_install(extra_args: List[str]) -> None: target_dir = default_install_location() print(f" {ORANGE}▸{RESET} {WHITE}Installing CraftBot to {target_dir}{RESET}") _full_install_frozen(target_dir, extra_args) - return + return True _warn_path_issues() # ── Step 1: Install dependencies via install.py ──────────────────────── @@ -1241,7 +1307,7 @@ def cmd_install(extra_args: List[str]) -> None: print( f" {DIM}Run 'python install.py' directly to see the full error.{RESET}" ) - return + return False # Verify critical packages are actually importable with this interpreter. # install.py may exit 0 while packages ended up in a different site-packages. @@ -1257,7 +1323,7 @@ def cmd_install(extra_args: List[str]) -> None: print( f" {DIM}Run 'python install.py' to reinstall with this Python.{RESET}" ) - return + return False print() else: print(f" {DIM}(install.py not found — skipping dependency install){RESET}\n") @@ -1281,13 +1347,16 @@ def cmd_install(extra_args: List[str]) -> None: # ── Step 3: Start the service now ────────────────────────────────────── _retro_step(3, 3, "Starting CraftBot") - cmd_start(extra_args) + if not cmd_start(extra_args): + print(f"\n {RED}✗{RESET} {WHITE}CraftBot failed to start.{RESET}") + return False print(f"\n {GREEN}▸{RESET} {WHITE}CRAFTBOT IS RUNNING IN THE BACKGROUND{RESET}") print(f" {DIM}░░{RESET} {ORANGE}{BROWSER_URL}{RESET}") print("You can close this window now.") time.sleep(2) _close_console_window() + return True def _remove_desktop_shortcut() -> None: @@ -1541,13 +1610,15 @@ def main() -> None: rest = args[1:] if command == "start": - cmd_start(rest) + if not cmd_start(rest): + sys.exit(1) elif command == "stop": cmd_stop() elif command == "restart": - cmd_restart(rest) + if not cmd_restart(rest): + sys.exit(1) elif command == "status": cmd_status() @@ -1563,7 +1634,8 @@ def main() -> None: cmd_logs(n) elif command == "install": - cmd_install(rest) + if not cmd_install(rest): + sys.exit(1) elif command == "uninstall": cmd_uninstall() diff --git a/run.py b/run.py index 7db8a51b..ca37ed3b 100644 --- a/run.py +++ b/run.py @@ -30,6 +30,11 @@ import atexit from typing import Tuple, Optional, Dict, Any, List +from app.runtime_preflight import ( + ensure_runtime_dependencies, + mark_runtime_dependencies_checked, +) + multiprocessing.freeze_support() # Configuration is loaded from settings.json via the agent startup @@ -119,7 +124,6 @@ def _bootstrap_frozen(): OMNIPARSER_ENV_NAME = "omni" OMNIPARSER_SERVER_URL = os.getenv("OMNIPARSER_BASE_URL", "http://localhost:7861") - # ========================================== # TERMINAL COLORS (orange/white brand palette) # ========================================== @@ -1254,6 +1258,13 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: print("Run 'python install.py' or 'python install.py --conda' first.\n") sys.exit(1) + ensure_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + conda_command=get_conda_command() if use_conda else "conda", + ) + mark_runtime_dependencies_checked() + # Start OmniParser only if GUI mode and it was installed if gui_mode and gui_installed: if not launch_omniparser(use_conda): diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py new file mode 100644 index 00000000..c71c96b4 --- /dev/null +++ b/tests/test_craftbot_service.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +import shlex +import subprocess +import sys + +import pytest +import craftbot + + +class _ExitedProcess: + pid = 12345 + + def wait(self, timeout=None): + return 1 + + +class _RunningProcess: + pid = 23456 + + def wait(self, timeout=None): + raise subprocess.TimeoutExpired("craftbot", timeout) + + +class _DelayedFailureProcess: + pid = 34567 + + def __init__(self): + self.calls = 0 + + def wait(self, timeout=None): + self.calls += 1 + if self.calls == 1: + raise subprocess.TimeoutExpired("craftbot", timeout) + return 1 + + +def test_start_reports_immediate_child_failure(tmp_path, monkeypatch, capsys): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + log_file.write_text("dependency failure\n", encoding="utf-8") + events = [] + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append("browser") + ) + + def fake_popen(*args, **kwargs): + return _ExitedProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + assert "dependency failure" in output + assert not pid_file.exists() + assert events == [] + + +def test_start_reports_success_for_long_running_child(tmp_path, monkeypatch, capsys): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + events = [] + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append(("browser", url)) + ) + + def fake_popen(*args, **kwargs): + kwargs["stdout"].write(" ▸ CRAFTBOT IS READY\n") + kwargs["stdout"].flush() + return _RunningProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is True + + output = capsys.readouterr().out + assert "CRAFTBOT STARTED" in output + assert pid_file.read_text() == "23456" + assert events == ["shortcut", ("browser", craftbot.BROWSER_URL)] + + +def test_start_ignores_stale_ready_marker_when_child_exits( + tmp_path, monkeypatch, capsys +): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + log_file.write_text("old run\nCRAFTBOT IS READY\n", encoding="utf-8") + events = [] + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append(("browser", url)) + ) + + def fake_popen(*args, **kwargs): + return _DelayedFailureProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + assert not pid_file.exists() + assert events == [] + + +def test_macos_source_shortcut_opens_or_starts_service(tmp_path, monkeypatch, capsys): + desktop = tmp_path / "Desktop" + desktop.mkdir() + base_dir = tmp_path / "Craft Bot" + base_dir.mkdir() + python_exe = "/Applications/Python 3.10/bin/python3.10" + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_python_exe", lambda: python_exe) + + craftbot._create_desktop_shortcut_unix() + + shortcut = desktop / "CraftBot.command" + content = shortcut.read_text() + assert f"cd {shlex.quote(str(base_dir))}" in content + assert "curl -fsS http://localhost:7925" in content + assert "curl -fsS http://localhost:7926" in content + assert "open http://localhost:7925" in content + assert f"exec {shlex.quote(python_exe)} craftbot.py start" in content + + output = capsys.readouterr().out + assert "Desktop shortcut created" in output + + +def test_macos_frozen_shortcut_only_opens_url(tmp_path, monkeypatch): + desktop = tmp_path / "Desktop" + desktop.mkdir() + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", True) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + + craftbot._create_desktop_shortcut_unix() + + content = (desktop / "CraftBot.command").read_text() + assert content == "#!/bin/sh\nopen http://localhost:7925\n" + assert "craftbot.py start" not in content + + +def test_linux_shortcut_remains_url_opener(tmp_path, monkeypatch): + desktop = tmp_path / "Desktop" + desktop.mkdir() + + monkeypatch.setattr(craftbot, "_PLATFORM", "linux") + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + + craftbot._create_desktop_shortcut_unix() + + content = (desktop / "CraftBot.desktop").read_text() + assert "Exec=" in content + assert "http://localhost:7925" in content + assert "craftbot.py start" not in content + + +def test_cli_start_exits_nonzero_when_start_fails(monkeypatch): + monkeypatch.setattr(sys, "argv", ["craftbot.py", "start"]) + monkeypatch.setattr(craftbot, "cmd_start", lambda args: False) + + with pytest.raises(SystemExit) as exc: + craftbot.main() + + assert exc.value.code == 1 + + +def test_cli_restart_exits_nonzero_when_restart_fails(monkeypatch): + monkeypatch.setattr(sys, "argv", ["craftbot.py", "restart"]) + monkeypatch.setattr(craftbot, "cmd_restart", lambda args: False) + + with pytest.raises(SystemExit) as exc: + craftbot.main() + + assert exc.value.code == 1 + + +def test_source_install_returns_false_when_service_start_fails( + tmp_path, monkeypatch, capsys +): + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(tmp_path)) + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "_is_installed", lambda: True) + monkeypatch.setattr(craftbot, "cmd_start", lambda args: False) + monkeypatch.setattr( + craftbot, + "_close_console_window", + lambda: (_ for _ in ()).throw(AssertionError("should not close")), + ) + + assert craftbot.cmd_install([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output diff --git a/tests/test_model_factory.py b/tests/test_model_factory.py new file mode 100644 index 00000000..298cd8dd --- /dev/null +++ b/tests/test_model_factory.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +from pathlib import Path +import subprocess +import sys +import textwrap + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _run_with_blocked_sdks(code: str) -> subprocess.CompletedProcess: + script = textwrap.dedent( + f""" + import importlib.abc + import sys + + class BlockProviderSdks(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if ( + fullname == "openai" + or fullname.startswith("openai.") + or fullname == "anthropic" + or fullname.startswith("anthropic.") + ): + raise ImportError(f"{{fullname}} intentionally blocked") + return None + + for name in list(sys.modules): + if ( + name == "openai" + or name.startswith("openai.") + or name == "anthropic" + or name.startswith("anthropic.") + ): + del sys.modules[name] + sys.meta_path.insert(0, BlockProviderSdks()) + + {textwrap.indent(textwrap.dedent(code), " ")} + """ + ) + return subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + text=True, + capture_output=True, + ) + + +def test_importing_model_factory_does_not_require_provider_sdks(): + result = _run_with_blocked_sdks( + """ + from agent_core.core.models.factory import ModelFactory + assert ModelFactory is not None + """ + ) + + assert result.returncode == 0, result.stderr + + +def test_deferred_openai_compatible_providers_do_not_require_openai_sdk(): + result = _run_with_blocked_sdks( + """ + from agent_core.core.models.factory import ModelFactory + from agent_core.core.models.types import InterfaceType + + for provider in ("deepseek", "grok", "moonshot", "minimax", "openrouter"): + ctx = ModelFactory.create( + provider=provider, + interface=InterfaceType.LLM, + deferred=True, + ) + assert ctx["initialized"] is False + assert ctx["client"] is None + """ + ) + + assert result.returncode == 0, result.stderr + + +def test_openai_compatible_providers_report_missing_openai_sdk(): + result = _run_with_blocked_sdks( + """ + from agent_core.core.models.factory import ModelFactory + from agent_core.core.models.types import InterfaceType + + providers = { + "deepseek": "DeepSeek", + "grok": "Grok", + "moonshot": "Moonshot", + "minimax": "MiniMax", + "openrouter": "OpenRouter", + } + + for provider, display in providers.items(): + try: + ModelFactory.create( + provider=provider, + interface=InterfaceType.LLM, + api_key=f"{provider}-key", + ) + except ImportError as exc: + message = str(exc) + assert "openai package is required" in message + assert display in message + else: + raise AssertionError( + f"expected missing OpenAI SDK to raise ImportError for {provider}" + ) + """ + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_run_dependency_check.py b/tests/test_run_dependency_check.py new file mode 100644 index 00000000..7275d382 --- /dev/null +++ b/tests/test_run_dependency_check.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +import subprocess +import sys +import textwrap + +from app import runtime_preflight + + +def test_find_missing_runtime_dependencies_reports_current_interpreter(monkeypatch): + seen_commands = [] + + def fake_run(cmd, capture_output, text, timeout): + seen_commands.append(cmd) + return subprocess.CompletedProcess( + cmd, + 0, + stdout='ignored import noise\n__CRAFTBOT_MISSING_RUNTIME_IMPORTS__["requests"]\n', + ) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( + use_conda=False, + env_name=None, + checks={"requests": "requests", "aiohttp": "aiohttp"}, + ) + + assert missing == ["requests"] + assert runtime_label == sys.executable + assert len(seen_commands) == 1 + assert seen_commands[0][:2] == [sys.executable, "-c"] + assert "importlib.import_module(import_name)" in seen_commands[0][2] + + +def test_find_missing_runtime_dependencies_checks_conda_env(monkeypatch): + seen_commands = [] + + def fake_run(cmd, capture_output, text, timeout): + seen_commands.append(cmd) + return subprocess.CompletedProcess( + cmd, + 0, + stdout="__CRAFTBOT_MISSING_RUNTIME_IMPORTS__[]\n", + ) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( + use_conda=True, + env_name="craftbot", + checks={"requests": "requests"}, + conda_command="conda", + ) + + assert missing == [] + assert runtime_label == "conda environment 'craftbot'" + assert len(seen_commands) == 1 + assert seen_commands[0][:6] == ["conda", "run", "-n", "craftbot", "python", "-c"] + assert "importlib.import_module(import_name)" in seen_commands[0][6] + + +def test_find_missing_runtime_dependencies_marks_all_missing_on_probe_failure( + monkeypatch, +): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess(cmd, 1, stdout="import failed\n") + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + checks = {"requests": "requests", "aiohttp": "aiohttp"} + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( + use_conda=False, + env_name=None, + checks=checks, + ) + + assert missing == ["requests", "aiohttp"] + assert runtime_label == sys.executable + + +def test_find_missing_runtime_dependencies_marks_all_missing_without_sentinel( + monkeypatch, +): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess(cmd, 0, stdout="third-party import noise\n") + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + checks = {"requests": "requests", "aiohttp": "aiohttp"} + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( + use_conda=False, + env_name=None, + checks=checks, + ) + + assert missing == ["requests", "aiohttp"] + assert runtime_label == sys.executable + + +def test_find_missing_runtime_dependencies_marks_all_missing_on_malformed_sentinel( + monkeypatch, +): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess( + cmd, + 0, + stdout="__CRAFTBOT_MISSING_RUNTIME_IMPORTS__not-json\n", + ) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + checks = {"requests": "requests", "aiohttp": "aiohttp"} + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( + use_conda=False, + env_name=None, + checks=checks, + ) + + assert missing == ["requests", "aiohttp"] + assert runtime_label == sys.executable + + +def test_current_runtime_preflight_skips_when_launcher_already_checked( + monkeypatch, +): + monkeypatch.setenv("CRAFTBOT_RUNTIME_PREFLIGHT_OK", "1") + + def fail_run(*args, **kwargs): + raise AssertionError("preflight should not run twice") + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fail_run) + + runtime_preflight.ensure_current_runtime_dependencies() + + +def test_print_missing_runtime_dependencies_includes_fix(monkeypatch, capsys): + monkeypatch.setattr(sys, "executable", "/opt/homebrew/bin/python3") + + runtime_preflight.print_missing_runtime_dependencies( + missing=["requests", "aiohttp"], + runtime_label="/opt/homebrew/bin/python3", + use_conda=False, + env_name=None, + ) + + output = capsys.readouterr().out + assert "requests" in output + assert "aiohttp" in output + assert "/opt/homebrew/bin/python3" in output + assert "/opt/homebrew/bin/python3 install.py" in output + + +def test_runtime_preflight_does_not_require_provider_sdks(): + assert "openai" not in runtime_preflight.RUNTIME_IMPORT_CHECKS + assert "anthropic" not in runtime_preflight.RUNTIME_IMPORT_CHECKS + + +def test_app_main_runs_preflight_before_agent_core_import(): + code = textwrap.dedent( + """ + import importlib.abc + import sys + import types + + fake_preflight = types.ModuleType("app.runtime_preflight") + + def ensure_current_runtime_dependencies(): + raise SystemExit(77) + + fake_preflight.ensure_current_runtime_dependencies = ensure_current_runtime_dependencies + sys.modules["app.runtime_preflight"] = fake_preflight + + class BlockAgentCore(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "agent_core" or fullname.startswith("agent_core."): + raise AssertionError("agent_core imported before runtime preflight") + return None + + sys.meta_path.insert(0, BlockAgentCore()) + + try: + import app.main # noqa: F401 + except SystemExit as exc: + assert exc.code == 77 + else: + raise AssertionError("app.main did not run runtime preflight") + """ + ) + + result = subprocess.run( + [sys.executable, "-c", code], + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr