diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index be66459960..bdda993288 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -1089,6 +1089,43 @@ class RelayAdapter(BasePlatformAdapter): raw_response=result, ) + def _format_hints(self) -> Optional[Dict[str, bool]]: + """Block-formatting hints for outbound text frames, or None. + + Native Slack reads ``platforms.slack.extra.rich_blocks`` / + ``markdown_blocks`` and renders Block Kit locally; on the relay lane + the CONNECTOR owns the Slack API call, so the gateway can only signal + intent. Hints are stamped ONLY when (a) the connector advertises + ``supports_block_formatting`` in its descriptor — an old connector + never receives dead metadata — and (b) the operator enabled at least + one knob under the relay's per-platform sub-block + (``platforms.relay.extra.slack.rich_blocks`` / ``markdown_blocks``, + same seam and same _coerce_flag semantics as reply_in_thread). + Both knobs default OFF, matching native's opt-in posture. + """ + if not getattr(self.descriptor, "supports_block_formatting", False): + return None + try: + extra = self._relay_slack_extra() + except Exception: # noqa: BLE001 - config shape is operator-owned + return None + hints: Dict[str, bool] = {} + for knob in ("rich_blocks", "markdown_blocks"): + if self._coerce_flag(extra.get(knob), False): + hints[knob] = True + return hints or None + + def _with_format_hints( + self, metadata: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + """Return metadata with ``format_hints`` stamped when applicable.""" + hints = self._format_hints() + if not hints: + return metadata + merged = dict(metadata or {}) + merged.setdefault("format_hints", hints) + return merged + async def send( self, chat_id: str, @@ -1121,7 +1158,9 @@ class RelayAdapter(BasePlatformAdapter): "chat_id": chat_id, "content": content, "reply_to": effective_reply_to, - "metadata": self._with_scope(chat_id, send_metadata), + "metadata": self._with_scope( + chat_id, self._with_format_hints(send_metadata) + ), }, platform=self._platform_by_chat.get(str(chat_id)), ) @@ -1359,7 +1398,13 @@ class RelayAdapter(BasePlatformAdapter): "chat_id": chat_id, "message_id": message_id, "content": content, - "metadata": self._with_scope(chat_id, metadata), + # Same format_hints as send: a streamed reply's FINAL edit is + # the frame that carries the finished markdown, so the edit + # lane must signal block rendering too or streams would seal + # as plain text (boundary rule: every text egress lane). + "metadata": self._with_scope( + chat_id, self._with_format_hints(metadata) + ), }, platform=self._platform_by_chat.get(str(chat_id)), ) diff --git a/gateway/relay/descriptor.py b/gateway/relay/descriptor.py index 15a4f87037..2412ec9436 100644 --- a/gateway/relay/descriptor.py +++ b/gateway/relay/descriptor.py @@ -72,6 +72,15 @@ class CapabilityDescriptor: # that never sends this keeps today's thread behavior — additive within # contract_version 1. supports_inchannel_continuable: bool = False + # Whether the connector's platform sender can render block-level + # formatting from raw markdown (Slack: rich_text lists, Block Kit + # tables/markdown blocks). When True AND the operator enables the + # rich_blocks/markdown_blocks knobs, the gateway stamps ``format_hints`` + # into outbound send/edit metadata; the connector renders blocks and + # keeps the plain text as fallback. Default False — old connectors never + # receive hints, old gateways never send them. Additive within + # contract_version 1. + supports_block_formatting: bool = False # Op-level capability discovery (Phase 1 parity): the outbound op names the # connector's sender for this platform actually implements (e.g. # ["send", "edit", "typing", "follow_up", "get_chat_info"]). Empty tuple = diff --git a/tests/relay/test_relay_format_hints.py b/tests/relay/test_relay_format_hints.py new file mode 100644 index 0000000000..8cb898b336 --- /dev/null +++ b/tests/relay/test_relay_format_hints.py @@ -0,0 +1,150 @@ +"""Relay lane parity: block formatting hints on outbound frames (Coatue F2). + +Field report 2026-08-18, finding 2: identical agent output renders as native +rich_text lists / Block Kit tables / highlighted code on native Slack, but +literal `-` bullets and code-fence tables on the relay lane. Native reads +platforms.slack.extra.rich_blocks / markdown_blocks; relay frames carry no +formatting signal at all, and the connector has no way to know the operator +wants block rendering. + +Contract (additive, v1): the connector advertises +``supports_block_formatting`` in its capability descriptor; when the operator +enables the knobs (relay shape: platforms.relay.extra.slack.rich_blocks / +markdown_blocks — same sub-block as the other relay Slack knobs), the gateway +stamps ``format_hints`` into outbound send metadata. Old connectors never +advertise, so no hint is ever sent (no dead metadata); old gateways never +stamp, so connectors keep rendering plain text. +""" + +import json +from types import SimpleNamespace + +import pytest + +from gateway.config import PlatformConfig +from gateway.relay.adapter import RelayAdapter +from gateway.relay.descriptor import CapabilityDescriptor + + +def _descriptor(**overrides): + base = dict( + contract_version=1, + platform="slack", + label="Slack", + max_message_length=4000, + supports_draft_streaming=False, + supports_edit=True, + supports_threads=True, + markdown_dialect="mrkdwn", + len_unit="chars", + ) + base.update(overrides) + return CapabilityDescriptor(**base) + + +class FakeTransport: + def __init__(self): + self.frames = [] + + async def send_outbound(self, frame, platform=None): + self.frames.append((frame, platform)) + return {"success": True, "message_id": "1.2"} + + +def _adapter(extra=None, descriptor=None): + config = PlatformConfig(enabled=True, extra=extra or {}) + a = RelayAdapter(config, descriptor or _descriptor(), transport=FakeTransport()) + return a + + +class TestDescriptorBit: + def test_default_false(self): + assert _descriptor().supports_block_formatting is False + + def test_from_json_reads_flag(self): + payload = dict( + contract_version=1, platform="slack", label="Slack", + max_message_length=4000, supports_draft_streaming=False, + supports_edit=True, supports_threads=True, + markdown_dialect="mrkdwn", len_unit="chars", + supports_block_formatting=True, + ) + assert CapabilityDescriptor.from_json( + json.dumps(payload) + ).supports_block_formatting is True + + +class TestFormatHintsStamping: + @pytest.mark.asyncio + async def test_hints_stamped_when_capable_and_enabled(self): + a = _adapter( + extra={"slack": {"rich_blocks": True, "markdown_blocks": True}}, + descriptor=_descriptor(supports_block_formatting=True), + ) + await a.send("D01", "# Report\n\n| a | b |\n|---|---|\n| 1 | 2 |") + frame, _ = a._transport.frames[-1] + hints = (frame.get("metadata") or {}).get("format_hints") + assert hints == {"rich_blocks": True, "markdown_blocks": True} + + @pytest.mark.asyncio + async def test_no_hints_when_connector_lacks_capability(self): + """Old connector: knob on, capability absent -> no dead metadata.""" + a = _adapter( + extra={"slack": {"rich_blocks": True}}, + descriptor=_descriptor(), + ) + await a.send("D01", "text") + frame, _ = a._transport.frames[-1] + assert "format_hints" not in (frame.get("metadata") or {}) + + @pytest.mark.asyncio + async def test_no_hints_when_knobs_off(self): + """Capable connector, operator never opted in -> no hint (native + parity: rich_blocks/markdown_blocks are opt-in on native too).""" + a = _adapter( + extra={}, + descriptor=_descriptor(supports_block_formatting=True), + ) + await a.send("D01", "text") + frame, _ = a._transport.frames[-1] + assert "format_hints" not in (frame.get("metadata") or {}) + + @pytest.mark.asyncio + async def test_quoted_false_knob_stays_off(self): + """YAML-quoted 'false' must coerce off — same _coerce_flag semantics + as the other relay Slack knobs.""" + a = _adapter( + extra={"slack": {"rich_blocks": "false", "markdown_blocks": "false"}}, + descriptor=_descriptor(supports_block_formatting=True), + ) + await a.send("D01", "text") + frame, _ = a._transport.frames[-1] + assert "format_hints" not in (frame.get("metadata") or {}) + + @pytest.mark.asyncio + async def test_partial_knobs_stamp_only_enabled(self): + a = _adapter( + extra={"slack": {"markdown_blocks": True}}, + descriptor=_descriptor(supports_block_formatting=True), + ) + await a.send("D01", "text") + frame, _ = a._transport.frames[-1] + hints = (frame.get("metadata") or {}).get("format_hints") + assert hints == {"markdown_blocks": True} + + @pytest.mark.asyncio + async def test_edit_lane_carries_hints_too(self): + """Boundary rule: every text egress lane crossing the frame contract + gets the hint — send AND edit (streaming final edits render blocks + on native).""" + a = _adapter( + extra={"slack": {"rich_blocks": True}}, + descriptor=_descriptor(supports_block_formatting=True), + ) + edit = getattr(a, "edit_message", None) + if edit is None: + pytest.skip("relay adapter has no edit lane") + await edit("D01", "1.2", "updated **content**") + frame, _ = a._transport.frames[-1] + hints = (frame.get("metadata") or {}).get("format_hints") + assert hints == {"rich_blocks": True}