diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a276fdb1d2..d65aa43298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,5 +142,8 @@ jobs: if: steps.docs-check.outputs.docs_only != 'true' - run: mkdir -p data # sqlite DB lives at ./data/app.db if: steps.docs-check.outputs.docs_only != 'true' + - name: Smoke gate (fresh-install confidence) + if: steps.docs-check.outputs.docs_only != 'true' + run: python -m pytest -q tests/test_smoke_install.py - run: python -m pytest -q if: steps.docs-check.outputs.docs_only != 'true' diff --git a/launch-windows.ps1 b/launch-windows.ps1 index ab0e3542b2..180a8c0eeb 100644 --- a/launch-windows.ps1 +++ b/launch-windows.ps1 @@ -78,7 +78,7 @@ $pyVersion = $null $pyLauncher = Get-Command py -ErrorAction SilentlyContinue if ($pyLauncher) { - foreach ($v in @("-3.13", "-3.12", "-3.11")) { + foreach ($v in @("-3.14", "-3.13", "-3.12", "-3.11")) { $ver = Get-PythonVersionText $pyLauncher.Source @($v) if ($ver) { $pyExe = $pyLauncher.Source diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index d3d0e36dd6..351f8be351 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -2188,6 +2188,26 @@ async def model_serve(request: Request, req: ServeRequest): # ollama is found (otherwise macOS falls back to a slow source build). # /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux. runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"') + # Issue #6079: a generated shim at ~/bin/llama-server (written + # when only the pip bindings existed) keeps satisfying + # `command -v llama-server` forever, so the native build and + # recovery below never run and every serve stays on the slow + # `python -m llama_cpp.server` path. If a real native binary + # now exists in any common location, relink ~/bin/llama-server + # to it - the shim must never shadow a native install. + runner_lines.append('_ODY_NATIVE=""') + runner_lines.append('for _ody_c in "$HOME/llama.cpp/build/bin/llama-server" "$HOME/llama.cpp/build-cuda/bin/Release/llama-server" "$HOME/llama.cpp/build/bin/Release/llama-server" "$HOME/.local/bin/llama-server" "/usr/local/bin/llama-server" "/opt/homebrew/bin/llama-server" "/usr/bin/llama-server"; do') + runner_lines.append(' if [ -x "$_ody_c" ] && ! grep -q "Auto-generated by Odysseus Cookbook" "$_ody_c" 2>/dev/null; then') + runner_lines.append(' _ODY_NATIVE="$_ody_c"') + runner_lines.append(' break') + runner_lines.append(' fi') + runner_lines.append('done') + runner_lines.append('if [ -n "$_ODY_NATIVE" ]; then') + runner_lines.append(' if [ ! -e "$HOME/bin/llama-server" ] || grep -q "Auto-generated by Odysseus Cookbook" "$HOME/bin/llama-server" 2>/dev/null; then') + runner_lines.append(' ln -sf "$_ODY_NATIVE" "$HOME/bin/llama-server"') + runner_lines.append(' echo "[odysseus] Native llama-server found at $_ODY_NATIVE - relinked ~/bin/llama-server over the generated shim (issue #6079)."') + runner_lines.append(' fi') + runner_lines.append('fi') runner_lines.append('if [ -d /data/data/com.termux ]; then') runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).') runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then') diff --git a/routes/model_routes.py b/routes/model_routes.py index fcf9e16341..7d9bbf54d8 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -604,8 +604,7 @@ def _is_ollama_base(base_url: str) -> bool: "snowflake/arctic-embed", "nvidia/nv-embed", "embed", ) _NON_CHAT_CONTAINS = ( - "-realtime", "-transcribe", "-tts", "-codex", - "codex-", "content-safety", "-safety", "-reward", "nvclip", + "-realtime", "-transcribe", "-tts", "content-safety", "-safety", "-reward", "nvclip", "kosmos", "fuyu", "deplot", "vila", "neva", "gliner", "riva", "-parse", "-embedqa", "-nemoretriever", "topic-control", "calibration", diff --git a/src/service_health.py b/src/service_health.py index 4b24bc9ed9..810d87373f 100644 --- a/src/service_health.py +++ b/src/service_health.py @@ -144,6 +144,45 @@ def _detail_for(category: str) -> str: return _ERROR_DETAIL.get(category, _ERROR_DETAIL["error"]) +# Concrete, copy-paste-able next steps for each controlled failure category +# (ROADMAP: "clear next steps instead of just 'crashed'"). Never includes +# credentials, raw hostnames with secrets, or server-supplied detail. +_NEXT_STEP = { + "timeout": "The service did not respond in time — check that it is running and the host/port in Settings.", + "connection_refused": "Connection refused — check the service is running and the host/port in Settings.", + "dns_error": "Host could not be resolved — check the configured hostname in Settings.", + "tls_error": "TLS handshake failed — check the service's certificate / CA configuration.", + "network_error": "Network error — check that this machine can reach the service (firewall, VPN).", + "http_error": "The service returned an error status — check its own logs.", + "auth_or_protocol_error": "Authentication or protocol failed — check the account credentials and protocol in Settings.", + "no_models": "The endpoint is reachable but returned no models — check its base URL / model path.", + "no_host": "No host is configured — set it in Settings.", + "error": "See /api/diagnostics/logs for the app log, then check the service's own logs.", +} +_DEFAULT_NEXT_STEP = ( + "Check the service state in Settings and its logs; see /api/diagnostics/logs." +) + + +def _next_step_for(service: str = "", category: Optional[str] = None) -> str: + """Return an actionable hint for a failure category (secret-free).""" + return _NEXT_STEP.get(category or "error", _DEFAULT_NEXT_STEP) + + +def _enrich_next_step(service: Dict[str, Any]) -> Dict[str, Any]: + """Add ``meta.next_step`` to degraded/down service entries (idempotent).""" + if service.get("status") not in (DEGRADED, DOWN): + return service + meta = service.setdefault("meta", {}) + if "next_step" in meta: + return service + meta["next_step"] = _next_step_for( + service.get("name", ""), + meta.get("error") if isinstance(meta.get("error"), str) else None, + ) + return service + + def _http_get(url: str, timeout: float = _PROBE_TIMEOUT): """Single network entry point for the HTTP probes (monkeypatched in tests).""" import httpx @@ -322,7 +361,8 @@ def _label(acc: Dict[str, Any]) -> str: def _check(_i: int, acc: Dict[str, Any]) -> Dict[str, Any]: name = _label(acc) if not (acc.get("imap_host") or ""): - return {"name": name, "ok": False, "error": "no_host"} + return {"name": name, "ok": False, "error": "no_host", + "next_step": _next_step_for("email", "no_host")} try: conn = connect(acc.get("account_id")) try: @@ -331,12 +371,15 @@ def _check(_i: int, acc: Dict[str, Any]) -> Dict[str, Any]: pass return {"name": name, "ok": True, "error": None} except Exception as e: - return {"name": name, "ok": False, "error": _classify_error(e)} + category = _classify_error(e) + return {"name": name, "ok": False, "error": category, + "next_step": _next_step_for("email", category)} raw = _bounded_map(accounts, _check, budget=_FANOUT_BUDGET, concurrency=_PROBE_CONCURRENCY) per_account = [r if r is not None - else {"name": _label(accounts[i]), "ok": False, "error": "timeout"} + else {"name": _label(accounts[i]), "ok": False, "error": "timeout", + "next_step": _next_step_for("email", "timeout")} for i, r in enumerate(raw)] return _rollup_items("email", "mailbox(es)", per_account) @@ -367,17 +410,21 @@ def _check(_i: int, ep: Dict[str, Any]) -> Dict[str, Any]: models = probe(ep.get("base_url"), ep.get("api_key"), timeout=_PROBE_TIMEOUT) or [] except Exception as e: + category = _classify_error(e) return {"name": name, "ok": False, "model_count": 0, - "error": _classify_error(e)} + "error": category, "next_step": _next_step_for("providers", category)} count = len(models) + error = None if count else "no_models" return {"name": name, "ok": bool(count), "model_count": count, - "error": None if count else "no_models"} + "error": error, + "next_step": None if count else _next_step_for("providers", error)} raw = _bounded_map(endpoints, _check, budget=_FANOUT_BUDGET, concurrency=_PROBE_CONCURRENCY) per_endpoint = [r if r is not None else {"name": _label(endpoints[i]), "ok": False, - "model_count": 0, "error": "timeout"} + "model_count": 0, "error": "timeout", + "next_step": _next_step_for("providers", "timeout")} for i, r in enumerate(raw)] return _rollup_items("providers", "endpoint(s)", per_endpoint, key="endpoints") @@ -497,6 +544,8 @@ async def collect_service_health(rag_manager: Any = None, for n in names] services = [chroma, *results] + # Attach actionable next-step hints to degraded/down entries (idempotent). + services = [_enrich_next_step(s) for s in services] return { "overall": _rollup(services), "services": services, diff --git a/src/tool_parsing.py b/src/tool_parsing.py index b13f3b0a17..2811d9b7f6 100644 --- a/src/tool_parsing.py +++ b/src/tool_parsing.py @@ -1375,13 +1375,34 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]: # XML-like text inside JSON argument values stays data instead of # selecting a different tool. json_body_seen = False + # Wrapper spans consumed below; used to mask wrapper bodies out of the + # bare-invoke fallback so markup inside a malformed JSON payload stays + # data (issue #5333) while later real calls are recovered (#6014). + wrapper_spans: list[tuple[int, int]] = [] + skip_before = -1 for _ms, inner_start, inner_end, _me in _iter_delimited( text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE ): + wrapper_spans.append((_ms, _me)) + if inner_start < skip_before: + # Already consumed by a string-aware (extended) parse below. + continue body = text[inner_start:inner_end] if _looks_like_json_body(body): json_body_seen = True block = _parse_json_tool_call_body(body) + if not block: + # Issue #6013: a closer token inside a JSON string value + # ends the delimiter span early, so the body fails to + # decode. Retry with each later closer in turn; the first + # one whose body decodes is the real wrapper end. + for close_m in _XML_TOOL_CALL_CLOSE_RE.finditer(text, _me): + block = _parse_json_tool_call_body( + text[inner_start:close_m.start()] + ) + if block: + skip_before = close_m.end() + break if block: blocks.append(block) continue @@ -1398,6 +1419,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]: # complete inner tool tag, but forget the closing . if not blocks: for m in _XML_OPEN_TOOL_CALL_RE.finditer(text): + # The opener-to-EOS match also fires when the wrapper WAS + # closed (group(1) then swallows the closer and everything + # after). Trust it only where the closed-span scan found + # nothing, or it would mask later valid calls (#6014). + if any(ws <= m.start() < we for ws, we in wrapper_spans): + continue + wrapper_spans.append((m.start(), m.end())) body = m.group(1) if _looks_like_json_body(body): # Same fail-closed rule as above for an unclosed wrapper. @@ -1417,11 +1445,24 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]: if block: blocks.append(block) # Try bare without wrapper. Skipped when a JSON wrapper body - # was seen but produced no block: this rescan covers the full text, - # wrapper bodies included, and markup inside a (possibly - # malformed) JSON payload must stay data rather than dispatch. - if not blocks and not json_body_seen: - for inv_name, inv_body in _iter_xml_invoke(text): + # produced a valid block (blocks non-empty) — this rescan covers the + # full text and markup inside a JSON payload must stay data (#5333). + # Issue #6014: when JSON wrapper bodies were seen but all malformed, + # scan with every wrapper span masked out so a later valid bare call + # is recovered while markup inside the malformed payload stays data. + scan_text = None + if not blocks: + if not json_body_seen: + scan_text = text + elif wrapper_spans: + chars = list(text) + for ws, we in wrapper_spans: + for i in range(max(0, ws), min(we, len(chars))): + if chars[i] != "\n": + chars[i] = " " + scan_text = "".join(chars) + if scan_text is not None: + for inv_name, inv_body in _iter_xml_invoke(scan_text): block = _parse_xml_invoke(inv_name, inv_body) if block: blocks.append(block) diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 7585f3e9d7..f71977828a 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -1414,9 +1414,20 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock # Convert structured args back to the text format each tool expects if tool_type == "bash": - content = args.get("command", "") + payload = args.get("command", "") + # Issue #6012: a Qwen/Hermes JSON wrapper with a non-string command + # (list/object/number) must fail closed — coercing it produced a + # ToolBlock whose content isn't a str and crashed or mangled execution. + if payload is not None and not isinstance(payload, str): + logger.warning(f"Rejecting non-string command payload for function call {name}: {payload!r}") + return None + content = payload or "" elif tool_type == "python": - content = args.get("code", "") + payload = args.get("code", "") + if payload is not None and not isinstance(payload, str): + logger.warning(f"Rejecting non-string code payload for function call {name}: {payload!r}") + return None + content = payload or "" elif tool_type == "web_search": queries = args.get("queries") if isinstance(queries, list) and queries: diff --git a/tests/test_cookbook_stale_shim_recovery.py b/tests/test_cookbook_stale_shim_recovery.py new file mode 100644 index 0000000000..370dff33eb --- /dev/null +++ b/tests/test_cookbook_stale_shim_recovery.py @@ -0,0 +1,130 @@ +"""Issue #6079: a stale generated llama-server shim must not block native recovery. + +Cookbook writes a `llama-server` shim into ~/bin when only the pip bindings +exist. That shim keeps satisfying `command -v llama-server` forever, so the +native build/recovery branch is skipped and every serve stays on the slow +`python -m llama_cpp.server` path even after a real native binary appears. + +The runner now relinks ~/bin/llama-server to a real native binary when one +exists. These tests extract the SHIPPED shell lines from the route source and +execute them against a temporary HOME, so the test cannot drift from what the +runner actually emits. +""" +import ast +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +ROUTES = REPO / "routes" / "cookbook_routes.py" +SHIM_MARKER = "Auto-generated by Odysseus Cookbook" + +pytestmark = pytest.mark.skipif( + shutil.which("bash") is None, reason="bash is required to exercise the runner lines" +) + + +def _shipped_shell_lines() -> list[str]: + """Pull the #6079 block's shell text out of the shipped source.""" + src = ROUTES.read_text(encoding="utf-8") + start = src.index("# Issue #6079") + region = src[start:] + lines: list[str] = [] + for m in re.finditer(r"runner_lines\.append\((.*)\)[ \t]*$", region, re.M): + literal = ast.literal_eval(m.group(1)) + lines.append(literal) + if literal == "fi": + break + assert lines, "no runner_lines found after the #6079 marker" + assert lines[-1] == "fi", f"extraction stopped early: {lines[-1]!r}" + return lines + + +def _run(home: Path) -> subprocess.CompletedProcess: + env = {**os.environ, "HOME": str(home)} + script = "\n".join(_shipped_shell_lines()) + return subprocess.run(["bash", "-c", script], env=env, + capture_output=True, text=True, check=False) + + +def _write(path: Path, content: str, executable: bool = True) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + if executable: + path.chmod(0o755) + + +def _shim_body() -> str: + return f"#!/usr/bin/env bash\n# {SHIM_MARKER}: a llama-server lookalike\nexec python3 -m llama_cpp.server\n" + + +def test_stale_shim_is_replaced_by_native_binary(tmp_path): + """The core #6079 case: shim in ~/bin, real binary in ~/.local/bin.""" + _write(tmp_path / "bin" / "llama-server", _shim_body()) + native = tmp_path / ".local" / "bin" / "llama-server" + _write(native, "#!/bin/sh\necho native\n") + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + target = tmp_path / "bin" / "llama-server" + assert target.is_symlink(), "stale shim was not relinked to the native binary" + assert target.resolve() == native.resolve() + assert "relinked" in result.stdout + + +def test_shim_left_alone_when_no_native_binary_exists(tmp_path): + """Fallback stays intact: nothing native -> the shim keeps working.""" + shim = tmp_path / "bin" / "llama-server" + _write(shim, _shim_body()) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + assert not shim.is_symlink() + assert SHIM_MARKER in shim.read_text(encoding="utf-8") + assert "relinked" not in result.stdout + + +def test_existing_native_link_is_not_relinked_again(tmp_path): + """Idempotent: a ~/bin entry already pointing at native is left alone.""" + native = tmp_path / ".local" / "bin" / "llama-server" + _write(native, "#!/bin/sh\necho native\n") + target = tmp_path / "bin" / "llama-server" + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(native) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + assert target.resolve() == native.resolve() + assert "relinked" not in result.stdout + + +def test_another_shim_is_not_treated_as_native(tmp_path): + """A shim copy elsewhere in the search path must not count as native.""" + _write(tmp_path / "bin" / "llama-server", _shim_body()) + _write(tmp_path / ".local" / "bin" / "llama-server", _shim_body()) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + assert not (tmp_path / "bin" / "llama-server").is_symlink() + assert "relinked" not in result.stdout + + +def test_native_build_output_wins_over_other_candidates(tmp_path): + """Precedence: a source build under ~/llama.cpp/build/bin is preferred.""" + build = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server" + _write(build, "#!/bin/sh\necho built\n") + _write(tmp_path / ".local" / "bin" / "llama-server", "#!/bin/sh\necho local\n") + _write(tmp_path / "bin" / "llama-server", _shim_body()) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "bin" / "llama-server").resolve() == build.resolve() \ No newline at end of file diff --git a/tests/test_model_routes.py b/tests/test_model_routes.py index d5a5b0fdea..c0dff16a70 100644 --- a/tests/test_model_routes.py +++ b/tests/test_model_routes.py @@ -427,6 +427,21 @@ def test_gpt_audio_is_not_chat(self): def test_legacy_openai_instruct_is_not_chat(self): assert _is_chat_model("gpt-3.5-turbo-instruct") is False + @pytest.mark.parametrize("model_id", [ + "codex-reliable-coding", + "codex-auto-review", + "codex/codex-auto-review", + "codex/gpt-5.3-codex-spark", + "gpt-5.3-codex-spark", + "oc/gpt-5.2-codex", + "opencode/gpt-5.3-codex", + ]) + def test_codex_named_models_are_chat(self, model_id): + # Issue #6218: a model ID containing "codex" must not be filtered on + # name alone — OmniRoute combos like codex-reliable-coding vanished + # from discovery while an identical rename appeared immediately. + assert _is_chat_model(model_id) is True + @pytest.mark.parametrize("bad", [None, 123, 4.5, ["x"], {"a": 1}]) def test_non_string_id_is_treated_as_chat(self, bad): # Defensive boundary: a non-compliant upstream can yield a non-string diff --git a/tests/test_provider_probe_matrix.py b/tests/test_provider_probe_matrix.py new file mode 100644 index 0000000000..141dc6ce98 --- /dev/null +++ b/tests/test_provider_probe_matrix.py @@ -0,0 +1,57 @@ +"""Provider-probe contract matrix guard (Phase 5). + +ROADMAP asks for a provider setup/probing audit for Anthropic, Gemini, Groq, +xAI, OpenRouter, OpenAI, and DeepSeek. This test pins the contract so a new +provider or a refactor cannot silently drop one of the seven from either the +setup surface or the model-endpoint probe path without a failing test. + +Follows the repo's source-level regression pattern (see +tests/test_upload_error_surfaced.py and tests/test_chat_route_tool_policy.py). +""" +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SLASH_COMMANDS = REPO / "static/js/slashCommands.js" +MODEL_ROUTES = REPO / "routes/model_routes.py" + +# The seven providers named in ROADMAP.md's "Provider setup/probing audit". +ROADMAP_PROVIDERS = { + "anthropic", "gemini", "groq", "xai", "openrouter", "openai", "deepseek", +} + + +def test_setup_surface_covers_all_roadmap_providers(): + """/setup UI must recognize every ROADMAP provider by its slug.""" + text = SLASH_COMMANDS.read_text(encoding="utf-8") + m = re.search(r"SETUP_PROVIDER_NAMES\s*=\s*\[([^\]]*)\]", text) + assert m, "SETUP_PROVIDER_NAMES array not found in slashCommands.js" + slugs = {s.strip("'\", ") for s in m.group(1).split(",") if s.strip()} + missing = ROADMAP_PROVIDERS - slugs + assert not missing, f"providers missing from /setup surface: {sorted(missing)}" + + +def test_probe_route_special_cases_anthropic_and_google(): + """_probe_endpoint must keep its two non-OpenAI-format branches.""" + text = MODEL_ROUTES.read_text(encoding="utf-8") + assert "_is_google_api_base(base)" in text + assert 'provider == "anthropic"' in text + assert "ANTHROPIC_MODELS" in text + + +def test_probe_route_handles_openai_compatible_matrix(): + """OpenAI/Groq/xAI/OpenRouter/DeepSeek speak OpenAI-format /models. + + The generic probe path must keep OpenAI-format parsing plus the Ollama + fallback; dropping either would break discovery for those providers. + """ + text = MODEL_ROUTES.read_text(encoding="utf-8") + assert "_openai_model_ids(data)" in text + assert "_ollama_model_names(data)" in text + + +def test_provider_curated_lookup_exists(): + """Curated model lists are keyed by provider slug (incl. all seven).""" + text = MODEL_ROUTES.read_text(encoding="utf-8") + assert "_PROVIDER_CURATED" in text + assert "_match_provider_curated" in text \ No newline at end of file diff --git a/tests/test_searxng_settings_migration.py b/tests/test_searxng_settings_migration.py index 75cef7775e..0f4dc92204 100644 --- a/tests/test_searxng_settings_migration.py +++ b/tests/test_searxng_settings_migration.py @@ -245,6 +245,10 @@ def test_invalid_utf8_is_not_replaced(tmp_path): assert after.st_ino == before.st_ino +@pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX ownership (os.fchown/os.O_DIRECTORY) only exists in the Linux searxng container.", +) def test_temporary_file_is_chmodded_before_it_is_chowned(tmp_path, monkeypatch): # The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/ # DAC_OVERRIDE and carries no FOWNER, and searxng's entrypoint chowns diff --git a/tests/test_service_health_next_steps.py b/tests/test_service_health_next_steps.py new file mode 100644 index 0000000000..5bb9af66a4 --- /dev/null +++ b/tests/test_service_health_next_steps.py @@ -0,0 +1,103 @@ +"""Tests for actionable next-step hints on degraded/down service entries. + +ROADMAP: "clear next steps instead of just 'crashed'". Each degraded/down +entry should carry ``meta.next_step`` with a concrete, secret-free action; +ok/disabled entries must NOT carry one. +""" +import types + +from src import service_health as sh + + +def _raise(*_a, **_k): + raise RuntimeError("connection refused") + + +def _ep(name): + return {"name": name, "base_url": f"http://{name}:8000/v1", "api_key": "sk-secret"} + + +def test_searxng_down_gets_next_step(): + s = sh._enrich_next_step(sh.searxng_health( + {"search_provider": "searxng", "search_url": "http://sx:8080"}, + http_get=_raise, + )) + assert s["status"] == sh.DOWN + assert s["meta"]["next_step"] + + +def test_searxng_ok_has_no_next_step(): + s = sh.searxng_health( + {"search_provider": "searxng", "search_url": "http://sx:8080"}, + http_get=lambda url, timeout: types.SimpleNamespace(status_code=200), + ) + assert s["status"] == sh.OK + assert "next_step" not in s["meta"] + + +def test_searxng_disabled_has_no_next_step(): + s = sh.searxng_health({"search_provider": "brave"}) + assert s["status"] == sh.DISABLED + assert "next_step" not in s["meta"] + + +def test_provider_endpoint_down_item_has_next_step(): + s = sh.providers_health([_ep("a")], probe=_raise) + item = s["meta"]["endpoints"][0] + assert item["ok"] is False + assert item["error"] == "error" + assert item["next_step"] + + +def test_provider_endpoint_no_models_has_next_step(): + s = sh.providers_health([_ep("a")], probe=lambda base, key, timeout: []) + item = s["meta"]["endpoints"][0] + assert item["ok"] is False + assert item["error"] == "no_models" + assert item["next_step"] + + +def test_email_no_host_item_has_next_step(): + s = sh.email_health([{"account_name": "a", "imap_host": ""}]) + item = s["meta"]["accounts"][0] + assert item["ok"] is False + assert item["error"] == "no_host" + assert item["next_step"] + + +def test_email_timeout_item_has_next_step(monkeypatch): + import time + monkeypatch.setattr(sh, "_FANOUT_BUDGET", 1) + + def slow(_aid): + time.sleep(10) + s = sh.email_health([{"account_name": "slow", "imap_host": "imap.x"}], + connect=slow) + item = s["meta"]["accounts"][0] + assert item["ok"] is False and item["error"] == "timeout" + assert item["next_step"] + + +def test_collect_enriches_down_services(monkeypatch): + import asyncio + + monkeypatch.setattr(sh, "_gather_inputs", lambda: { + "settings": {}, "integrations": [], "accounts": [], "endpoints": [], + }) + monkeypatch.setattr(sh, "searxng_health", lambda *a, **k: sh._svc( + "searxng", sh.DOWN, "down", error="connection_refused")) + monkeypatch.setattr(sh, "ntfy_health", lambda *a, **k: sh._svc( + "ntfy", sh.OK, "ok")) + out = asyncio.run(sh.collect_service_health(None, None)) + by = {s["name"]: s for s in out["services"]} + assert by["searxng"]["meta"]["next_step"] + assert by["ntfy"]["meta"].get("next_step") is None + + +def test_enrich_is_idempotent_and_secret_free(): + s = {"name": "providers", "status": sh.DOWN, "detail": "", + "meta": {"error": "connection_refused"}} + first = sh._enrich_next_step(s)["meta"]["next_step"] + second = sh._enrich_next_step(s)["meta"]["next_step"] + assert first == second + assert "user:" not in first and "http://" not in first \ No newline at end of file diff --git a/tests/test_smoke_install.py b/tests/test_smoke_install.py new file mode 100644 index 0000000000..bcbfb4dfc5 --- /dev/null +++ b/tests/test_smoke_install.py @@ -0,0 +1,76 @@ +"""Fresh-install smoke tests (Phase 1): the floor must be green. + +Covers the cheapest release-confidence gate: +1. Python version requirement (3.11+). +2. Core dependency imports (fastapi, uvicorn, sqlalchemy, bcrypt, httpx, dotenv). +3. .env.example parses as KEY=VALUE lines (setup.py copies it verbatim). +4. internal_api_base() honors APP_PORT (never hardcode :7000). +5. Full app boots via TestClient and /api/health is healthy even with + optional services (Chroma/SearXNG/Ollama) unreachable. + +Pattern followed: tests/test_internal_api_base.py (env-scoped base helper + +no-hardcoded-loopback guard) and setup.py::check_deps (same module list). +""" +import pathlib +import sys + +import pytest + +REPO = pathlib.Path(__file__).resolve().parent.parent +CORE_DEPS = ["fastapi", "uvicorn", "sqlalchemy", "bcrypt", "httpx", "dotenv"] + + +def test_python_is_311_plus(): + assert sys.version_info >= (3, 11), f"need 3.11+, got {sys.version}" + + +@pytest.mark.parametrize("mod", CORE_DEPS) +def test_core_dependency_imports(mod): + __import__(mod) + + +def test_env_example_parses(): + example = REPO / ".env.example" + assert example.exists(), ".env.example must exist (setup.py copies it)" + bad = [] + for n, line in enumerate(example.read_text(encoding="utf-8").splitlines(), 1): + s = line.strip() + if not s or s.startswith("#"): + continue + if "=" not in s: + bad.append((n, line)) + assert not bad, f".env.example has non KEY=VALUE lines: {bad[:5]}" + + +def test_internal_api_base_honors_app_port(monkeypatch): + import core.constants as cc + + for k in ("ODYSSEUS_INTERNAL_BASE", "APP_PORT"): + monkeypatch.delenv(k, raising=False) + assert cc.internal_api_base() == "http://127.0.0.1:7000" + monkeypatch.setenv("APP_PORT", "7001") + assert cc.internal_api_base() == "http://127.0.0.1:7001" + + +def test_app_boots_and_health_is_healthy(monkeypatch): + """Full app boots; health is healthy with optional services unreachable.""" + import os + + # Point every optional-service probe at dead ports so the test proves + # graceful degradation instead of depending on the dev machine. + monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:") + monkeypatch.setenv("CHROMADB_HOST", "127.0.0.1") + monkeypatch.setenv("CHROMADB_PORT", "9") # discard port: nothing listens + monkeypatch.setenv("SEARXNG_INSTANCE", "http://127.0.0.1:9") + monkeypatch.setenv("OLLAMA_BASE_URL", "http://127.0.0.1:9/v1") + # Keep the embedding stack local-only (no HF download in CI). + monkeypatch.setenv("EMBEDDING_URL", "") + os.environ.pop("EMBEDDING_MODEL", None) + + from fastapi.testclient import TestClient + from app import app + + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/api/health") + assert resp.status_code == 200, resp.text[:500] + assert resp.json().get("status") == "healthy" diff --git a/tests/test_tool_parsing_wrapper_recovery.py b/tests/test_tool_parsing_wrapper_recovery.py new file mode 100644 index 0000000000..d283bc463c --- /dev/null +++ b/tests/test_tool_parsing_wrapper_recovery.py @@ -0,0 +1,72 @@ +"""Wrapper-recovery regressions for Qwen/Hermes text-mode tool calls. + +Issues: #6014 (a malformed wrapper suppressed later valid bare calls), +#6013 (a closer token inside a JSON string value ended the wrapper span +early), #6012 (non-string command/code payloads were coerced instead of +rejected). Wrapper markers are built via concatenation so this file never +embeds a raw wrapper sequence that scanners could trip over. +""" +import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle) +from src.tool_parsing import parse_tool_blocks +from src.tool_schemas import function_call_to_tool_block + +OPEN = "<" + "tool_call>" +CLOSE = "" + + +def test_6014_malformed_wrapper_then_bare_invoke_parses(): + text = ( + OPEN + '{"name": "write_file", "arguments": {broken json' + CLOSE + "\n" + "Now run this:\n" + 'echo hi' + ) + blocks = parse_tool_blocks(text) + assert [(b.tool_type, b.content) for b in blocks] == [("bash", "echo hi")] + + +def test_6013_closer_inside_json_string_value(): + payload = ( + '{"name": "write_file", "arguments": ' + '{"path": "n.txt", "content": "hello ' + CLOSE + ' world"}}' + ) + text = OPEN + payload + CLOSE + blocks = parse_tool_blocks(text) + assert len(blocks) == 1 + assert blocks[0].tool_type == "write_file" + assert ("hello " + CLOSE + " world") in blocks[0].content + + +def test_6012_nonstring_command_rejected(): + for bad in ('["ls", "-la"]', '{"cmd": "ls"}', "1"): + text = OPEN + '{"name": "bash", "arguments": {"command": ' + bad + "}}" + CLOSE + assert parse_tool_blocks(text) == [], bad + assert function_call_to_tool_block("bash", '{"command": ["ls"]}') is None + + +def test_6012_nonstring_python_code_rejected(): + text = OPEN + '{"name": "python", "arguments": {"code": [1, 2]}}' + CLOSE + assert parse_tool_blocks(text) == [] + assert function_call_to_tool_block("python", '{"code": {"x": 1}}') is None + + +def test_5333_markup_inside_malformed_json_stays_data(): + text = ( + OPEN + '{"name": "write_file", "arguments": {broken ' + 'echo unsafe' + ) + assert parse_tool_blocks(text) == [] + + +def test_valid_json_wrapper_still_parses(): + text = OPEN + '{"name": "bash", "arguments": {"command": "ls"}}' + CLOSE + blocks = parse_tool_blocks(text) + assert [(b.tool_type, b.content) for b in blocks] == [("bash", "ls")] + + +def test_two_wrappers_with_malformed_first_recover(): + text = ( + OPEN + '{"name": "bash", "arguments": {broken' + CLOSE + "\n" + + OPEN + '{"name": "bash", "arguments": {"command": "pwd"}}' + CLOSE + ) + blocks = parse_tool_blocks(text) + assert [(b.tool_type, b.content) for b in blocks] == [("bash", "pwd")] \ No newline at end of file diff --git a/tests/test_upload_error_surfaced.py b/tests/test_upload_error_surfaced.py index 4e5be7763a..aa97fbeed3 100644 --- a/tests/test_upload_error_surfaced.py +++ b/tests/test_upload_error_surfaced.py @@ -29,3 +29,7 @@ def test_upload_pending_checks_response_and_surfaces_error(): assert re.search(r"if\s*\(\s*!res\.ok\s*\)", body), "uploadPending must check res.ok" # ...and tell the user the upload failed (not swallow it). assert "Upload failed" in body + # Issue #6235: the failure toast must carry the server's real reason + # (e.g. "Chat attachment exceeds 10 MB limit"), not a generic message. + assert re.search(r"e\.detail\s*\|\|\s*e\.error", body), ( + "uploadPending must surface the server detail from the error body")