diff --git a/tests/tui_gateway/test_config_profile_scope.py b/tests/tui_gateway/test_config_profile_scope.py new file mode 100644 index 0000000000..d7d1a8e02e --- /dev/null +++ b/tests/tui_gateway/test_config_profile_scope.py @@ -0,0 +1,113 @@ +"""config.get / config.set must honor params.profile (issue #95760). + +A single dashboard backend in desktop app-global remote mode serves every +profile. projects.* RPCs already bind params.profile via @_profile_scoped; +config.get / config.set did not, so reads and persistent writes used the +launch profile's config.yaml for every focused profile. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +import tui_gateway.server as server + + +LAUNCH_CWD = "/workspace/default" +WORKER_CWD = "/workspace/code" +LAUNCH_ROOTS = ["/workspace/default"] +WORKER_ROOTS = ["/workspace/code"] + + +def _write_cfg(home: Path, cwd: str, roots: list[str]) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + yaml.safe_dump( + { + "terminal": {"cwd": cwd}, + "desktop": {"repo_scan_roots": list(roots)}, + "display": {"busy_input_mode": "queue"}, + } + ), + encoding="utf-8", + ) + + +def _homes(tmp_path: Path) -> tuple[Path, Path]: + launch = tmp_path / "launch" + worker = tmp_path / "profiles" / "code" + _write_cfg(launch, LAUNCH_CWD, LAUNCH_ROOTS) + _write_cfg(worker, WORKER_CWD, WORKER_ROOTS) + return launch, worker + + +def _reset_cfg_cache() -> None: + server._cfg_cache = None + server._cfg_mtime = None + server._cfg_path = None + + +def _bind_homes(monkeypatch, launch: Path, worker: Path) -> None: + monkeypatch.setattr(server, "_hermes_home", launch) + monkeypatch.setattr( + server, + "_profile_home", + lambda name: worker if (name or "").strip() == "code" else None, + ) + _reset_cfg_cache() + + +def _get(params: dict) -> dict: + return server._methods["config.get"]("rid-get", params) + + +def _set(params: dict) -> dict: + return server._methods["config.set"]("rid-set", params) + + +def _read_yaml(home: Path) -> dict: + return yaml.safe_load((home / "config.yaml").read_text(encoding="utf-8")) or {} + + +def test_config_get_full_reads_params_profile_yaml_not_launch(tmp_path, monkeypatch): + launch, worker = _homes(tmp_path) + _bind_homes(monkeypatch, launch, worker) + + launch_resp = _get({"key": "full"}) + assert launch_resp["result"]["config"]["terminal"]["cwd"] == LAUNCH_CWD + assert launch_resp["result"]["config"]["desktop"]["repo_scan_roots"] == LAUNCH_ROOTS + + _reset_cfg_cache() + worker_resp = _get({"key": "full", "profile": "code"}) + assert worker_resp["result"]["config"]["terminal"]["cwd"] == WORKER_CWD + assert worker_resp["result"]["config"]["desktop"]["repo_scan_roots"] == WORKER_ROOTS + + # Launch file must be untouched by the focused-profile read. + assert _read_yaml(launch)["terminal"]["cwd"] == LAUNCH_CWD + + +def test_config_set_persistent_write_lands_on_params_profile_yaml(tmp_path, monkeypatch): + launch, worker = _homes(tmp_path) + _bind_homes(monkeypatch, launch, worker) + + resp = _set({"key": "busy", "value": "steer", "profile": "code"}) + assert resp["result"]["value"] == "steer" + + worker_cfg = _read_yaml(worker) + launch_cfg = _read_yaml(launch) + assert worker_cfg["display"]["busy_input_mode"] == "steer" + assert launch_cfg["display"]["busy_input_mode"] == "queue" + assert launch_cfg["terminal"]["cwd"] == LAUNCH_CWD + assert worker_cfg["terminal"]["cwd"] == WORKER_CWD + + +def test_config_set_without_profile_still_writes_launch_home(tmp_path, monkeypatch): + launch, worker = _homes(tmp_path) + _bind_homes(monkeypatch, launch, worker) + + resp = _set({"key": "busy", "value": "interrupt"}) + assert resp["result"]["value"] == "interrupt" + assert _read_yaml(launch)["display"]["busy_input_mode"] == "interrupt" + assert _read_yaml(worker)["display"]["busy_input_mode"] == "queue" diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 22be8c454b..916abfc182 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -179,6 +179,7 @@ def _(rid, params: dict) -> dict: @method("config.get") +@_profile_scoped def _(rid, params: dict) -> dict: key = params.get("key", "") if key == "provider": diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 46be96ee92..c36eebd7df 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4430,7 +4430,9 @@ def _save_cfg(cfg: dict): from utils import atomic_roundtrip_yaml_save - path = _hermes_home / "config.yaml" + override = get_hermes_home_override() + home = Path(override) if isinstance(override, str) and override else _hermes_home + path = Path(home) / "config.yaml" # Comment-, ordering-, and Unicode-preserving full-state write. # Replaces the previous `yaml.safe_dump(cfg, f)` (and later # `atomic_config_write`, which is not comment-preserving) which clobbered @@ -13919,6 +13921,7 @@ def _respond(rid, params, key, *, allow_expired=False): # opt/model-resolution-core PR touches its body; move it to methods_config.py # in a follow-up once that PR lands. @method("config.set") +@_profile_scoped def _(rid, params: dict) -> dict: key, value = params.get("key", ""), params.get("value", "") session = _sessions.get(params.get("session_id", ""))