feat: shared thread sessions by default — multi-user thread support (#5391)
Threads (Telegram forum topics, Discord threads, Slack threads) now default to shared sessions where all participants see the same conversation. This is the expected UX for threaded conversations where multiple users @mention the bot and interact collaboratively. Changes: - build_session_key(): when thread_id is present, user_id is no longer appended to the session key (threads are shared by default) - New config: thread_sessions_per_user (default: false) — opt-in to restore per-user isolation in threads if needed - Sender attribution: messages in shared threads are prefixed with [sender name] so the agent can tell participants apart - System prompt: shared threads show 'Multi-user thread' note instead of a per-turn User line (avoids busting prompt cache) - Wired through all callers: gateway/run.py, base.py, telegram.py, feishu.py - Regular group messages (no thread) remain per-user isolated (unchanged) - DM threads are unaffected (they have their own keying logic) Closes community request from demontut_ re: thread-based shared sessions.
This commit is contained in:
@@ -109,6 +109,7 @@ class TestGatewayConfigRoundtrip:
|
||||
reset_triggers=["/new"],
|
||||
quick_commands={"limits": {"type": "exec", "command": "echo ok"}},
|
||||
group_sessions_per_user=False,
|
||||
thread_sessions_per_user=True,
|
||||
)
|
||||
d = config.to_dict()
|
||||
restored = GatewayConfig.from_dict(d)
|
||||
@@ -118,6 +119,7 @@ class TestGatewayConfigRoundtrip:
|
||||
assert restored.reset_triggers == ["/new"]
|
||||
assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
|
||||
assert restored.group_sessions_per_user is False
|
||||
assert restored.thread_sessions_per_user is True
|
||||
|
||||
def test_roundtrip_preserves_unauthorized_dm_behavior(self):
|
||||
config = GatewayConfig(
|
||||
@@ -167,6 +169,30 @@ class TestLoadGatewayConfig:
|
||||
|
||||
assert config.group_sessions_per_user is False
|
||||
|
||||
def test_bridges_thread_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text("thread_sessions_per_user: true\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.thread_sessions_per_user is True
|
||||
|
||||
def test_thread_sessions_per_user_defaults_to_false(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text("{}\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.thread_sessions_per_user is False
|
||||
|
||||
def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
@@ -291,6 +291,69 @@ class TestBuildSessionContextPrompt:
|
||||
|
||||
assert "WhatsApp" in prompt or "whatsapp" in prompt.lower()
|
||||
|
||||
def test_multi_user_thread_prompt(self):
|
||||
"""Shared thread sessions show multi-user note instead of single user."""
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake"),
|
||||
},
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_name="Test Group",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
user_name="Alice",
|
||||
)
|
||||
ctx = build_session_context(source, config)
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
assert "Multi-user thread" in prompt
|
||||
assert "[sender name]" in prompt
|
||||
# Should NOT show a specific **User:** line (would bust cache)
|
||||
assert "**User:** Alice" not in prompt
|
||||
|
||||
def test_non_thread_group_shows_user(self):
|
||||
"""Regular group messages (no thread) still show the user name."""
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake"),
|
||||
},
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_name="Test Group",
|
||||
chat_type="group",
|
||||
user_name="Alice",
|
||||
)
|
||||
ctx = build_session_context(source, config)
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
assert "**User:** Alice" in prompt
|
||||
assert "Multi-user thread" not in prompt
|
||||
|
||||
def test_dm_thread_shows_user_not_multi(self):
|
||||
"""DM threads are single-user and should show User, not multi-user note."""
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake"),
|
||||
},
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="99",
|
||||
chat_type="dm",
|
||||
thread_id="topic-1",
|
||||
user_name="Alice",
|
||||
)
|
||||
ctx = build_session_context(source, config)
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
assert "**User:** Alice" in prompt
|
||||
assert "Multi-user thread" not in prompt
|
||||
|
||||
|
||||
class TestSessionStoreRewriteTranscript:
|
||||
"""Regression: /retry and /undo must persist truncated history to disk."""
|
||||
@@ -636,7 +699,28 @@ class TestWhatsAppDMSessionKeyConsistency:
|
||||
key = build_session_key(source)
|
||||
assert key == "agent:main:telegram:group:-1002285219667:17585"
|
||||
|
||||
def test_group_thread_sessions_are_isolated_per_user(self):
|
||||
def test_group_thread_sessions_are_shared_by_default(self):
|
||||
"""Threads default to shared sessions — user_id is NOT appended."""
|
||||
alice = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
user_id="alice",
|
||||
)
|
||||
bob = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
user_id="bob",
|
||||
)
|
||||
assert build_session_key(alice) == "agent:main:telegram:group:-1002285219667:17585"
|
||||
assert build_session_key(bob) == "agent:main:telegram:group:-1002285219667:17585"
|
||||
assert build_session_key(alice) == build_session_key(bob)
|
||||
|
||||
def test_group_thread_sessions_can_be_isolated_per_user(self):
|
||||
"""thread_sessions_per_user=True restores per-user isolation in threads."""
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
@@ -644,9 +728,60 @@ class TestWhatsAppDMSessionKeyConsistency:
|
||||
thread_id="17585",
|
||||
user_id="42",
|
||||
)
|
||||
key = build_session_key(source)
|
||||
key = build_session_key(source, thread_sessions_per_user=True)
|
||||
assert key == "agent:main:telegram:group:-1002285219667:17585:42"
|
||||
|
||||
def test_non_thread_group_sessions_still_isolated_per_user(self):
|
||||
"""Regular group messages (no thread_id) remain per-user by default."""
|
||||
alice = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_type="group",
|
||||
user_id="alice",
|
||||
)
|
||||
bob = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1002285219667",
|
||||
chat_type="group",
|
||||
user_id="bob",
|
||||
)
|
||||
assert build_session_key(alice) == "agent:main:telegram:group:-1002285219667:alice"
|
||||
assert build_session_key(bob) == "agent:main:telegram:group:-1002285219667:bob"
|
||||
assert build_session_key(alice) != build_session_key(bob)
|
||||
|
||||
def test_discord_thread_sessions_shared_by_default(self):
|
||||
"""Discord threads are shared across participants by default."""
|
||||
alice = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="guild-123",
|
||||
chat_type="thread",
|
||||
thread_id="thread-456",
|
||||
user_id="alice",
|
||||
)
|
||||
bob = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="guild-123",
|
||||
chat_type="thread",
|
||||
thread_id="thread-456",
|
||||
user_id="bob",
|
||||
)
|
||||
assert build_session_key(alice) == build_session_key(bob)
|
||||
assert "alice" not in build_session_key(alice)
|
||||
assert "bob" not in build_session_key(bob)
|
||||
|
||||
def test_dm_thread_sessions_not_affected(self):
|
||||
"""DM threads use their own keying logic and are not affected."""
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="99",
|
||||
chat_type="dm",
|
||||
thread_id="topic-1",
|
||||
user_id="42",
|
||||
)
|
||||
key = build_session_key(source)
|
||||
# DM logic: chat_id + thread_id, user_id never included
|
||||
assert key == "agent:main:telegram:dm:99:topic-1"
|
||||
|
||||
|
||||
class TestSessionStoreEntriesAttribute:
|
||||
"""Regression: /reset must access _entries, not _sessions."""
|
||||
|
||||
Reference in New Issue
Block a user