"""End-to-end persistence of the output_language settings change. Closes the loop the reviewer flagged: a language chosen in the settings web UI must (1) take effect immediately for the reply engine and (2) survive a container recreate. The pieces: bridge._save() -> writes BOTH /data/jarvis-settings.json (persistent) and JARVIS_CONFIG_PATH (live runtime config) entrypoint merge -> on recreate, re-renders config from the env template then merges the persistent overrides back on top engine._resolve_output_language() -> reads JARVIS_CONFIG_PATH, config wins over the OUTPUT_LANGUAGE env This test drives the REAL bridge save function and the REAL engine resolver (the resolver is loaded standalone because the full engine import needs the mcp package, which isn't installed in CI here). It simulates the env default disagreeing with the chosen language, which is exactly the bug condition. """ import ast import json import os from pathlib import Path import pytest # bridge.settings_web imports only stdlib at module load (flask is imported # lazily inside register()), so it is safe to import directly. import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bridge")) import settings_web # noqa: E402 def _load_resolver(): """Load engine._resolve_output_language + _extra_config without importing the heavy jarvis package (which pulls in the optional mcp dependency).""" src = ( Path(__file__).resolve().parents[1] / "src/jarvis/reply/engine.py" ).read_text("utf-8") tree = ast.parse(src) wanted = {"_extra_config", "_resolve_output_language"} mod = ast.Module( body=[ n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in wanted ], type_ignores=[], ) ns = {"os": os, "Optional": __import__("typing").Optional} exec(compile(mod, "engine_subset", "exec"), ns) # noqa: S102 return ns["_resolve_output_language"] def _simulate_recreate_merge(template_lang: str, config_path: Path, persist_path: Path): """Mirror docker/entrypoint.sh: re-render the runtime config from the env template, then merge the persistent overrides on top.""" config_path.write_text(json.dumps({"output_language": template_lang}), "utf-8") if persist_path.exists(): base = json.loads(config_path.read_text("utf-8")) ov = json.loads(persist_path.read_text("utf-8")) base.update(ov) config_path.write_text(json.dumps(base, ensure_ascii=False, indent=2), "utf-8") @pytest.mark.integration def test_settings_save_applies_and_survives_recreate(monkeypatch, tmp_path): config_path = tmp_path / "jarvis.json" persist_path = tmp_path / "data" / "jarvis-settings.json" # The compose env default is the "old" language that must be overridden. monkeypatch.setenv("OUTPUT_LANGUAGE", "English") monkeypatch.setenv("JARVIS_CONFIG_PATH", str(config_path)) monkeypatch.setenv("JARVIS_SETTINGS_PATH", str(persist_path)) # Start from the env-rendered config (as entrypoint would produce). config_path.write_text(json.dumps({"output_language": "English"}), "utf-8") resolve = _load_resolver() # Before the change: the env default wins. assert resolve() == "English" # 1) User saves Korean in the settings UI. settings_web._save({"output_language": "Korean"}) # Both targets are written. assert json.loads(config_path.read_text("utf-8"))["output_language"] == "Korean" assert json.loads(persist_path.read_text("utf-8"))["output_language"] == "Korean" # 2) Applies immediately: the resolver now returns Korean (config > env). assert resolve() == "Korean" # 3) Survives a container recreate: entrypoint re-renders the config from the # env template (still English) then merges the persistent override. _simulate_recreate_merge("English", config_path, persist_path) assert json.loads(config_path.read_text("utf-8"))["output_language"] == "Korean" assert resolve() == "Korean" @pytest.mark.integration def test_persona_and_directive_follow_persisted_language(monkeypatch, tmp_path): """After persistence, the persona and the reply directive both lock to the saved language, not the env default.""" from jarvis.system_prompt import build_system_prompt, reply_language_directive config_path = tmp_path / "jarvis.json" persist_path = tmp_path / "data" / "jarvis-settings.json" monkeypatch.setenv("OUTPUT_LANGUAGE", "English") monkeypatch.setenv("JARVIS_CONFIG_PATH", str(config_path)) monkeypatch.setenv("JARVIS_SETTINGS_PATH", str(persist_path)) config_path.write_text(json.dumps({"output_language": "English"}), "utf-8") settings_web._save({"output_language": "Korean"}) lang = _load_resolver()() persona = build_system_prompt("Jarvis", lang) directive = reply_language_directive(lang, "melo") assert "in Korean" in persona and "in English" not in persona assert directive is not None and "Korean" in directive