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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user