From b493bf63c7e327bed92a47937edd87855d7f52f7 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 27 Jul 2026 14:04:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(relay):=20rich=20Slack=20status-line=20par?= =?UTF-8?q?ity=20=E2=80=94=20advertise=20supports=5Fstatus=5Ftext,=20carry?= =?UTF-8?q?=20live=20per-tool=20phrase=20on=20typing=20frames=20(QA-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Slack shows dynamic assistant-status text ('Finding answers…', 'is running pytest…') because SlackAdapter sets supports_status_text=True and renders the set_status_text() phrase in send_typing. The relay lane advertised nothing, so run.py's live-status lane never fed it phrases and the connector fell back to the static default. - supports_status_text: descriptor-gated property (Slack only; other fronted platforms keep textless bubbles) - send_typing: carry the stashed phrase as the typing op's content; omit when unset (empty string is Slack's explicit clear, reserved for stop_typing). Connector already renders content via assistant.threads.setStatus (#154). --- gateway/relay/adapter.py | 40 ++++++++++++++++--- .../relay/test_relay_slack_prompt_dm_root.py | 38 ++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) 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)" + )