"""Relay-side Slack unfurl suppression: gateway-directed metadata stamping. The gateway resolves ``platforms.relay.extra.slack.unfurl_links`` / ``unfurl_media`` and stamps them onto the outbound frame metadata; the connector just forwards whatever the gateway resolved (no connector config). Contract under test: - Slack chats: explicit booleans are stamped; omitted keys are absent. - Non-Slack chats: never stamped (metadata not polluted cross-platform). - Non-boolean values (hostile/hand-edited config) are dropped. - The scheduled/cron lane (send_for_platform) stamps too. """ import pytest from gateway.config import Platform, PlatformConfig from gateway.relay.adapter import RelayAdapter from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor def make_desc(**kw) -> CapabilityDescriptor: base = dict( contract_version=CONTRACT_VERSION, platform="slack", label="Slack", max_message_length=39000, supports_draft_streaming=False, supports_edit=True, supports_threads=True, markdown_dialect="slack", len_unit="char", emoji="\U0001f4bc", platform_hint="", pii_safe=False, ) base.update(kw) return CapabilityDescriptor(**base) class _CaptureTransport: def __init__(self): self.sent = None self.sent_platform = None # Advertise a slack identity so fronts_platform() passes for the # send_for_platform (cron) lane. self._identities = [("slack", None)] def set_inbound_handler(self, h): # noqa: D401 self._h = h async def send_outbound(self, action, *, platform=None): self.sent = action self.sent_platform = platform return {"success": True, "message_id": "m1"} def _slack_adapter(extra): a = RelayAdapter( PlatformConfig(extra=extra), make_desc(platform="slack"), transport=_CaptureTransport() ) return a def _mark_slack_chat(a, chat_id="chan-1"): from gateway.platforms.base import MessageEvent, MessageType from gateway.session import SessionSource src = SessionSource( platform=Platform.SLACK, chat_id=chat_id, chat_type="channel", scope_id="w-1" ) ev = MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) a._capture_scope(ev) class TestUnfurlHints: def test_non_slack_returns_none(self): a = _slack_adapter({"slack": {"unfurl_links": False}}) assert a._slack_unfurl_hints("discord") is None assert a._slack_unfurl_hints(None) is None def test_slack_explicit_bools_returned(self): a = _slack_adapter({"slack": {"unfurl_links": False, "unfurl_media": False}}) assert a._slack_unfurl_hints("slack") == { "unfurl_links": False, "unfurl_media": False, } def test_omitted_keys_return_none(self): a = _slack_adapter({"slack": {}}) assert a._slack_unfurl_hints("slack") is None def test_string_bools_from_config_set_are_coerced(self): # Railway knobs / `hermes config set` persist YAML strings. a = _slack_adapter({"slack": {"unfurl_links": "true", "unfurl_media": "false"}}) assert a._slack_unfurl_hints("slack") == { "unfurl_links": True, "unfurl_media": False, } def test_junk_values_dropped(self): a = _slack_adapter({"slack": {"unfurl_links": "maybe", "unfurl_media": 0}}) assert a._slack_unfurl_hints("slack") is None def test_flat_legacy_key_fallback(self): # _relay_slack_extra falls back to the flat extra when no "slack" # object exists (legacy staging configs). a = _slack_adapter({"unfurl_links": False}) assert a._slack_unfurl_hints("slack") == {"unfurl_links": False} class TestSendStampsUnfurl: @pytest.mark.asyncio async def test_send_stamps_explicit_bools(self): a = _slack_adapter({"slack": {"unfurl_links": False, "unfurl_media": False}}) _mark_slack_chat(a) await a.send("chan-1", "see https://example.com") assert a._transport.sent["metadata"]["unfurl_links"] is False assert a._transport.sent["metadata"]["unfurl_media"] is False @pytest.mark.asyncio async def test_send_omits_when_unconfigured(self): a = _slack_adapter({"slack": {}}) _mark_slack_chat(a) await a.send("chan-1", "plain text") assert "unfurl_links" not in a._transport.sent["metadata"] assert "unfurl_media" not in a._transport.sent["metadata"] @pytest.mark.asyncio async def test_non_slack_chat_never_stamped(self): a = _slack_adapter({"slack": {"unfurl_links": False}}) # A chat mapped to discord, not slack, must not carry the hint. from gateway.platforms.base import MessageEvent, MessageType from gateway.session import SessionSource src = SessionSource( platform=Platform.DISCORD, chat_id="chan-1", chat_type="channel", scope_id="w-1", ) a._capture_scope( MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) ) await a.send("chan-1", "see https://example.com") assert "unfurl_links" not in a._transport.sent["metadata"] @pytest.mark.asyncio async def test_send_falls_back_to_descriptor_platform(self): """No inbound frame yet (e.g. gateway restart): _platform_by_chat is empty, so the platform must resolve from the negotiated descriptor — the same fallback the streaming gate and delivery resolver use.""" a = _slack_adapter({"slack": {"unfurl_links": False}}) assert not a._platform_by_chat await a.send("chan-1", "see https://example.com") assert a._transport.sent["metadata"]["unfurl_links"] is False class TestMediaLaneStampsUnfurl: """The send_media lane egresses through the connector's Slack sender too, so it must stamp the same unfurl hints as the text lane.""" def _media_adapter(self, extra): a = RelayAdapter( PlatformConfig(extra=extra), make_desc(platform="slack", supported_ops=("send", "send_media")), transport=_CaptureTransport(), ) return a @pytest.mark.asyncio async def test_media_lane_stamps_explicit_bools(self): a = self._media_adapter({"slack": {"unfurl_links": False, "unfurl_media": False}}) _mark_slack_chat(a) res = await a.send_image("chan-1", "https://img.example/x.png", caption="cap") assert res.success is True assert a._transport.sent["op"] == "send_media" assert a._transport.sent["metadata"]["unfurl_links"] is False assert a._transport.sent["metadata"]["unfurl_media"] is False @pytest.mark.asyncio async def test_media_lane_falls_back_to_descriptor_platform(self): """Regression: _send_media resolved platform only from _platform_by_chat; after a gateway restart a proactive media send to a Slack chat missed the stamp. Must fall back to descriptor.platform.""" a = self._media_adapter({"slack": {"unfurl_links": False}}) assert not a._platform_by_chat res = await a.send_image("chan-1", "https://img.example/x.png") assert res.success is True assert a._transport.sent["op"] == "send_media" assert a._transport.sent["metadata"]["unfurl_links"] is False @pytest.mark.asyncio async def test_media_lane_omits_when_unconfigured(self): a = self._media_adapter({"slack": {}}) _mark_slack_chat(a) await a.send_image("chan-1", "https://img.example/x.png") assert a._transport.sent["op"] == "send_media" assert "unfurl_links" not in a._transport.sent["metadata"] assert "unfurl_media" not in a._transport.sent["metadata"] class TestSendForPlatformStampsUnfurl: @pytest.mark.asyncio async def test_cron_lane_stamps_explicit_bools(self): a = _slack_adapter({"slack": {"unfurl_links": False, "unfurl_media": False}}) from gateway.config import Platform as P res = await a.send_for_platform(P.SLACK, "C123", "brief https://x.dev") assert res.success is True assert a._transport.sent["metadata"]["unfurl_links"] is False assert a._transport.sent["metadata"]["unfurl_media"] is False @pytest.mark.asyncio async def test_cron_lane_omits_when_unconfigured(self): a = _slack_adapter({"slack": {}}) from gateway.config import Platform as P await a.send_for_platform(P.SLACK, "C123", "brief") assert "unfurl_links" not in a._transport.sent["metadata"] assert "unfurl_media" not in a._transport.sent["metadata"] class TestUnfurlDisablesDraftStreaming: def test_explicit_unfurl_disables_slack_draft_stream(self): a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": True}}), make_desc( platform="slack", supports_draft_streaming=True, supported_ops=("send", "draft"), ), transport=_CaptureTransport(), ) assert a.supports_draft_streaming() is False def test_omitted_unfurl_keeps_slack_draft_stream(self): a = RelayAdapter( PlatformConfig(extra={"slack": {}}), make_desc( platform="slack", supports_draft_streaming=True, supported_ops=("send", "draft"), ), transport=_CaptureTransport(), ) assert a.supports_draft_streaming() is True def test_string_true_also_disables_stream(self): a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": "true"}}), make_desc( platform="slack", supports_draft_streaming=True, supported_ops=("send", "draft"), ), transport=_CaptureTransport(), ) assert a.supports_draft_streaming() is False class TestFreshFinalForForceOnUnfurl: """Slack evaluates unfurls ONLY at chat.postMessage (live-probed 2026-08-28: an edit that introduces a URL never previews, stamped or not). Force-on hints must therefore route streamed finals through the consumer's fresh-final path; false-only hints must NOT (suppression rides the placeholder post and inherits through edits).""" def _adapter(self, slack_extra): return RelayAdapter( PlatformConfig(extra={"slack": slack_extra}), make_desc( platform="slack", supports_draft_streaming=True, supported_ops=("send", "draft"), ), transport=_CaptureTransport(), ) def test_force_on_with_link_prefers_fresh_final(self): a = self._adapter({"unfurl_links": True, "unfurl_media": True}) assert ( a.prefers_fresh_final_streaming("see https://studiotwin.ai") is True ) def test_force_on_string_true_prefers_fresh_final(self): # hermes config set / Railway knobs persist YAML strings. a = self._adapter({"unfurl_links": "true"}) assert a.prefers_fresh_final_streaming("see https://x.dev") is True def test_force_on_mrkdwn_link_prefers_fresh_final(self): a = self._adapter({"unfurl_links": True}) assert ( a.prefers_fresh_final_streaming("see ") is True ) def test_force_on_without_link_keeps_edit_lane(self): # No URL => nothing to unfurl; a fresh final would only duplicate # the preview (relay contract v1 has no delete op). a = self._adapter({"unfurl_links": True}) assert a.prefers_fresh_final_streaming("plain text answer") is False def test_false_only_hints_keep_edit_lane(self): # Enterprise fail-closed posture: suppression is decided at the # placeholder post; the edit lane preserves it. No UX change. a = self._adapter({"unfurl_links": False, "unfurl_media": False}) assert ( a.prefers_fresh_final_streaming("see https://studiotwin.ai") is False ) def test_unconfigured_keeps_edit_lane(self): a = self._adapter({}) assert ( a.prefers_fresh_final_streaming("see https://studiotwin.ai") is False ) def test_non_slack_platform_keeps_edit_lane(self): a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": True}}), make_desc( platform="telegram", supports_draft_streaming=True, supported_ops=("send", "draft"), ), transport=_CaptureTransport(), ) assert ( a.prefers_fresh_final_streaming("see https://studiotwin.ai") is False ) class _RecordingTransport(_CaptureTransport): """Capture EVERY outbound action in order, not just the last.""" def __init__(self): super().__init__() self.actions = [] async def send_outbound(self, action, *, platform=None): self.actions.append(action) self.sent = action self.sent_platform = platform return {"success": True, "message_id": f"m{len(self.actions)}"} class TestConsumerRoutesForceOnFinalAsFreshSend: """End-to-end consumer contract (the lane the live regression hid in): an edit-streamed turn whose URL only exists in the FINAL text must finalize via a fresh `send` op carrying the unfurl stamps — not via an `edit` op, which Slack never re-evaluates for previews.""" @pytest.mark.asyncio async def test_url_arriving_late_finalizes_as_stamped_fresh_send(self): from gateway.stream_consumer import ( GatewayStreamConsumer, StreamConsumerConfig, ) transport = _RecordingTransport() a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": True, "unfurl_media": True}}), make_desc(platform="slack", supports_edit=True), transport=transport, ) consumer = GatewayStreamConsumer( adapter=a, chat_id="D1", config=StreamConsumerConfig(), ) # Frame 1: placeholder posts WITHOUT any URL (the task-card / # early-frame shape from the live regression). await consumer._send_or_edit("Working on it…") # Final: the URL exists only now. await consumer._send_or_edit( "see https://studiotwin.ai", finalize=True ) ops = [x.get("op") for x in transport.actions] # Placeholder went out as a send; the FINAL must be a fresh send # too (not an edit) so Slack evaluates the preview with the URL # present. assert ops[0] == "send" assert ops[-1] == "send", f"final left as {ops[-1]!r}; ops={ops}" final = transport.actions[-1] assert final["content"] == "see https://studiotwin.ai" assert final["metadata"]["unfurl_links"] is True assert final["metadata"]["unfurl_media"] is True @pytest.mark.asyncio async def test_false_only_hints_finalize_via_edit_unchanged(self): from gateway.stream_consumer import ( GatewayStreamConsumer, StreamConsumerConfig, ) transport = _RecordingTransport() a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": False, "unfurl_media": False}}), make_desc(platform="slack", supports_edit=True), transport=transport, ) consumer = GatewayStreamConsumer( adapter=a, chat_id="D1", config=StreamConsumerConfig(), ) await consumer._send_or_edit("Working on it…") await consumer._send_or_edit("see https://studiotwin.ai", finalize=True) ops = [x.get("op") for x in transport.actions] # Fail-closed posture keeps the edit lane: placeholder send (which # carries the false stamps at post time) + finalize edit. assert ops[0] == "send" assert transport.actions[0]["metadata"]["unfurl_links"] is False assert ops[-1] == "edit", f"ops={ops}" class TestMultiplexPerChatFreshFinalSeam: """The consumer must resolve the fresh-final decision through the CHAT's negotiated platform, not the relay's primary identity (the scalar-vs- per-chat descriptor seam). Two failure directions: - Slack PRIMARY + force-on unfurl: a fronted TELEGRAM chat must keep the edit lane — its descriptor advertises no ``delete`` op, so a fresh final would deliver the answer twice (orphaned preview). - Non-Slack primary: a fronted SLACK chat must still get the fresh stamped final, or the shipped feature is dark on exactly the chats it was built for. """ class _MultiplexTransport(_RecordingTransport): def __init__(self): super().__init__() self.descriptors = { "telegram": make_desc( platform="telegram", supports_edit=True, supported_ops=("send", "edit", "typing"), ), "slack": make_desc( platform="slack", supports_edit=True, supported_ops=("send", "edit", "typing", "delete"), ), } def descriptor_for_platform(self, platform): return self.descriptors.get(platform) def _consumer(self, adapter, chat_id): from gateway.stream_consumer import ( GatewayStreamConsumer, StreamConsumerConfig, ) return GatewayStreamConsumer( adapter=adapter, chat_id=chat_id, config=StreamConsumerConfig(), ) @pytest.mark.asyncio async def test_telegram_chat_on_slack_primary_keeps_edit_lane(self): transport = self._MultiplexTransport() a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": True}}), make_desc(platform="slack", supports_edit=True), # Slack PRIMARY transport=transport, ) # Relay learned this chat is Telegram from inbound traffic. a._platform_by_chat["TG1"] = "telegram" consumer = self._consumer(a, "TG1") await consumer._send_or_edit("Working on it…") await consumer._send_or_edit("see https://studiotwin.ai", finalize=True) ops = [x.get("op") for x in transport.actions] assert ops[-1] == "edit", ( f"telegram chat misrouted through fresh-final: ops={ops}" ) @pytest.mark.asyncio async def test_slack_chat_on_telegram_primary_gets_fresh_final(self): transport = self._MultiplexTransport() a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": True}}), make_desc(platform="telegram", supports_edit=True), # non-Slack primary transport=transport, ) a._platform_by_chat["D1"] = "slack" consumer = self._consumer(a, "D1") await consumer._send_or_edit("Working on it…") await consumer._send_or_edit("see https://studiotwin.ai", finalize=True) ops = [x.get("op") for x in transport.actions] # Fresh stamped final followed by cleanup of the sealed preview # (Slack's descriptor advertises delete) — no duplicate. assert "delete" in ops, f"preview not cleaned up: ops={ops}" sends = [x for x in transport.actions if x.get("op") == "send"] assert len(sends) == 2, f"slack chat on non-slack primary left dark: ops={ops}" final = sends[-1] assert final["content"] == "see https://studiotwin.ai" assert final["metadata"]["unfurl_links"] is True class TestDeleteOpForFreshFinalCleanup: """Relay delete_message: emitted only when the negotiated descriptor advertises the additive `delete` op; older connectors degrade to the leave-the-preview-behind behavior (return False, no wire traffic).""" @pytest.mark.asyncio async def test_delete_emitted_when_advertised(self): transport = _RecordingTransport() a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": True}}), make_desc( platform="slack", supported_ops=("send", "edit", "delete"), ), transport=transport, ) ok = await a.delete_message("D1", "1700000000.000200") assert ok is True assert transport.actions[-1]["op"] == "delete" assert transport.actions[-1]["message_id"] == "1700000000.000200" @pytest.mark.asyncio async def test_delete_refused_when_not_advertised(self): transport = _RecordingTransport() a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": True}}), make_desc(platform="slack", supported_ops=("send", "edit")), transport=transport, ) ok = await a.delete_message("D1", "1700000000.000200") assert ok is False assert transport.actions == [] # no wire traffic for old connectors @pytest.mark.asyncio async def test_consumer_fresh_final_deletes_preview_when_supported(self): from gateway.stream_consumer import ( GatewayStreamConsumer, StreamConsumerConfig, ) transport = _RecordingTransport() a = RelayAdapter( PlatformConfig(extra={"slack": {"unfurl_links": True, "unfurl_media": True}}), make_desc( platform="slack", supports_edit=True, supported_ops=("send", "edit", "delete"), ), transport=transport, ) consumer = GatewayStreamConsumer( adapter=a, chat_id="D1", config=StreamConsumerConfig() ) await consumer._send_or_edit("Working on it…") await consumer._send_or_edit("see https://studiotwin.ai", finalize=True) ops = [x.get("op") for x in transport.actions] # send (placeholder) ... send (fresh final) ... delete (preview) assert ops[-1] == "delete", f"ops={ops}" deleted = transport.actions[-1]["message_id"] # The deleted message must be the FIRST send's id (m1), not the final. assert deleted == "m1"