diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 1d1fe52fbf..e98309bfd2 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -1909,8 +1909,14 @@ class SlackAdapter(BasePlatformAdapter): def scope_id_for_chat(self, chat_id: str) -> Optional[str]: """Return the workspace id owning ``chat_id``. ``None`` for unknown channels and for channels claimed by several workspaces (dropped from - the map) — no scope beats a wrong one.""" + the map) — no scope beats a wrong one. A channel unseen since boot (the map only fills from + inbound events) still resolves when exactly one workspace is authenticated: every inbound + reply will carry that team_id, so a caller keying a session on it must match (#111896).""" team_id = chat_id and (getattr(self, "_channel_team", None) or {}).get(str(chat_id)) + if not team_id and chat_id and str(chat_id) not in (getattr(self, "_channel_teams", None) or {}): + team_clients = getattr(self, "_team_clients", None) or {} + if len(team_clients) == 1: + team_id = next(iter(team_clients)) return str(team_id) if team_id else None def _get_client(self, chat_id: str, team_id: Optional[str] = None) -> Any: diff --git a/tests/gateway/test_handoff_thread_session_key.py b/tests/gateway/test_handoff_thread_session_key.py index ddc343847a..7f344c9b7f 100644 --- a/tests/gateway/test_handoff_thread_session_key.py +++ b/tests/gateway/test_handoff_thread_session_key.py @@ -104,24 +104,27 @@ def test_discord_handoff_key_does_not_use_parent_channel(): assert handoff != buggy, "handoff regressed to keying on the parent channel" -def _slack_handoff_destination(channel_id: str, thread_ts: str, team_id: str): - """Run the real handoff destination/key path against a Slack home channel.""" - config = GatewayConfig(platforms={Platform.SLACK: PlatformConfig(enabled=True, token="test")}) - config.platforms[Platform.SLACK].home_channel = HomeChannel( - platform=Platform.SLACK, chat_id=channel_id, name="home", scope_id=team_id) - adapter = MagicMock() - adapter.create_handoff_thread = AsyncMock(return_value=thread_ts) +def _handoff_destination(platform: Platform, channel_id: str, thread_id: str, scope_id, adapter): + """Run the real handoff destination/key path against a configured home channel.""" + config = GatewayConfig(platforms={platform: PlatformConfig(enabled=True, token="test")}) + config.platforms[platform].home_channel = HomeChannel( + platform=platform, chat_id=channel_id, name="home", scope_id=scope_id) + adapter.create_handoff_thread = AsyncMock(return_value=thread_id) runner = object.__new__(GatewayRunner) runner.config = config - runner.adapters = {Platform.SLACK: adapter} + runner.adapters = {platform: adapter} runner.session_store = None with patch("gateway.delivery.resolve_delivery_transport", lambda *_a: SimpleNamespace(adapter=adapter, send=AsyncMock())): dest = asyncio.run(runner._handoff_resolve_destination( - {"id": "cli-session", "title": "work", "handoff_platform": "slack"}, profile_name=None)) + {"id": "cli-session", "title": "work", "handoff_platform": platform.value}, profile_name=None)) return dest, runner._handoff_session_key(dest, profile_name=None) +def _slack_handoff_destination(channel_id: str, thread_ts: str, team_id: str): + return _handoff_destination(Platform.SLACK, channel_id, thread_ts, team_id, MagicMock()) + + def _organic_slack_reply_key(channel_id: str, thread_ts: str, team_id: str, chat_type: str) -> str: """Key the Slack adapter builds for a thread reply (``_build_message_event``): parent channel as chat_id, ``dm``/``group`` from the channel type, workspace id as scope_id.""" @@ -144,3 +147,14 @@ def test_slack_channel_handoff_key_matches_the_thread_reply_key(): dest, handoff = _slack_handoff_destination("C0CHANNEL01", "1789474088.089709", "T0C2HL96FH6") assert handoff == _organic_slack_reply_key("C0CHANNEL01", "1789474088.089709", "T0C2HL96FH6", "group") assert dest.source.chat_id == "C0CHANNEL01" + + +def test_slack_handoff_without_stored_scope_uses_the_sole_workspace_on_a_cold_channel_map(): + """An env/legacy home has no scope_id and the adapter's channel→team map is empty right after + boot; a single authenticated workspace still identifies the team the reply key will carry.""" + from plugins.platforms.slack.adapter import SlackAdapter + + adapter = SlackAdapter.__new__(SlackAdapter) + adapter._channel_team, adapter._channel_teams, adapter._team_clients = {}, {}, {"T0C2HL96FH6": object()} + _dest, handoff = _handoff_destination(Platform.SLACK, "D0C1HFBMQAX", "1789474088.089709", None, adapter) + assert handoff == _organic_slack_reply_key("D0C1HFBMQAX", "1789474088.089709", "T0C2HL96FH6", "dm")