{"repo":"NousResearch/hermes-agent","public_id":"44108","display_label":"Preserve named custom provider identity in desktop session resume","summary":"All three members concern desktop (and TUI) session persistence storing the provider as the generic \"custom\" after lookup, instead of the named custom_providers entry, which the issue says leaves resume without credentials. Two items are fix-oriented pull requests proposing to persist the entry identity; their outcomes are not stated here.","subsystem":"dashboard","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":45578,"merged_at":"2026-06-13T12:51:05+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-13T12:51:05+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":44022,"pr_number":45578,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-13T12:51:05+00:00"},{"issue_number":44108,"pr_number":45578,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-13T12:51:05+00:00"}],"members":[{"kind":"issue","number":44108,"title":"Desktop/TUI: session persistence stores resolved provider \"custom\", losing named custom_providers entry identity — resume rebuilds with no credentials","url":null,"state":"closed"},{"kind":"pr","number":44109,"title":"fix(desktop): persist custom provider entry identity in session runtime, not the resolved \"custom\"","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45578","number":45578}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":45578,"title":"fix(desktop): preserve custom provider identity on resume","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/runtime_provider.py","scripts/release.py","tests/hermes_cli/test_custom_provider_identity.py","tests/test_tui_gateway_server.py","tests/tui_gateway/test_custom_provider_session_persistence.py","tui_gateway/server.py"],"fix_hunks":[{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -660,6 +660,61 @@ def has_named_custom_provider(requested_provider: str) -> bool:\n         return False\n \n \n+def find_custom_provider_identity(base_url: str) -> Optional[str]:\n+    \"\"\"Map an endpoint URL back to its canonical ``custom:<name>`` menu key.\n+\n+    Returns the ``custom:<normalized-name>`` slug of the first ``providers:``\n+    / ``custom_providers:`` entry whose base_url matches, or ``None`` when no\n+    entry owns the URL.\n+\n+    Session persistence stores the agent's *resolved* provider, and for every\n+    named custom endpoint that is the literal string ``\"custom\"`` — the entry\n+    name is lost, and the api_key is deliberately never persisted. The\n+    endpoint URL is the one durable fact that survives the round-trip, so\n+    this reverse lookup lets persist/rebuild paths recover the entry identity\n+    (and with it key_env/api_key/api_mode resolution via\n+    :func:`_get_named_custom_provider`) instead of failing with\n+    ``auth_unavailable`` or silently rebuilding with placeholder credentials.\n+    \"\"\"\n+    target = _normalize_base_url_for_match(base_url)\n+    if not target:\n+        return None\n+    try:\n+        config = load_config()\n+    except Exception:\n+        return None\n+\n+    providers = config.get(\"providers\")\n+    if isinstance(providers, dict):\n+        for ep_name, entry in providers.items():\n+            if not isinstance(entry, dict):\n+                continue\n+            entry_url = (\n+                entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n+            )\n+            if _normalize_base_url_for_match(entry_url) == target:\n+                return f\"custom:{_normalize_custom_provider_name(str(ep_name))}\"\n+\n+    try:\n+        custom_providers = get_compatible_custom_providers(config)\n+    except Exception:\n+        custom_providers = None\n+    for entry in custom_providers or []:\n+        if not isinstance(entry, dict):\n+            continue\n+        name = entry.get(\"name\")\n+        if not isinstance(name, str) or not name.strip():\n+            continue\n+        if _normalize_base_url_for_match(entry.get(\"base_url\")) == target:\n+            return f\"custom:{_normalize_custom_provider_name(name)}\"\n+\n+    return None\n+\n+\n+def _normalize_base_url_for_match(value) -> str:\n+    return str(value or \"\").strip().rstrip(\"/\").lower()\n+\n+\n def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:\n     extra_body = custom_provider.get(\"extra_body\")\n     if not isinstance(extra_body, dict) or not extra_body:"]},{"file":"scripts/release.py","hunks":["@@ -47,6 +47,8 @@\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"pinguarmy\",\n     \"[redacted:email]\": \"AIalliAI\",\n+    \"adalsteinnhelgason@users.noreply.github.com\": \"AIalliAI\",\n+    \"[redacted:email]\": \"HaozheZhang6\",\n     \"[redacted:email]\": \"barronlroth\",\n     \"[redacted:email]\": \"OndrejDrapalik\",\n     \"[redacted:email]\": \"tomekpanek\","]},{"file":"tests/hermes_cli/test_custom_provider_identity.py","hunks":["@@ -0,0 +1,99 @@\n+\"\"\"Unit tests for find_custom_provider_identity (base_url → custom:<name>).\n+\n+Reverse lookup used by tui_gateway session persistence to recover a named\n+``providers:`` / ``custom_providers:`` entry from the only durable fact the\n+session row keeps once the provider has been resolved to the literal string\n+\"custom\": the endpoint URL. See\n+tests/tui_gateway/test_custom_provider_session_persistence.py for the\n+end-to-end persist/resume round-trip.\n+\"\"\"\n+\n+import hermes_cli.runtime_provider as rp\n+\n+\n+def test_matches_legacy_custom_providers_list(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\"name\": \"MiMo v2.5 Pro\", \"base_url\": \"https://api.mimo.example/v1\"}\n+            ]\n+        },\n+    )\n+    assert (\n+        rp.find_custom_provider_identity(\"https://api.mimo.example/v1\")\n+        == \"custom:mimo-v2.5-pro\"\n+    )\n+\n+\n+def test_matches_providers_dict_by_key(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\"providers\": {\"local\": {\"api\": \"http://127.0.0.1:8000/v1\"}}},\n+    )\n+    assert (\n+        rp.find_custom_provider_identity(\"http://127.0.0.1:8000/v1\")\n+        == \"custom:local\"\n+    )\n+\n+\n+def test_match_ignores_trailing_slash_and_case(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\"name\": \"local\", \"base_url\": \"http://Localhost:8000/v1/\"}\n+            ]\n+        },\n+    )\n+    assert (\n+        rp.find_custom_provider_identity(\"http://localhost:8000/v1\")\n+        == \"custom:local\"\n+    )\n+\n+\n+def test_no_match_returns_none(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\"name\": \"other\", \"base_url\": \"https://elsewhere.example/v1\"}\n+            ]\n+        },\n+    )\n+    assert rp.find_custom_provider_identity(\"https://api.mimo.example/v1\") is None\n+\n+\n+def test_empty_base_url_returns_none(monkeypatch):\n+    monkeypatch.setattr(\n+        rp, \"load_config\", lambda: {\"custom_providers\": [{\"name\": \"x\"}]}\n+    )\n+    assert rp.find_custom_provider_identity(\"\") is None\n+    assert rp.find_custom_provider_identity(None) is None\n+\n+\n+def test_identity_resolves_back_through_named_lookup(monkeypatch):\n+    \"\"\"The returned slug must be accepted by _get_named_custom_provider —\n+    that is the whole point of persisting it.\"\"\"\n+    config = {\n+        \"custom_providers\": [\n+            {\n+                \"name\": \"mimo-v2.5-pro\",\n+                \"base_url\": \"https://api.mimo.example/v1\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+            }\n+        ]\n+    }\n+    monkeypatch.setattr(rp, \"load_config\", lambda: config)\n+\n+    slug = rp.find_custom_provider_identity(\"https://api.mimo.example/v1\")\n+    assert slug == \"custom:mimo-v2.5-pro\"\n+\n+    entry = rp._get_named_custom_provider(slug)\n+    assert entry is not None\n+    assert entry[\"base_url\"] == \"https://api.mimo.example/v1\"\n+    assert entry[\"api_key\"] == \"sk-entry\""]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -847,6 +847,41 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():\n     ]\n \n \n+def test_history_to_messages_keeps_reasoning_only_assistant_turn():\n+    # A thinking-only assistant turn (reasoning present, no visible text) is\n+    # persisted and recallable, but was dropped from the resumed session view\n+    # as \"empty\" -- so it vanished while the agent could still recall it from\n+    # the transcript. Keep it (with reasoning) so the desktop \"Thinking…\"\n+    # disclosure renders. (#44022)\n+    history = [\n+        {\"role\": \"user\", \"content\": \"think about this\"},\n+        {\"role\": \"assistant\", \"content\": \"\", \"reasoning\": \"step-by-step thoughts\"},\n+        {\"role\": \"assistant\", \"content\": \"here is the answer\"},\n+    ]\n+\n+    assert server._history_to_messages(history) == [\n+        {\"role\": \"user\", \"text\": \"think about this\"},\n+        {\"role\": \"assistant\", \"text\": \"\", \"reasoning\": \"step-by-step thoughts\"},\n+        {\"role\": \"assistant\", \"text\": \"here is the answer\"},\n+    ]\n+\n+\n+def test_history_to_messages_still_drops_empty_assistant_without_reasoning():\n+    # A genuinely empty assistant turn (no text, no reasoning, no tool calls)\n+    # remains filtered out -- the fix only spares reasoning-bearing turns.\n+    history = [\n+        {\"role\": \"user\", \"content\": \"hi\"},\n+        {\"role\": \"assistant\", \"content\": \"\", \"reasoning\": \"\"},\n+        {\"role\": \"assistant\", \"content\": \"   \"},\n+        {\"role\": \"assistant\", \"content\": \"real reply\"},\n+    ]\n+\n+    assert server._history_to_messages(history) == [\n+        {\"role\": \"user\", \"text\": \"hi\"},\n+        {\"role\": \"assistant\", \"text\": \"real reply\"},\n+    ]\n+\n+\n def test_history_to_messages_renders_multimodal_content():\n     # bb/gui preserves image URLs in the resume payload so the desktop\n     # renderer's extractEmbeddedImages can pull them back out and display","@@ -971,6 +1006,35 @@ def fake_init_session(sid, key, agent, history, cols=80):\n     assert server._sessions[runtime_sid][\"model_override\"] == captured[\"model_override\"]\n \n \n+def test_stored_session_runtime_overrides_skips_bare_billing_provider():\n+    \"\"\"A bare billing bucket (\"custom\"/\"auto\"/\"openrouter\") must not be restored as the\n+    provider identity on resume. A custom endpoint that never used `/model` persists only\n+    `billing_provider=\"custom\"`; restoring that broke `session.resume` with \"No LLM provider\n+    configured\" (agent_init treats it as non-routable). A real provider, or an explicit\n+    `model_config.provider`, is still restored.\n+    \"\"\"\n+    # Bare \"custom\" bucket, no explicit model_config.provider: no provider override restored.\n+    ov = server._stored_session_runtime_overrides({\"model\": \"my-model\", \"billing_provider\": \"custom\"})\n+    assert \"provider_override\" not in ov\n+    assert ov[\"model_override\"][\"provider\"] is None\n+\n+    for bare in (\"auto\", \"openrouter\", \"custom\"):\n+        ov = server._stored_session_runtime_overrides({\"model\": \"m\", \"billing_provider\": bare})\n+        assert \"provider_override\" not in ov\n+\n+    # A real provider in billing_provider is still restored.\n+    ov = server._stored_session_runtime_overrides({\"model\": \"m\", \"billing_provider\": \"anthropic\"})\n+    assert ov[\"provider_override\"] == \"anthropic\"\n+    assert ov[\"model_override\"][\"provider\"] == \"anthropic\"\n+\n+    # An explicit routable provider in model_config wins over the bare billing bucket.\n+    ov = server._stored_session_runtime_overrides(\n+        {\"model\": \"m\", \"billing_provider\": \"custom\", \"model_config\": {\"provider\": \"custom:myendpoint\"}}\n+    )\n+    assert ov[\"provider_override\"] == \"custom:myendpoint\"\n+    assert ov[\"model_override\"][\"provider\"] == \"custom:myendpoint\"\n+\n+\n def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):\n     updates = {}\n "]},{"file":"tests/tui_gateway/test_custom_provider_session_persistence.py","hunks":["@@ -0,0 +1,198 @@\n+\"\"\"Session persistence must not strip a custom provider's identity.\n+\n+``_runtime_model_config`` persists the live agent's RESOLVED provider into\n+the session row's ``model_config`` JSON. For any named ``providers:`` /\n+``custom_providers:`` entry (e.g. one called \"mimo-v2.5-pro\"),\n+``agent.provider`` is the literal string \"custom\", so the entry name was\n+lost — and the api_key is deliberately never persisted. On ``session.resume``\n+or ``_reset_session_agent``, ``_stored_session_runtime_overrides`` fed\n+provider=\"custom\" back into ``_make_agent`` →\n+``resolve_runtime_provider(requested=\"custom\")``, which cannot match an entry\n+named \"mimo-v2.5-pro\". Depending on config the rebuild either raised\n+\"No LLM provider configured. Run `hermes model`...\" (resume failed) or\n+silently resolved placeholder credentials (\"no-key-required\") against the\n+patched-back base_url.\n+\n+Fix: persist the REQUESTED/entry identity — ``_runtime_model_config`` maps\n+the agent's base_url back to the canonical ``custom:<name>`` menu key via\n+``find_custom_provider_identity``; ``_make_agent`` performs the same\n+recovery for rows persisted before the fix (and falls back to handing the\n+stored base_url to the direct-alias branch when no entry matches).\n+\n+Related investigation: GH #44070 / PR #44099 (credential-pool base_url\n+pinning); same family of resolved-vs-requested identity loss.\n+\"\"\"\n+\n+import json\n+import types\n+from unittest.mock import MagicMock, patch\n+\n+import hermes_cli.runtime_provider as rp\n+\n+MIMO_URL = \"https://token-plan-cn.xiaomimimo.com/v1\"\n+MIMO_KEY = \"sk-mimo-entry-key\"\n+\n+LEGACY_LIST_CONFIG = {\n+    \"custom_providers\": [\n+        {\n+            \"name\": \"mimo-v2.5-pro\",\n+            \"base_url\": MIMO_URL,\n+            \"api_key\": MIMO_KEY,\n+            \"api_mode\": \"chat_completions\",\n+        }\n+    ]\n+}\n+\n+PROVIDERS_DICT_CONFIG = {\n+    \"providers\": {\n+        \"mimo-v2.5-pro\": {\n+            \"api\": MIMO_URL,\n+            \"api_key\": MIMO_KEY,\n+        }\n+    }\n+}\n+\n+\n+def _custom_agent(base_url=MIMO_URL):\n+    return types.SimpleNamespace(\n+        model=\"mimo-v2.5-pro\",\n+        provider=\"custom\",\n+        base_url=base_url,\n+        api_mode=\"chat_completions\",\n+        reasoning_config=None,\n+        service_tier=None,\n+    )\n+\n+\n+class TestRuntimeModelConfigPersistsEntryIdentity:\n+    def test_persists_menu_key_instead_of_resolved_custom(self, monkeypatch):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: LEGACY_LIST_CONFIG)\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        config = _runtime_model_config(_custom_agent())\n+\n+        assert config[\"provider\"] == \"custom:mimo-v2.5-pro\"\n+        assert config[\"base_url\"] == MIMO_URL\n+        # Credentials must keep coming from config/provider resolution,\n+        # never from the session DB.\n+        assert \"api_key\" not in config\n+\n+    def test_persists_menu_key_for_providers_dict_entry(self, monkeypatch):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: PROVIDERS_DICT_CONFIG)\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        config = _runtime_model_config(_custom_agent())\n+\n+        assert config[\"provider\"] == \"custom:mimo-v2.5-pro\"\n+\n+    def test_keeps_bare_custom_when_no_entry_matches(self, monkeypatch):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: {})\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        config = _runtime_model_config(_custom_agent())\n+\n+        assert config[\"provider\"] == \"custom\"\n+\n+    def test_non_custom_provider_untouched(self, monkeypatch):\n+        def _boom():\n+            raise AssertionError(\"identity lookup must not run for built-ins\")\n+\n+        monkeypatch.setattr(rp, \"load_config\", _boom)\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        agent = _custom_agent()\n+        agent.provider = \"anthropic\"\n+        agent.base_url = \"https://api.anthropic.com\"\n+\n+        assert _runtime_model_config(agent)[\"provider\"] == \"anthropic\"\n+\n+\n+def _make_agent_with_override(override, monkeypatch, config):\n+    \"\"\"Run _make_agent through the REAL resolve_runtime_provider against a\n+    patched config, returning the kwargs AIAgent was constructed with.\"\"\"\n+    monkeypatch.setattr(rp, \"load_config\", lambda: config)\n+    monkeypatch.setattr(rp, \"_get_model_config\", lambda: {})\n+    # Keep credential-pool resolution off the developer's real HERMES home.\n+    monkeypatch.setattr(rp, \"_try_resolve_from_custom_pool\", lambda *a, **k: None)\n+\n+    fake_cfg = {\"agent\": {\"system_prompt\": \"\"}, \"model\": {\"default\": \"unused\"}}\n+    with (\n+        patch(\"tui_gateway.server._load_cfg\", return_value=fake_cfg),\n+        patch(\"tui_gateway.server._get_db\", return_value=MagicMock()),\n+        patch(\"tui_gateway.server._load_reasoning_config\", return_value=None),\n+        patch(\"tui_gateway.server._load_service_tier\", return_value=None),\n+        patch(\"tui_gateway.server._load_enabled_toolsets\", return_value=None),\n+        patch(\"run_agent.AIAgent\") as mock_agent,\n+    ):\n+        from tui_gateway.server import _make_agent\n+\n+        _make_agent(\"sid-custom\", \"key-custom\", model_override=override)\n+\n+    return mock_agent.call_args.kwargs\n+\n+\n+class TestResumeRoundTrip:\n+    def test_round_trip_restores_entry_credentials(self, monkeypatch):\n+        \"\"\"persist → stored-overrides → _make_agent resolves the entry's\n+        api_key again (the exact path that raised \"No LLM provider\n+        configured\" before the fix).\"\"\"\n+        monkeypatch.setattr(rp, \"load_config\", lambda: LEGACY_LIST_CONFIG)\n+\n+        from tui_gateway.server import (\n+            _runtime_model_config,\n+            _stored_session_runtime_overrides,\n+        )\n+\n+        model_config = _runtime_model_config(_custom_agent())\n+        row = {\n+            \"model\": \"mimo-v2.5-pro\",\n+            \"model_config\": json.dumps(model_config),\n+        }\n+        overrides = _stored_session_runtime_overrides(row)\n+        assert overrides[\"model_override\"][\"provider\"] == \"custom:mimo-v2.5-pro\"\n+\n+        kwargs = _make_agent_with_override(\n+            overrides[\"model_override\"], monkeypatch, LEGACY_LIST_CONFIG\n+        )\n+\n+        assert kwargs[\"provider\"] == \"custom\"\n+        assert kwargs[\"base_url\"] == MIMO_URL\n+        assert kwargs[\"api_key\"] == MIMO_KEY\n+\n+    def test_legacy_row_with_bare_custom_heals_via_base_url(self, monkeypatch):\n+        \"\"\"Rows persisted BEFORE the fix stored provider=\"custom\"; the\n+        rebuild must recover the entry identity from the stored base_url.\"\"\"\n+        override = {\n+            \"model\": \"mimo-v2.5-pro\",\n+            \"provider\": \"custom\",\n+            \"base_url\": MIMO_URL,\n+            \"api_mode\": \"chat_completions\",\n+        }\n+\n+        kwargs = _make_agent_with_override(override, monkeypatch, LEGACY_LIST_CONFIG)\n+\n+        assert kwargs[\"base_url\"] == MIMO_URL\n+        assert kwargs[\"api_key\"] == MIMO_KEY\n+\n+    def test_legacy_row_without_matching_entry_keeps_endpoint(self, monkeypatch):\n+        \"\"\"No config entry owns the stored URL: the direct-alias branch must\n+        still receive the base_url so resolution targets the session's\n+        endpoint instead of raising auth_unavailable.\"\"\"\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+        override = {\n+            \"model\": \"local-model\",\n+            \"provider\": \"custom\",\n+            \"base_url\": \"http://127.0.0.1:8000/v1\",\n+            \"api_mode\": \"chat_completions\",\n+        }\n+\n+        kwargs = _make_agent_with_override(override, monkeypatch, {})\n+\n+        assert kwargs[\"provider\"] == \"custom\"\n+        assert kwargs[\"base_url\"] == \"http://127.0.0.1:8000/v1\"\n+        assert kwargs[\"api_key\"] == \"no-key-required\""]},{"file":"tui_gateway/server.py","hunks":["@@ -1477,6 +1477,11 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:\n     return model, None\n \n \n+# Bare billing buckets are not routable provider identities (kept in parity with the\n+# provider gate in agent_init). Restoring one as a session provider override breaks resume.\n+_BARE_BILLING_PROVIDERS = {\"auto\", \"openrouter\", \"custom\"}\n+\n+\n def _stored_session_runtime_overrides(row: dict | None) -> dict:\n     \"\"\"Return runtime fields persisted with a stored session.\n ","@@ -1503,12 +1508,18 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:\n \n     overrides: dict = {}\n     model = str(row.get(\"model\") or model_config.get(\"model\") or \"\").strip()\n-    provider = str(\n-        model_config.get(\"provider\")\n-        or model_config.get(\"billing_provider\")\n-        or row.get(\"billing_provider\")\n-        or \"\"\n+    # ``billing_provider`` is only the billing bucket — for a custom endpoint it is the\n+    # bare class ``\"custom\"``, which agent_init treats as non-routable, so restoring it as\n+    # the provider override makes ``session.resume`` fail with \"No LLM provider configured\".\n+    # Only restore an explicit provider; otherwise leave it unset so resume falls back to\n+    # the configured default, matching the working CLI path.\n+    explicit_provider = str(model_config.get(\"provider\") or \"\").strip()\n+    billing_provider = str(\n+        model_config.get(\"billing_provider\") or row.get(\"billing_provider\") or \"\"\n     ).strip()\n+    provider = explicit_provider\n+    if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS:\n+        provider = billing_provider\n     base_url = str(model_config.get(\"base_url\") or \"\").strip()\n     api_mode = str(model_config.get(\"api_mode\") or \"\").strip()\n     reasoning_config = model_config.get(\"reasoning_config\")","@@ -1548,6 +1559,25 @@ def _runtime_model_config(agent, existing: dict | None = None) -> dict:\n     if model:\n         config[\"model\"] = model\n     if provider:\n+        if provider == \"custom\" and base_url:\n+            # ``agent.provider`` is the RESOLVED provider, and for any named\n+            # ``providers:`` / ``custom_providers:`` entry that is the literal\n+            # string \"custom\" — persisting it loses the entry identity, so a\n+            # later resume/rebuild cannot re-resolve the entry's credentials\n+            # (the api_key is deliberately never persisted; see\n+            # _stored_session_runtime_overrides). Recover the canonical\n+            # ``custom:<name>`` menu key from the endpoint URL so\n+            # resolve_runtime_provider() can find the entry again.\n+            try:\n+                from hermes_cli.runtime_provider import (\n+                    find_custom_provider_identity,\n+                )\n+\n+                provider = find_custom_provider_identity(base_url) or provider\n+            except Exception:\n+                logger.debug(\n+                    \"custom provider identity lookup failed\", exc_info=True\n+                )\n         config[\"provider\"] = provider\n     if base_url:\n         config[\"base_url\"] = base_url","@@ -3299,9 +3329,30 @@ def _make_agent(\n         override_base_url = model_override.get(\"base_url\")\n         override_api_key = model_override.get(\"api_key\")\n         override_api_mode = model_override.get(\"api_mode\")\n+        resolve_kwargs = {}\n+        if (\n+            override_base_url\n+            and str(requested_provider or \"\").strip().lower() == \"custom\"\n+        ):\n+            # Session rows persisted before the custom-provider identity fix\n+            # (see _runtime_model_config) stored the resolved provider\n+            # \"custom\", which _get_named_custom_provider cannot match back to\n+            # a named ``providers:`` / ``custom_providers:`` entry — the\n+            # rebuild then either raised auth_unavailable or silently\n+            # resolved placeholder credentials against the patched-back\n+            # base_url. Recover the entry identity from the persisted\n+            # base_url; failing that, hand the base_url to the direct-alias\n+            # branch so pool/env credentials can still be resolved for it.\n+            from hermes_cli.runtime_provider import find_custom_provider_identity\n+\n+            recovered = find_custom_provider_identity(override_base_url)\n+            if recovered:\n+                requested_provider = recovered\n+            resolve_kwargs[\"explicit_base_url\"] = override_base_url\n         runtime = resolve_runtime_provider(\n             requested=requested_provider,\n             target_model=model or None,\n+            **resolve_kwargs,\n         )\n         # The switch already resolved concrete credentials/endpoint; honor them\n         # so a custom/named endpoint survives the rebuild even if global","@@ -3657,16 +3708,27 @@ def _history_to_messages(history: list[dict]) -> list[dict]:\n                 {\"role\": \"tool\", \"name\": name, \"context\": _tool_ctx(name, args)}\n             )\n             continue\n-        if not content_text.strip():\n+        # An assistant turn may carry only reasoning/thinking content with no\n+        # visible text (extended-thinking turns, thinking-only recovery\n+        # responses). Such a turn is persisted with its reasoning fields and is\n+        # recallable from the transcript, but dropping it here as \"empty\" makes\n+        # it vanish from the resumed/reloaded session view while the desktop's\n+        # reasoning disclosure has nothing to render. Keep it when it carries\n+        # reasoning so the \"Thinking…\" block still shows. (#44022)\n+        reasoning_keys = (\n+            \"reasoning\",\n+            \"reasoning_content\",\n+            \"reasoning_details\",\n+            \"codex_reasoning_items\",\n+        )\n+        has_reasoning = role == \"assistant\" and any(\n+            m.get(key) for key in reasoning_keys\n+        )\n+        if not content_text.strip() and not has_reasoning:\n             continue\n         msg = {\"role\": role, \"text\": content_text}\n         if role == \"assistant\":\n-            for key in (\n-                \"reasoning\",\n-                \"reasoning_content\",\n-                \"reasoning_details\",\n-                \"codex_reasoning_items\",\n-            ):\n+            for key in reasoning_keys:\n                 if key in m and m.get(key) is not None:\n                     msg[key] = m.get(key)\n         messages.append(msg)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}