diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 7bf687cb49f..fff21bb6a94 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -133,6 +133,23 @@ class RelayAdapter(BasePlatformAdapter): def message_len_fn(self) -> Callable[[str], int]: return _LEN_FNS.get(self.descriptor.len_unit, len) + @property + def supports_status_text(self) -> bool: # type: ignore[override] + """Whether the fronted platform renders a TEXT status line. + + Native parity (QA-1 rich status): Slack's typing surface is the + assistant status line ("Finding answers…" next to the bot name), a + text-rendering indicator. When the relay fronts Slack, advertise it so + run.py's live-status lane feeds per-tool phrases via + ``set_status_text()`` — exactly the wiring the native SlackAdapter + gets (``supports_status_text = True``). Other fronted platforms keep + textless typing bubbles and must NOT receive phrase traffic. + + Property (not class attr) because ONE RelayAdapter class fronts many + platforms; the answer depends on the handshaked descriptor. + """ + return self.descriptor.platform == Platform.SLACK.value + def supports_draft_streaming( self, chat_type: Optional[str] = None, @@ -892,13 +909,26 @@ class RelayAdapter(BasePlatformAdapter): """ if self._transport is None: return + # Rich status parity (QA-1): run.py's live-status lane stashes the + # current per-tool phrase via set_status_text() (base class store). + # Carry it as the typing frame's content so the connector's Slack + # sender renders it on assistant.threads.setStatus — the same phrase + # the native adapter shows ("is running pytest…", "Finding answers…"). + # Absent (None/empty) => omit content; the connector falls back to its + # default "is typing…" heartbeat, preserving pre-phrase behaviour on + # every platform. Never send empty-string content here: on Slack that + # is the explicit CLEAR request reserved for stop_typing. + frame: Dict[str, Any] = { + "op": "typing", + "chat_id": chat_id, + "metadata": self._with_scope(chat_id, metadata), + } + phrase = getattr(self, "_status_text", {}).get(str(chat_id)) + if phrase: + frame["content"] = str(phrase) try: await self._transport.send_outbound( - { - "op": "typing", - "chat_id": chat_id, - "metadata": self._with_scope(chat_id, metadata), - }, + frame, platform=self._platform_by_chat.get(str(chat_id)), ) except Exception: # noqa: BLE001 - typing is cosmetic, never breaks a turn diff --git a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py index f08ba30dec8..5e48e972b8d 100644 --- a/tests/gateway/relay/test_relay_slack_prompt_dm_root.py +++ b/tests/gateway/relay/test_relay_slack_prompt_dm_root.py @@ -208,3 +208,41 @@ async def test_non_slack_dm_approval_keeps_thread_id(): await adapter.send_exec_approval("dc1", "cmd", "s", metadata=md) frame = _last_prompt(stub) assert frame["metadata"]["thread_id"] == "9000" + + +# --------------------------------------------------------------------------- +# QA-1 rich status: the relay advertises Slack's text status line and carries +# the live per-tool phrase on the typing frame (native set_status_text parity). +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_slack_relay_advertises_status_text(): + adapter, _stub = _wire("D1", "dm") + assert adapter.supports_status_text is True + + +@pytest.mark.asyncio +async def test_non_slack_relay_does_not_advertise_status_text(): + stub = StubConnector(_slack_desc(platform="discord")) + adapter = RelayAdapter( + PlatformConfig(), _slack_desc(platform="discord"), transport=stub + ) + assert adapter.supports_status_text is False + + +@pytest.mark.asyncio +async def test_typing_carries_live_status_phrase(): + """set_status_text() -> the next typing frame carries the phrase as + content; clearing it (None) reverts to a content-less heartbeat frame + (never an empty string, which is Slack's explicit clear).""" + adapter, stub = _wire("D1", "dm", scope_id="T1") + adapter.set_status_text("D1", "is running pytest…") + await adapter.send_typing("D1", metadata={"scope_id": "T1"}) + typing = [f for f in stub.sent if f["op"] == "typing"] + assert typing and typing[-1].get("content") == "is running pytest…" + + adapter.set_status_text("D1", None) + await adapter.send_typing("D1", metadata={"scope_id": "T1"}) + typing = [f for f in stub.sent if f["op"] == "typing"] + assert "content" not in typing[-1], ( + "cleared phrase must omit content (empty string means CLEAR on Slack)" + )