Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/desktop/src/plugins/hermes-bots/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3306,7 +3306,12 @@ function createCanonicalChat(name) {
// 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
Expand Down Expand Up @@ -4125,7 +4130,16 @@ async function ensureGroupChatSession(group, member) {
profile: member.name,
title,
// Room member sessions are plumbing — always hidden from the sidebar.
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 room bots stuck on a
// stale provider — e.g. "out of Nous credits" — after a profile switch).
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

Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,35 @@ test('turn transport is gateway-native (session RPCs) and hostile text rides ver
assert.match(pluginSource, /const title = `Group: \$\{group\}`/)
})

test('room plumbing sessions carry the room_plumbing contract', async () => {
const gc = load(() => '(pass)')

gc.sendToGroupChat('Rpc', [{ name: 'research', title: '' }], 'hello')
for (let i = 0; i < 200 && (gc.$groupChats.get().Rpc || {}).running; i++) {
await new Promise(resolve => setImmediate(resolve))
}

// The per-group plumbing session must be hidden AND stamped as
// room_plumbing so the server rebuilds it from the member profile's
// CURRENT config instead of restoring a stale stored provider pin
// (GH #89497 — room bots stuck on Nous after a provider switch).
assert.match(pluginSource, /hidden: true/)
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 }))
Expand Down
196 changes: 196 additions & 0 deletions tests/tui_gateway/test_custom_provider_session_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,199 @@ def test_identity_by_model_from_providers_dict_models_list(self, monkeypatch):
)


# --- Bot-Mode room plumbing sessions follow the profile's CURRENT config ------
#
# Room plumbing sessions are per-member scratch conversations inside a group
# chat (desktop Bot Mode). They must ALWAYS rebuild from the member profile's
# current config: restoring the stored model/provider pin from an old row is
# what left room bots stuck on a stale provider (e.g. "out of Nous credits"
# after the profile was switched to ollama-cloud) while the same bots worked
# fine in DMs. The stored-runtime restore stays intact for normal 1:1 chats.
#
# The contract is an EXPLICIT ``room_plumbing`` marker persisted in
# model_config (set by session.create/room consumers), with the hidden +
# "Group:" title shape kept as a legacy fallback for rows created by older
# desktop builds that never sent the marker.
#
# Regression: GH #89497 (room bots hang then report "out of Nous credits").


class TestRoomPlumbingRuntimeOverrides:
def test_marked_row_returns_no_overrides(self):
"""A row carrying the room_plumbing 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", "room_plumbing": True}
),
}
assert _stored_session_runtime_overrides(row) == {}

def test_marked_row_dict_model_config(self):
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", "room_plumbing": True},
}
assert _stored_session_runtime_overrides(row) == {}

def test_legacy_group_title_shape_still_skipped(self):
"""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

row = {
"title": "Group: Ceo, Product Designer, Cfo, COO, CTO, Coding",
"hidden": 1,
"model": "openai/gpt-5.6-luna-pro",
"billing_provider": "nous",
"model_config": json.dumps({"model": "openai/gpt-5.6-luna-pro", "provider": "nous"}),
}
assert _stored_session_runtime_overrides(row) == {}

def test_normal_row_still_restores_stored_runtime(self):
"""The intended stored-runtime restore is untouched for normal 1:1
chats: reopening an old chat shows the model it actually used."""
from tui_gateway.server import _stored_session_runtime_overrides

row = {
"title": "Analyze business idea gaps",
"hidden": 0,
"model": "glm-5.1",
"billing_provider": "ollama-cloud",
"model_config": json.dumps(
{"model": "glm-5.1", "provider": "ollama-cloud", "service_tier": "normal"}
),
}
overrides = _stored_session_runtime_overrides(row)
assert overrides["model_override"]["model"] == "glm-5.1"
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
stored-runtime restore — the legacy shape is narrow on purpose."""
from tui_gateway.server import _stored_session_runtime_overrides

row = {
"title": "My hidden scratchpad",
"hidden": 1,
"model": "glm-5.1",
"billing_provider": "ollama-cloud",
"model_config": json.dumps({"model": "glm-5.1", "provider": "ollama-cloud"}),
}
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
2 changes: 2 additions & 0 deletions tui_gateway/methods_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ def _(rid, params: dict) -> dict:
"parent_session_id": parent_session_id,
"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,
Expand Down
75 changes: 75 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3009,6 +3009,23 @@ def _ensure_session_db_row(session: dict) -> None:
parent_session_id = session.get("parent_session_id") or None
if parent_session_id:
model_config["_branched_from"] = parent_session_id
# Bot-Mode room plumbing sessions are per-member scratch conversations
# inside a group chat: their runtime must ALWAYS follow the member profile's
# CURRENT config, never the provider that was pinned when the row was first
# written. Persist that contract explicitly so resume can distinguish room
# plumbing from a normal user chat (whose stored model/provider must be
# 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,
Expand Down Expand Up @@ -4134,6 +4151,64 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
if not row:
return {}

# Bot-Mode room plumbing sessions (hidden, titled "Group: <name>") are
# per-member scratch conversations inside a group chat. They must always
# rebuild from the member profile's CURRENT config: restoring the stored
# model/provider pin from an old row is what left room bots stuck on
# Nous (or any earlier provider) long after the profile was switched —
# every room message then failed with "out of Nous credits" while the
# same bots worked fine in DMs. 1:1 chats keep the stored-runtime
# restore (opening an older chat must show the model it actually used);
# only the room plumbing is exempt.
#
# The primary signal is the EXPLICIT ``room_plumbing`` contract persisted
# by session.create/room consumers (desktop Bot Mode) — a deliberate
# marker, not a presentation heuristic. The hidden + "Group:" title
# shape is kept as a legacy fallback so rows created by older desktop
# builds (which never sent the marker) still behave correctly until the
# client catches up.
raw_plumbing = row.get("model_config")
if isinstance(raw_plumbing, dict):
_plumbing_marker = raw_plumbing.get("room_plumbing")
elif isinstance(raw_plumbing, str) and raw_plumbing.strip():
try:
_plumbing_marker = json.loads(raw_plumbing).get("room_plumbing")
except Exception:
_plumbing_marker = None
else:
_plumbing_marker = None
if _plumbing_marker:
return {}
_row_title = str(row.get("title") or "").strip()
_row_hidden = row.get("hidden")
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):
Expand Down