NousResearch/hermes-agent

Preserve named custom provider identity in desktop session resume

Cluster 44108 · dashboard. Data as of

Nothing open on GitHub. Fix merged in #45578 on 13 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #45578. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #45578 fix(desktop): preserve custom provider identity on resume

    merged to main

    files: 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

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

1 PR closed without merging

From the close comments: merged via another change 1

  • #44109 fix(desktop): persist custom provider entry identity in session runtime, not the resolved "custom" · merged via another change → #45578
Closed issues · 1
  • Closed#44108Desktop/TUI: session persistence stores resolved provider "custom", losing named custom_providers entry identity — resume rebuilds with no credentials
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#45578
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
13 Jun 2026

For your agent

The same facts, typed.