From 84e17db0bd26a4d8fcdfc6b53451dfdb54457c5c Mon Sep 17 00:00:00 2001 From: David Tyler Date: Thu, 20 Aug 2026 20:02:05 +0000 Subject: [PATCH] fix(bot-mode): canonical bot DMs always follow the profile's current config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot-Mode canonical chats (the ONE forever DM per bot) and room plumbing sessions are plugin-owned scratch conversations. They are now created with an explicit follow_profile_config contract, persisted in the session row's model_config, so session.resume rebuilds from the member profile's CURRENT config instead of restoring the stored model/provider pin from an old row. That stale pin is what left bot DMs stuck on a dead provider (e.g. 'out of Nous credits' after the profile was switched to ollama-cloud) while the same bot worked fine in rooms — the mirror image of the room-plumbing bug (#89497 class). Normal 1:1 user chats keep the stored-runtime restore: opening an older chat must show the model it actually used. - tui_gateway/methods_session.py: accept follow_profile_config on session.create - tui_gateway/server.py: persist the marker in the row; skip stored-runtime overrides on resume when present - apps/desktop/src/plugins/hermes-bots/plugin.js: send the contract from createCanonicalChat and ensureGroupChatSession - tests: backend override + row-persist coverage; desktop source-contract coverage for both session kinds --- .../desktop/src/plugins/hermes-bots/plugin.js | 13 +- .../hermes-bots/tests/group-chat.test.mjs | 13 ++ ...est_custom_provider_session_persistence.py | 112 +++++++++++++++++- tui_gateway/methods_session.py | 1 + tui_gateway/server.py | 34 ++++++ 5 files changed, 169 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index a79b788963..11a8301d77 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5868,7 +5868,12 @@ function createCanonicalChat(owner, { kickoff = false, openingStillCurrent = nul // plugin-owned. Core applies this via the generic `hidden` flag // (deferred as pending_hidden until the row exists); older gateways // ignore the unknown param and it stays visible. - hidden: true + hidden: true, + // Explicit contract: this session's runtime always follows the member + // profile's CURRENT config. Resume must NOT restore the stored + // model/provider pin from an old row (that left bot DMs stuck on a + // stale provider — e.g. "out of Nous credits" — after a profile switch). + follow_profile_config: true }) const sid = res?.stored_session_id const runtime = res?.session_id @@ -7479,7 +7484,11 @@ async function ensureGroupChatSession(group, member) { // profile's CURRENT config. Resume must NOT restore the stored // model/provider pin from an old row (that left room bots stuck on a // stale provider — e.g. "out of Nous credits" — after a profile switch). - room_plumbing: true + room_plumbing: true, + // Same follow-profile-config contract as the canonical Bot Chat: a room + // member's runtime always follows the member profile's CURRENT config, + // never a stale stored model/provider pin from an old row. + follow_profile_config: true }) const stored = created?.stored_session_id || null diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs index fcbdac1da2..e0bfa85836 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs @@ -598,6 +598,19 @@ test('room plumbing sessions carry the room_plumbing contract', async () => { assert.match(pluginSource, /room_plumbing: true/) }) +test('bot sessions carry the follow-profile-config contract (canonical DM + room)', () => { + // Canonical Bot Chat: the ONE forever DM per bot must always follow the + // member profile's CURRENT config — never a stale stored model/provider pin + // (the "out of Nous credits" DM bug after a profile switch). + assert.match(pluginSource, /follow_profile_config: true/) + // The contract is sent on session.create for BOTH the canonical chat and + // room plumbing sessions, so resume rebuilds from current config. + const canonical = pluginSource.slice(pluginSource.indexOf('function createCanonicalChat')) + assert.match(canonical, /follow_profile_config: true/) + const room = pluginSource.slice(pluginSource.indexOf('async function ensureGroupChatSession')) + assert.match(room, /follow_profile_config: true/) +}) + test('log trimming keeps watermarks consistent', () => { const gc = load(() => '(pass)') const log = Array.from({ length: 200 }, (_, i) => ({ from: { kind: 'user', name: 'You' }, text: `m${i}`, at: i })) diff --git a/tests/tui_gateway/test_custom_provider_session_persistence.py b/tests/tui_gateway/test_custom_provider_session_persistence.py index 59b228472b..b58aaff1af 100644 --- a/tests/tui_gateway/test_custom_provider_session_persistence.py +++ b/tests/tui_gateway/test_custom_provider_session_persistence.py @@ -511,7 +511,7 @@ class TestRoomPlumbingRuntimeOverrides: assert _stored_session_runtime_overrides(row) == {} def test_legacy_group_title_shape_still_skipped(self): - """Rows from older desktop builds (hidden + \"Group:\" title, no + """Rows from older desktop builds (hidden + "Group:" title, no marker) keep the legacy guard: they also rebuild from current config.""" from tui_gateway.server import _stored_session_runtime_overrides @@ -543,7 +543,7 @@ class TestRoomPlumbingRuntimeOverrides: assert overrides["model_override"]["provider"] == "ollama-cloud" def test_hidden_normal_chat_untouched_by_legacy_shape(self): - """A hidden NON-room chat (hidden without a \"Group:\" title) keeps the + """A hidden NON-room chat (hidden without a "Group:" title) keeps the stored-runtime restore — the legacy shape is narrow on purpose.""" from tui_gateway.server import _stored_session_runtime_overrides @@ -557,3 +557,111 @@ class TestRoomPlumbingRuntimeOverrides: overrides = _stored_session_runtime_overrides(row) assert overrides["model_override"]["model"] == "glm-5.1" + +# --- Regression: bot DM stuck on a stale provider pin (GH #89497 class) ------ +# +# Bot-Mode canonical chats (the ONE forever DM per bot) and room plumbing +# sessions are plugin-owned scratch conversations. They are created with the +# explicit ``follow_profile_config`` contract so resume ALWAYS rebuilds from +# the member profile's CURRENT config — restoring the stored model/provider +# pin from an old row is what left bot DMs stuck on a stale provider (e.g. +# "out of Nous credits" after the profile was switched to ollama-cloud) while +# the same bot worked fine in rooms. Normal 1:1 user chats keep the +# stored-runtime restore (opening an older chat must show the model it +# actually used). + + +class TestFollowProfileConfigRuntimeOverrides: + def test_marked_row_returns_no_overrides(self): + """A row carrying the follow_profile_config marker never restores a + stored provider pin — resume falls back to the profile's CURRENT + config.""" + from tui_gateway.server import _stored_session_runtime_overrides + + row = { + "model": "openai/gpt-5.6-luna-pro", + "billing_provider": "nous", + "model_config": json.dumps( + { + "model": "openai/gpt-5.6-luna-pro", + "provider": "nous", + "follow_profile_config": True, + } + ), + } + assert _stored_session_runtime_overrides(row) == {} + + def test_marked_row_dict_model_config_returns_no_overrides(self): + """Same contract when model_config is already a dict (not JSON).""" + from tui_gateway.server import _stored_session_runtime_overrides + + row = { + "model": "openai/gpt-5.6-luna-pro", + "model_config": { + "model": "openai/gpt-5.6-luna-pro", + "provider": "nous", + "follow_profile_config": True, + }, + } + assert _stored_session_runtime_overrides(row) == {} + + def test_unmarked_row_still_restores_stored_runtime(self): + """Normal 1:1 user chats keep the stored-runtime restore — the + contract must not leak into ordinary sessions.""" + from tui_gateway.server import _stored_session_runtime_overrides + + row = { + "model": "openai/gpt-5.6-luna-pro", + "billing_provider": "nous", + "model_config": json.dumps( + {"model": "openai/gpt-5.6-luna-pro", "provider": "nous"} + ), + } + overrides = _stored_session_runtime_overrides(row) + assert overrides["model_override"]["model"] == "openai/gpt-5.6-luna-pro" + assert overrides["model_override"]["provider"] == "nous" + + def test_ensure_db_row_persists_contract_marker(self, monkeypatch): + """_ensure_session_db_row stamps follow_profile_config into the row's + model_config when the session carries the contract.""" + import tui_gateway.server as server + + captured = {} + + class FakeDB: + def create_session(self, *args, **kwargs): + captured["model_config"] = kwargs.get("model_config") + return None + + monkeypatch.setattr(server, "_get_db", lambda: FakeDB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "glm-5.1") + + session = { + "session_key": "key-1", + "model_override": {"model": "glm-5.1", "provider": "ollama-cloud"}, + "follow_profile_config": True, + } + server._ensure_session_db_row(session) + assert captured["model_config"].get("follow_profile_config") is True + + def test_ensure_db_row_omits_marker_without_contract(self, monkeypatch): + """Sessions without the contract do NOT get the marker — normal chats + keep the stored-runtime restore.""" + import tui_gateway.server as server + + captured = {} + + class FakeDB: + def create_session(self, *args, **kwargs): + captured["model_config"] = kwargs.get("model_config") + return None + + monkeypatch.setattr(server, "_get_db", lambda: FakeDB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "glm-5.1") + + session = { + "session_key": "key-2", + "model_override": {"model": "glm-5.1", "provider": "ollama-cloud"}, + } + server._ensure_session_db_row(session) + assert captured["model_config"].get("follow_profile_config") is None diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 2692758dfb..f76a4325c7 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -100,6 +100,7 @@ def _(rid, params: dict) -> dict: "pending_title": title or None, "pending_hidden": is_truthy_value(params.get("hidden", False)), "room_plumbing": is_truthy_value(params.get("room_plumbing", False)), + "follow_profile_config": is_truthy_value(params.get("follow_profile_config", False)), "profile_home": str(profile_home) if profile_home is not None else None, "running": False, "session_key": key, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 38c2ab72fa..d957b8866e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3897,6 +3897,15 @@ def _ensure_session_db_row(session: dict) -> None: # restored verbatim). See _stored_session_runtime_overrides. if session.get("room_plumbing"): model_config["room_plumbing"] = True + # Bot-Mode canonical chats (the ONE forever DM per bot) and room plumbing + # sessions are plugin-owned scratch conversations: their runtime must ALWAYS + # follow the member profile's CURRENT config, never the model/provider that + # was pinned when the row was first written. Persist that contract explicitly + # so resume can distinguish them from a normal user chat (whose stored + # model/provider must be restored verbatim). See + # _stored_session_runtime_overrides. + if session.get("follow_profile_config"): + model_config["follow_profile_config"] = True try: db.create_session( key, @@ -5285,6 +5294,31 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict: if _row_hidden and _row_title.startswith("Group:"): return {} + # Bot-Mode canonical chats (the ONE forever DM per bot) and room plumbing + # sessions are plugin-owned scratch conversations. They must always rebuild + # from the member profile's CURRENT config: restoring the stored + # model/provider pin from an old row is what left bot DMs stuck on a stale + # provider (e.g. "out of Nous credits" after the profile was switched to + # ollama-cloud) while the same bot worked fine in rooms. 1:1 user chats + # keep the stored-runtime restore (opening an older chat must show the + # model it actually used); only the plugin-owned bot sessions are exempt. + # + # The primary signal is the EXPLICIT ``follow_profile_config`` contract + # persisted by session.create consumers (desktop Bot Mode) — a deliberate + # marker, not a presentation heuristic. + raw_follow = row.get("model_config") + if isinstance(raw_follow, dict): + _follow_marker = raw_follow.get("follow_profile_config") + elif isinstance(raw_follow, str) and raw_follow.strip(): + try: + _follow_marker = json.loads(raw_follow).get("follow_profile_config") + except Exception: + _follow_marker = None + else: + _follow_marker = None + if _follow_marker: + return {} + raw_config = row.get("model_config") model_config: dict = {} if isinstance(raw_config, dict):