fix(bot-mode): canonical bot DMs always follow the profile's current config

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
This commit is contained in:
David Tyler
2026-08-20 20:02:05 +00:00
committed by Teknium
parent 316e51ae72
commit 84e17db0bd
5 changed files with 169 additions and 4 deletions
+11 -2
View File
@@ -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
@@ -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 }))
@@ -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
+1
View File
@@ -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,
+34
View File
@@ -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):