diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7c9667e61c6..2c0a6be4e7b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -188,13 +188,54 @@ def test_completion_cwd_prefers_profile_over_stale_env(monkeypatch, tmp_path): stale.mkdir() monkeypatch.setenv("TERMINAL_CWD", str(stale)) + monkeypatch.setattr(server, "_load_cfg", lambda: {}) monkeypatch.setattr(server, "_profile_home", lambda name: home if name else None) assert server._completion_cwd({"profile": "ef-design"}) == str(profile_b) - # No profile → unchanged fallback to the launch env var. + # No profile and no launch config → fallback to the launch env var. assert server._completion_cwd({}) == str(stale) +def test_completion_cwd_prefers_launch_config_over_stale_env(monkeypatch, tmp_path): + """Dashboard /chat's launch-profile in-memory gateway must honor config. + + The embedded Node TUI child gets TERMINAL_CWD from the dashboard PTY bridge, + but the default-profile chat attaches to the dashboard process's already + running in-memory gateway. That process may not have TERMINAL_CWD in its own + environment (or has a stale one), so config.yaml is read directly and wins + over the process env before falling back to the launch directory. + """ + configured = tmp_path / "omni" + configured.mkdir() + stale = tmp_path / "hermes-agent" + stale.mkdir() + + monkeypatch.setenv("TERMINAL_CWD", str(stale)) + monkeypatch.setattr(server, "_load_cfg", lambda: {"terminal": {"cwd": str(configured)}}) + monkeypatch.setattr(server, "_profile_home", lambda _name: None) + + assert server._completion_cwd({}) == str(configured) + + +def test_default_session_cwd_prefers_launch_config(monkeypatch, tmp_path): + """A freshly created / resumed session with no explicit cwd lands in the + configured terminal.cwd, not os.getcwd(), even when the in-memory gateway + process env carries a stale TERMINAL_CWD.""" + configured = tmp_path / "workspace" + configured.mkdir() + stale = tmp_path / "launch-dir" + stale.mkdir() + + monkeypatch.setenv("TERMINAL_CWD", str(stale)) + monkeypatch.setattr(server, "_load_cfg", lambda: {"terminal": {"cwd": str(configured)}}) + + assert server._default_session_cwd() == str(configured) + + # No launch config → fall back to the process env var. + monkeypatch.setattr(server, "_load_cfg", lambda: {}) + assert server._default_session_cwd() == str(stale) + + def test_completion_cwd_explicit_cwd_wins_over_profile(monkeypatch, tmp_path): """An explicit client-provided cwd still beats the profile config.""" explicit = tmp_path / "explicit" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2721783d84e..7293aa70ff5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -955,6 +955,24 @@ def _profile_scoped(handler): _CWD_PLACEHOLDERS = {".", "auto", "cwd"} +def _configured_cwd_from_cfg(cfg: dict | None) -> str | None: + """Return an absolute, existing ``terminal.cwd`` from a config mapping. + + Returns None for placeholders (``.``/``auto``/``cwd``), missing values, or + paths that don't resolve to a real directory. + """ + if not isinstance(cfg, dict): + return None + terminal_cfg = cfg.get("terminal") + if not isinstance(terminal_cfg, dict): + return None + raw = str(terminal_cfg.get("cwd") or "").strip() + if not raw or raw in _CWD_PLACEHOLDERS: + return None + resolved = os.path.abspath(os.path.expanduser(raw)) + return resolved if os.path.isdir(resolved) else None + + def _profile_configured_cwd(profile_home: Path | None) -> str | None: """Resolve a non-launch profile's ``terminal.cwd`` from its own config.yaml. @@ -974,15 +992,39 @@ def _profile_configured_cwd(profile_home: Path | None) -> str | None: return None with open(p, encoding="utf-8") as f: data = yaml.safe_load(f) or {} - raw = str((data.get("terminal") or {}).get("cwd") or "").strip() - if not raw or raw in _CWD_PLACEHOLDERS: - return None - resolved = os.path.abspath(os.path.expanduser(raw)) - return resolved if os.path.isdir(resolved) else None + return _configured_cwd_from_cfg(data) except Exception: return None +def _launch_configured_cwd() -> str | None: + """Resolve the launch profile's ``terminal.cwd`` from config.yaml. + + Dashboard ``/chat`` for the launch profile attaches to the dashboard + process's in-memory TUI gateway. The Node PTY child receives a bridged + ``TERMINAL_CWD`` env var, but this in-memory process does not — so reading + the process env alone leaves a fresh chat starting in ``os.getcwd()`` + (wherever ``hermes dashboard`` was launched) instead of the configured + ``terminal.cwd``. Read config directly so changing ``terminal.cwd`` affects + new in-memory TUI sessions too. + """ + try: + return _configured_cwd_from_cfg(_load_cfg()) + except Exception: + return None + + +def _default_session_cwd() -> str: + """Fallback cwd for a session with no explicit / stored / profile cwd. + + Mirrors the launch-config-aware tail of :func:`_completion_cwd` so freshly + created AND resumed sessions land in the configured ``terminal.cwd`` rather + than ``os.getcwd()`` when the in-memory gateway's process env has no bridged + ``TERMINAL_CWD``. + """ + return _launch_configured_cwd() or os.getenv("TERMINAL_CWD") or os.getcwd() + + def write_json(obj: dict) -> bool: """Emit one JSON frame. Routes via the most-specific transport available. @@ -1373,6 +1415,11 @@ def _completion_cwd(params: dict | None = None) -> str: # A session bound to another profile resolves its workspace from THAT # profile's config before falling back to the launch profile's env var. or _profile_configured_cwd(_profile_home(params.get("profile"))) + # The launch profile's dashboard /chat attaches to the dashboard's + # in-memory gateway, which does NOT inherit the PTY child's bridged + # TERMINAL_CWD. Read the launch profile's config.yaml directly so a + # configured terminal.cwd wins over a stale process env / launch dir. + or _launch_configured_cwd() or os.environ.get("TERMINAL_CWD") or os.getcwd() ) @@ -5401,7 +5448,7 @@ def _(rid, params: dict) -> dict: if lease is not None: lease.release() return _err(rid, 5000, f"resume failed: {e}") - cwd = profile_resume_cwd or os.getenv("TERMINAL_CWD", os.getcwd()) + cwd = profile_resume_cwd or _default_session_cwd() record = _deferred_session_record( target, cols=cols, @@ -5474,7 +5521,7 @@ def _(rid, params: dict) -> dict: # the build drops the provider ("No LLM provider configured"). overrides = _stored_session_runtime_overrides(found) or {} model_override = overrides.get("model_override") or {} - cwd = profile_resume_cwd or os.getenv("TERMINAL_CWD", os.getcwd()) + cwd = profile_resume_cwd or _default_session_cwd() record = _deferred_session_record( target, cols=cols, @@ -5760,7 +5807,7 @@ def _fallback_session_info(session: dict) -> dict: if agent is not None: return _session_info(agent) return { - "cwd": os.getenv("TERMINAL_CWD", os.getcwd()), + "cwd": _default_session_cwd(), "lazy": True, "model": _resolve_model(), "skills": {},