fix(gateway): /sethome must not persist Slack's synthetic per-message session thread as the home target

Third lane of the same contract (found in live staging validation):
/sethome run as a top-level relay-fronted Slack DM message captured the
adapter's session-keying thread stamp (the /sethome message's own id)
into the persisted HomeChannel.thread_id and its legacy env mirror.
Every bare-platform delivery (deliver="slack") then resolved home chat +
home thread and landed inside the ephemeral thread around the old
/sethome message. Extracted _home_thread_from_source with the same
synthetic-stamp recognition as cron origin capture; a /sethome run
inside a genuine thread keeps that thread as the home target. Users
repair an already-poisoned home target by rerunning /sethome.
This commit is contained in:
Victor Kyriazakos
2026-08-13 16:13:04 +00:00
committed by Teknium
parent 58ff0fd302
commit 9c5d08c0d5
2 changed files with 74 additions and 1 deletions

View File

@@ -98,6 +98,31 @@ def _model_switch_skew_guard() -> Optional[str]:
)
def _home_thread_from_source(source) -> Optional[str]:
"""The thread id /sethome should persist on the home target, or None.
Slack thread-per-message session keying stamps a top-level message's own
id as ``source.thread_id`` (a session KEY, not a durable location).
Persisting it would pin the HOME target itself to the ephemeral thread
spawned around the /sethome message — every bare-platform delivery
(``deliver="slack"``) would then land in that thread forever. Same
recognition as cron origin capture: a Slack thread id equal to the
message's own id is synthetic. A /sethome run inside a genuine thread
(thread id = the parent's id, not this message's own) keeps that thread
as the home target.
"""
thread_id = getattr(source, "thread_id", None)
if not thread_id:
return None
if (
getattr(source, "platform", None) == Platform.SLACK
and getattr(source, "message_id", None)
and str(thread_id) == str(source.message_id)
):
return None
return str(thread_id)
class GatewaySlashCommandsMixin:
"""In-session slash-command handlers for GatewayRunner."""
@@ -3020,7 +3045,7 @@ class GatewaySlashCommandsMixin:
error="Relay does not authenticate this logical home target",
)
thread_id = source.thread_id
thread_id = _home_thread_from_source(source)
home = HomeChannel(
platform=source.platform,
chat_id=str(chat_id),

View File

@@ -0,0 +1,48 @@
"""/sethome must not persist Slack's synthetic per-message session thread.
Live repro (relay-fronted Slack staging, 2026-08-13): /sethome run as a
top-level DM message captured source.thread_id — which the relay adapter had
stamped with the /sethome message's OWN id for session keying — into the
persisted HomeChannel. Every bare-platform delivery (deliver="slack") then
resolved home chat + home thread and landed inside the ephemeral thread
spawned around the old /sethome message.
Same contract as cron origin capture: a Slack thread id equal to the
message's own id is a synthetic session key, never a durable location.
"""
from types import SimpleNamespace
from gateway.config import Platform
from gateway.slash_commands import _home_thread_from_source
def _source(platform=Platform.SLACK, thread_id=None, message_id=None):
return SimpleNamespace(
platform=platform, thread_id=thread_id, message_id=message_id
)
class TestHomeThreadFromSource:
def test_synthetic_slack_thread_dropped(self):
"""Top-level /sethome: stamped thread == own message id -> None."""
src = _source(thread_id="1755043010.123456", message_id="1755043010.123456")
assert _home_thread_from_source(src) is None
def test_genuine_slack_thread_kept(self):
"""/sethome inside a real thread keeps that thread as home target."""
src = _source(thread_id="1755040000.000100", message_id="1755043010.123456")
assert _home_thread_from_source(src) == "1755040000.000100"
def test_no_thread_returns_none(self):
assert _home_thread_from_source(_source()) is None
def test_no_message_id_keeps_thread(self):
"""Without a message id to compare, never guess: keep the thread."""
src = _source(thread_id="1755040000.000100", message_id=None)
assert _home_thread_from_source(src) == "1755040000.000100"
def test_non_slack_platform_untouched(self):
"""Telegram forum topics legitimately reuse ids; rule is Slack-scoped."""
src = _source(platform=Platform.TELEGRAM, thread_id="2203", message_id="2203")
assert _home_thread_from_source(src) == "2203"