diff --git a/tests/tui_gateway/test_make_agent_provider.py b/tests/tui_gateway/test_make_agent_provider.py index bdc7fecf4..9fc78882e 100644 --- a/tests/tui_gateway/test_make_agent_provider.py +++ b/tests/tui_gateway/test_make_agent_provider.py @@ -27,16 +27,22 @@ def test_make_agent_passes_resolved_provider(): "agent": {"system_prompt": "test"}, } - with patch("tui_gateway.server._load_cfg", return_value=fake_cfg), \ - patch("tui_gateway.server._get_db", return_value=MagicMock()), \ - patch("tui_gateway.server._load_tool_progress_mode", return_value="compact"), \ - patch("tui_gateway.server._load_reasoning_config", return_value=None), \ - patch("tui_gateway.server._load_service_tier", return_value=None), \ - patch("tui_gateway.server._load_enabled_toolsets", return_value=None), \ - patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_runtime) as mock_resolve, \ - patch("run_agent.AIAgent") as mock_agent: + with ( + patch("tui_gateway.server._load_cfg", return_value=fake_cfg), + patch("tui_gateway.server._get_db", return_value=MagicMock()), + patch("tui_gateway.server._load_tool_progress_mode", return_value="compact"), + patch("tui_gateway.server._load_reasoning_config", return_value=None), + patch("tui_gateway.server._load_service_tier", return_value=None), + patch("tui_gateway.server._load_enabled_toolsets", return_value=None), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=fake_runtime, + ) as mock_resolve, + patch("run_agent.AIAgent") as mock_agent, + ): from tui_gateway.server import _make_agent + _make_agent("sid-1", "key-1") mock_resolve.assert_called_once_with(requested=None) @@ -46,3 +52,98 @@ def test_make_agent_passes_resolved_provider(): assert call_kwargs.kwargs["base_url"] == "https://api.anthropic.com" assert call_kwargs.kwargs["api_key"] == "sk-test-key" assert call_kwargs.kwargs["api_mode"] == "anthropic_messages" + + +def test_probe_config_health_flags_null_sections(): + """Bare YAML keys (`agent:` with no value) parse as None and silently + drop nested settings; probe must surface them so users can fix.""" + from tui_gateway.server import _probe_config_health + + assert _probe_config_health({"agent": {"x": 1}}) == "" + assert _probe_config_health({}) == "" + + msg = _probe_config_health({"agent": None, "display": None, "model": {}}) + assert "agent" in msg and "display" in msg + assert "model" not in msg + + +def test_probe_config_health_flags_null_personalities_with_active_personality(): + from tui_gateway.server import _probe_config_health + + msg = _probe_config_health( + { + "agent": {"personalities": None}, + "display": {"personality": "kawaii"}, + "model": {}, + } + ) + assert "display.personality" in msg + assert "agent.personalities" in msg + + +def test_make_agent_tolerates_null_config_sections(): + """Bare `agent:` / `display:` keys in ~/.hermes/config.yaml parse as + None. cfg.get("agent", {}) returns None (default only fires on missing + key), so downstream .get() chains must be guarded. Reported via Twitter + against the new TUI.""" + + fake_runtime = { + "provider": "openrouter", + "base_url": "https://api.synthetic.new/v1", + "api_key": "sk-test", + "api_mode": "chat_completions", + "command": None, + "args": None, + "credential_pool": None, + } + null_cfg = {"agent": None, "display": None, "model": {"default": "glm-5"}} + + with ( + patch("tui_gateway.server._load_cfg", return_value=null_cfg), + patch("tui_gateway.server._get_db", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=fake_runtime, + ), + patch("run_agent.AIAgent") as mock_agent, + ): + + from tui_gateway.server import _make_agent + + _make_agent("sid-null", "key-null") + + assert mock_agent.called + + +def test_make_agent_tolerates_null_personalities_with_active_personality(): + fake_runtime = { + "provider": "openrouter", + "base_url": "https://api.synthetic.new/v1", + "api_key": "sk-test", + "api_mode": "chat_completions", + "command": None, + "args": None, + "credential_pool": None, + } + cfg = { + "agent": {"personalities": None}, + "display": {"personality": "kawaii"}, + "model": {"default": "glm-5"}, + } + + with ( + patch("tui_gateway.server._load_cfg", return_value=cfg), + patch("tui_gateway.server._get_db", return_value=MagicMock()), + patch("cli.load_cli_config", return_value={"agent": {"personalities": None}}), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=fake_runtime, + ), + patch("run_agent.AIAgent") as mock_agent, + ): + from tui_gateway.server import _make_agent + + _make_agent("sid-null-personality", "key-null-personality") + + assert mock_agent.called + assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cc2d7b08d..e919c7c98 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -61,7 +61,11 @@ def _panic_hook(exc_type, exc_value, exc_tb): # Stderr goes through to the TUI as a gateway.stderr Activity line — # the first line here is what the user will see without opening any # log files. Rest of the stack is still in the log for full context. - first = str(exc_value).strip().splitlines()[0] if str(exc_value).strip() else exc_type.__name__ + first = ( + str(exc_value).strip().splitlines()[0] + if str(exc_value).strip() + else exc_type.__name__ + ) print(f"[gateway-crash] {exc_type.__name__}: {first}", file=sys.stderr, flush=True) # Chain to the default hook so the process still terminates normally. sys.__excepthook__(exc_type, exc_value, exc_tb) @@ -593,13 +597,17 @@ def _coerce_statusbar(raw) -> str: def _load_reasoning_config() -> dict | None: from hermes_constants import parse_reasoning_effort - effort = str(_load_cfg().get("agent", {}).get("reasoning_effort", "") or "").strip() + effort = str( + (_load_cfg().get("agent") or {}).get("reasoning_effort", "") or "" + ).strip() return parse_reasoning_effort(effort) def _load_service_tier() -> str | None: raw = ( - str(_load_cfg().get("agent", {}).get("service_tier", "") or "").strip().lower() + str((_load_cfg().get("agent") or {}).get("service_tier", "") or "") + .strip() + .lower() ) if not raw or raw in {"normal", "default", "standard", "off", "none"}: return None @@ -609,11 +617,11 @@ def _load_service_tier() -> str | None: def _load_show_reasoning() -> bool: - return bool(_load_cfg().get("display", {}).get("show_reasoning", False)) + return bool((_load_cfg().get("display") or {}).get("show_reasoning", False)) def _load_tool_progress_mode() -> str: - raw = _load_cfg().get("display", {}).get("tool_progress", "all") + raw = (_load_cfg().get("display") or {}).get("tool_progress", "all") if raw is False: return "off" if raw is True: @@ -816,6 +824,39 @@ def _probe_credentials(agent) -> str: return "" +def _probe_config_health(cfg: dict) -> str: + """Flag bare YAML keys (`agent:` with no value → None) that silently + drop nested settings. Returns warning or ''.""" + if not isinstance(cfg, dict): + return "" + warnings: list[str] = [] + null_keys = sorted(k for k, v in cfg.items() if v is None) + if not null_keys: + pass + else: + keys = ", ".join(f"`{k}`" for k in null_keys) + warnings.append( + f"config.yaml has empty section(s): {keys}. " + f"Remove the line(s) or set them to `{{}}` — " + f"empty sections silently drop nested settings." + ) + display_cfg = cfg.get("display") + agent_cfg = cfg.get("agent") + if isinstance(display_cfg, dict): + personality = str(display_cfg.get("personality", "") or "").strip().lower() + if ( + personality + and personality not in {"default", "none", "neutral"} + and isinstance(agent_cfg, dict) + and agent_cfg.get("personalities") is None + ): + warnings.append( + "`display.personality` is set but `agent.personalities` is empty/null; " + "personality overlay will be skipped." + ) + return " ".join(warnings).strip() + + def _session_info(agent) -> dict: info: dict = { "model": getattr(agent, "model", ""), @@ -1104,20 +1145,22 @@ def _wire_callbacks(sid: str): def _resolve_personality_prompt(cfg: dict) -> str: """Resolve the active personality into a system prompt string.""" - name = (cfg.get("display", {}).get("personality", "") or "").strip().lower() + name = ((cfg.get("display") or {}).get("personality", "") or "").strip().lower() if not name or name in ("default", "none", "neutral"): return "" try: from cli import load_cli_config - personalities = load_cli_config().get("agent", {}).get("personalities", {}) + personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {} except Exception: try: from hermes_cli.config import load_config as _load_full_cfg - personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) + personalities = ( + (_load_full_cfg().get("agent") or {}).get("personalities", {}) or {} + ) except Exception: - personalities = cfg.get("agent", {}).get("personalities", {}) + personalities = (cfg.get("agent") or {}).get("personalities", {}) or {} pval = personalities.get(name) if pval is None: return "" @@ -1139,15 +1182,15 @@ def _available_personalities(cfg: dict | None = None) -> dict: try: from cli import load_cli_config - return load_cli_config().get("agent", {}).get("personalities", {}) or {} + return (load_cli_config().get("agent") or {}).get("personalities", {}) or {} except Exception: try: from hermes_cli.config import load_config as _load_full_cfg - return _load_full_cfg().get("agent", {}).get("personalities", {}) or {} + return (_load_full_cfg().get("agent") or {}).get("personalities", {}) or {} except Exception: cfg = cfg or _load_cfg() - return cfg.get("agent", {}).get("personalities", {}) or {} + return (cfg.get("agent") or {}).get("personalities", {}) or {} def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]: @@ -1257,7 +1300,7 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): from hermes_cli.runtime_provider import resolve_runtime_provider cfg = _load_cfg() - system_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" + system_prompt = (cfg.get("agent") or {}).get("system_prompt", "") or "" if not system_prompt: system_prompt = _resolve_personality_prompt(cfg) runtime = resolve_runtime_provider(requested=None) @@ -1503,6 +1546,10 @@ def _(rid, params: dict) -> dict: warn = _probe_credentials(agent) if warn: info["credential_warning"] = warn + cfg_warn = _probe_config_health(_load_cfg()) + if cfg_warn: + info["config_warning"] = cfg_warn + logger.warning(cfg_warn) _emit("session.info", sid, info) except Exception as e: session["agent_error"] = str(e) @@ -1649,9 +1696,7 @@ def _(rid, params: dict) -> dict: return _db_unavailable_error(rid, code=5007) title, key = params.get("title", ""), session["session_key"] if not title: - return _ok( - rid, {"title": db.get_session_title(key) or "", "session_key": key} - ) + return _ok(rid, {"title": db.get_session_title(key) or "", "session_key": key}) try: db.set_session_title(key, title) return _ok(rid, {"title": title}) @@ -2278,7 +2323,9 @@ def _(rid, params: dict) -> dict: f.write(trace) except Exception: pass - print(f"[gateway-turn] {type(e).__name__}: {e}", file=sys.stderr, flush=True) + print( + f"[gateway-turn] {type(e).__name__}: {e}", file=sys.stderr, flush=True + ) _emit("error", sid, {"message": str(e)}) finally: try: @@ -2701,9 +2748,7 @@ def _(rid, params: dict) -> dict: cfg = _load_cfg() display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} sections_cfg = ( - display.get("sections") - if isinstance(display.get("sections"), dict) - else {} + display.get("sections") if isinstance(display.get("sections"), dict) else {} ) nv = str(value or "").strip().lower() @@ -2838,18 +2883,21 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) if key == "skin": return _ok( - rid, {"value": _load_cfg().get("display", {}).get("skin", "default")} + rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")} ) if key == "personality": return _ok( - rid, {"value": _load_cfg().get("display", {}).get("personality", "default")} + rid, + {"value": (_load_cfg().get("display") or {}).get("personality", "default")}, ) if key == "reasoning": cfg = _load_cfg() - effort = str(cfg.get("agent", {}).get("reasoning_effort", "medium") or "medium") + effort = str( + (cfg.get("agent") or {}).get("reasoning_effort", "medium") or "medium" + ) display = ( "show" - if bool(cfg.get("display", {}).get("show_reasoning", False)) + if bool((cfg.get("display") or {}).get("show_reasoning", False)) else "hide" ) return _ok(rid, {"value": effort, "display": display}) @@ -2857,7 +2905,7 @@ def _(rid, params: dict) -> dict: allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) raw = ( str( - _load_cfg().get("display", {}).get("details_mode", "collapsed") + (_load_cfg().get("display") or {}).get("details_mode", "collapsed") or "collapsed" ) .strip() @@ -2868,13 +2916,17 @@ def _(rid, params: dict) -> dict: if key == "thinking_mode": allowed_tm = frozenset({"collapsed", "truncated", "full"}) cfg = _load_cfg() - raw = str(cfg.get("display", {}).get("thinking_mode", "") or "").strip().lower() + raw = ( + str((cfg.get("display") or {}).get("thinking_mode", "") or "") + .strip() + .lower() + ) if raw in allowed_tm: nv = raw else: dm = ( str( - cfg.get("display", {}).get("details_mode", "collapsed") + (cfg.get("display") or {}).get("details_mode", "collapsed") or "collapsed" ) .strip() @@ -2883,7 +2935,7 @@ def _(rid, params: dict) -> dict: nv = "full" if dm == "expanded" else "collapsed" return _ok(rid, {"value": nv}) if key == "compact": - on = bool(_load_cfg().get("display", {}).get("tui_compact", False)) + on = bool((_load_cfg().get("display") or {}).get("tui_compact", False)) return _ok(rid, {"value": "on" if on else "off"}) if key == "statusbar": display = _load_cfg().get("display") @@ -3369,7 +3421,16 @@ def _list_repo_files(root: str) -> list[str]: if top_result.returncode == 0: top = top_result.stdout.decode("utf-8", "replace").strip() list_result = subprocess.run( - ["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + [ + "git", + "-C", + top, + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ], capture_output=True, timeout=2.0, check=False, @@ -3378,7 +3439,9 @@ def _list_repo_files(root: str) -> list[str]: for p in list_result.stdout.decode("utf-8", "replace").split("\0"): if not p: continue - rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/") + rel = os.path.relpath(os.path.join(top, p), root).replace( + os.sep, "/" + ) # Skip parents/siblings of cwd — keep the picker scoped # to root-and-below, matching Cmd-P workspace semantics. if rel.startswith("../"): @@ -3512,12 +3575,7 @@ def _(rid, params: dict) -> dict: # editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with # `/`, `./`, `~/`, `/abs`) fall through to the directory-listing # path so explicit navigation intent is preserved. - if ( - is_context - and path_part - and "/" not in path_part - and prefix_tag != "folder" - ): + if is_context and path_part and "/" not in path_part and prefix_tag != "folder": root = os.getcwd() ranked: list[tuple[tuple[int, int], str, str]] = [] for rel in _list_repo_files(root): @@ -3721,7 +3779,7 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: _apply_personality_to_session(sid, session, new_prompt) elif name == "prompt" and agent: cfg = _load_cfg() - new_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" + new_prompt = (cfg.get("agent") or {}).get("system_prompt", "") or "" agent.ephemeral_system_prompt = new_prompt or None agent._cached_system_prompt = None elif name == "compress" and agent: @@ -3943,9 +4001,7 @@ def _(rid, params: dict) -> dict: voice_cfg = _load_cfg().get("voice", {}) start_continuous( - on_transcript=lambda t: _voice_emit( - "voice.transcript", {"text": t} - ), + on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}), on_status=lambda s: _voice_emit("voice.status", {"state": s}), on_silent_limit=lambda: _voice_emit( "voice.transcript", {"no_speech_limit": True} diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index acd10135e..baaf3fc3c 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -142,6 +142,10 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { sys(`warning: ${info.credential_warning}`) } + if (info?.config_warning) { + sys(`warning: ${info.config_warning}`) + } + if (msg) { sys(msg) } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index a38e06804..91fced32a 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -93,7 +93,7 @@ export interface SetupStatusResponse { // ── Session lifecycle ──────────────────────────────────────────────── export interface SessionCreateResponse { - info?: SessionInfo & { credential_warning?: string } + info?: SessionInfo & { config_warning?: string; credential_warning?: string } session_id: string }