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:
Teknium
2026-04-05 19:46:58 -07:00
committed by GitHub
parent 43d468cea8
commit 89c812d1d2
8 changed files with 233 additions and 7 deletions

View File

@@ -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()

View File

@@ -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."""