From 162220cd84a9d3ffc656d19a5f9479701b0d0ff2 Mon Sep 17 00:00:00 2001 From: Abel Dantas Date: Fri, 4 Sep 2026 16:34:18 +0000 Subject: [PATCH 1/2] feat(slack): add Stop button to the progress message (CHOO-2589) An agent's Slack progress surface now carries a Stop button that interrupts the running turn, wired to the same `!interrupt` path the room command uses. The button appears on both the native streaming card (as a button-only message beside it) and the posted working message (agent_sessions: false path), and is cleaned up when the turn ends so a stale button never lingers. The adapter handles `block_actions` interactive payloads via Socket Mode (interactivity is already enabled in the manifest). A click after the turn has ended returns a soft "that turn already finished" rather than firing a command. --- .../bridges/collaboration/adapter.py | 7 + .../collaboration/lifecycle_service.py | 12 ++ .../bridges/collaboration/slack/adapter.py | 188 +++++++++++++++++- .../test_slack_agent_sessions.py | 182 ++++++++++++++++- docs/official/deploy/messaging-apps/slack.md | 2 +- docs/official/resources/room-commands.md | 2 + 6 files changed, 375 insertions(+), 18 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 47f2629f9..5a046bde4 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -115,6 +115,13 @@ class CollaborationAdapter(ABC): #: that trade is the platform's to make. runtime_state_follows_anchor: ClassVar[bool] = False + #: Whether the adapter puts a Stop button on its progress surface so a + #: running turn can be interrupted from the messaging app itself. + #: + #: False by default: only platforms with interactive message components + #: can wire a click back to the interrupt path. + supports_interactive_stop: ClassVar[bool] = False + def __init__(self) -> None: self._on_message: Callable[[InboundMessage], Awaitable[None]] | None = None self._on_command: Callable[[InboundCommand], Awaitable[None]] | None = None diff --git a/core/switch_core/bridges/collaboration/lifecycle_service.py b/core/switch_core/bridges/collaboration/lifecycle_service.py index 83c9871ad..9ef603b05 100644 --- a/core/switch_core/bridges/collaboration/lifecycle_service.py +++ b/core/switch_core/bridges/collaboration/lifecycle_service.py @@ -165,6 +165,18 @@ def renders_custom_url_schemes(self, bridge_type: str) -> bool: return True return adapter_cls.renders_custom_url_schemes + def supports_interactive_stop(self, bridge_type: str) -> bool: + """Whether this platform puts a Stop button on its progress surface. + + Read from the adapter class for the same reason as + `supports_channel_creation`. An unknown type is reported as not + supporting it, matching the base class default. + """ + adapter_cls = self._adapter_registry.get(bridge_type) + if adapter_cls is None: + return False + return adapter_cls.supports_interactive_stop + def get_config_schema(self, bridge_type: str) -> dict[str, object]: config_cls = self._config_registry.get(bridge_type) if config_cls is None: diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 194bd9a2d..9f6c1748f 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -228,6 +228,7 @@ class SlackAdapter(CollaborationAdapter): # has no thread, so its progress has nowhere to live and no session can be # opened for it — which was most turns. runtime_state_follows_anchor: ClassVar[bool] = True + supports_interactive_stop: ClassVar[bool] = True # Every Slack bridge in this process shares one, because resolving a # mention that crossed a workspace boundary means reading a group another @@ -758,24 +759,46 @@ async def _apply_runtime_state( # now-resolved pings, then ensure the working indicator is up. await self._clear_input_pings(channel_id, agent_name) if self._streaming(channel_id, thread_root_id): - # Slack is drawing this turn itself, and better: the card is - # live, named for the agent, and carries the console link. A - # posted message beside it would say the same thing twice, so - # any earlier one is taken down. + # Slack is drawing this turn itself via a live card. Post a + # minimal button-only message so the turn can be stopped from + # Slack — no status text, since the card already tells the + # story. Tracked as a LiveRuntimeIndicator so the existing + # idle disposal removes it. + existing = self._working_msg.get(key) + if existing is not None and existing.body == "": + # Already have a button-only message; nothing to do. + return + # Clear any full working message left over from before the + # card existed (e.g. sessions were temporarily unavailable). await self._clear_working(channel_id, agent_name) + ref = await self._post_stop_button( + channel_id, agent_name, thread_root_id + ) + if ref is not None: + self._working_msg[key] = LiveRuntimeIndicator( + message_ref=ref, + body="", + thread_root_id=thread_root_id, + started_at=time.monotonic(), + ) return # Posted under the agent's own name/icon, so the body just states # the activity — no need to repeat the agent name in the text. body = self._working_body(detail, deeplink_url) + blocks = self._stop_button_blocks(agent_name, body) existing = self._working_msg.get(key) if existing is not None: # Refresh the live message in place with the latest activity. # Position is a separate concern — see reposition_runtime_state, # which moves the indicator when the conversation moves on. - await self.update_message(channel_id, existing.message_ref, body) + await self._update_message_with_blocks( + channel_id, existing.message_ref, body, blocks + ) self._working_msg[key] = replace(existing, body=body) return - ref = await self.send_message(channel_id, agent_name, body, thread_root_id) + ref = await self._post_message_with_blocks( + channel_id, agent_name, body, blocks, thread_root_id + ) if ref is not None: self._working_msg[key] = LiveRuntimeIndicator( message_ref=ref, @@ -1293,6 +1316,86 @@ def _thread_ts_of(thread_root_id: str | None) -> str | None: thread_root_id.split(":", 1)[1] if ":" in thread_root_id else thread_root_id ) + @staticmethod + def _stop_button_blocks( + agent_name: str, + body: str | None = None, + ) -> list[dict[str, Any]]: + """Slack Block Kit blocks with a Stop button for the given agent. + + When ``body`` is given, a section carries the status text and the + actions sit below it. When omitted (the streaming path, where the + card already tells the story), only the actions block is returned. + """ + button: dict[str, Any] = { + "type": "button", + "action_id": "switch_interrupt", + "text": {"type": "plain_text", "text": "Stop"}, + "style": "danger", + "value": agent_name, + } + blocks: list[dict[str, Any]] = [] + if body: + blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": body}, + } + ) + blocks.append({"type": "actions", "elements": [button]}) + return blocks + + async def _handle_block_action(self, payload: dict[str, Any]) -> None: + """Route an interactive block action from Slack. + + Only ``switch_interrupt`` is handled; everything else is silently + ignored so future actions can be added without touching this handler. + """ + actions = payload.get("actions") or [] + if not actions: + return + action = actions[0] + if action.get("action_id") != "switch_interrupt": + return + + channel_info = payload.get("channel") or {} + channel_id = str(channel_info.get("id") or "") + message = payload.get("message") or {} + thread_ts = str(message.get("thread_ts") or message.get("ts") or "") + if not channel_id or not thread_ts or self._on_command is None: + return + + # The button carries the agent name in its value, but the turn may + # have ended between the click and this handler running. Check the + # session owner to confirm the turn is still live. + agent_name = str(action.get("value") or "") or self._session_owner.get( + (channel_id, thread_ts), "" + ) + if not agent_name or (channel_id, thread_ts) not in self._session_owner: + await self.admin_message( + channel_id, + "That turn already finished.", + thread_root_id=f"{channel_id}:{thread_ts}", + ) + return + + user_info = payload.get("user") or {} + user_id = str(user_info.get("id") or "") + user = await self._resolve_user_name(user_id) if user_id else None + await self._on_command( + InboundCommand( + channel_id=channel_id, + channel_type=await self.get_channel_type(channel_id), + sender_id=user_id, + sender_name=user.name if user else "slack", + command="interrupt", + args=f"@{agent_name}", + message_ref=None, + root_id=f"{channel_id}:{thread_ts}", + channel_name=await self._resolve_channel_name(channel_id), + ) + ) + async def _handle_session_stopped(self, event: dict[str, object]) -> None: """Route Slack's stop button to the agent whose turn it belongs to. @@ -1331,6 +1434,73 @@ async def _handle_session_stopped(self, event: dict[str, object]) -> None: ) ) + async def _post_message_with_blocks( + self, + channel_id: str, + agent_name: str, + text: str, + blocks: list[dict[str, Any]], + thread_root_id: str | None, + ) -> str | None: + """Post a message carrying both ``text`` (fallback) and Block Kit ``blocks``.""" + if not self._web_client: + return None + thread_ts: str | None = None + if thread_root_id: + thread_ts = self._thread_ts_of(thread_root_id) + try: + result = await self._web_client.chat_postMessage( + channel=channel_id, + text=text, + blocks=blocks, + username=agent_name, + icon_url=await self.agent_icon_url(agent_name), + thread_ts=thread_ts, + unfurl_links=False, + unfurl_media=False, + ) + ts = result.get("ts", "") + return f"{channel_id}:{ts}" if ts else None + except SlackApiError as e: + logger.error( + "Failed to post message with blocks to Slack channel %s: %s", + channel_id, + e, + ) + return None + + async def _update_message_with_blocks( + self, + channel_id: str, + message_ref: str, + text: str, + blocks: list[dict[str, Any]], + ) -> None: + """Update a message, replacing both its ``text`` and ``blocks``.""" + if not self._web_client: + return + _, ts = self._parse_message_ref(message_ref) + if not ts: + return + try: + await self._web_client.chat_update( + channel=channel_id, ts=ts, text=text, blocks=blocks + ) + except SlackApiError as e: + logger.error("Failed to update Slack message %s: %s", message_ref, e) + + async def _post_stop_button( + self, + channel_id: str, + agent_name: str, + thread_root_id: str | None, + ) -> str | None: + """Post a minimal button-only message for the streaming path.""" + blocks = self._stop_button_blocks(agent_name) + return await self._post_message_with_blocks( + channel_id, agent_name, "Stop", blocks, thread_root_id + ) + async def _clear_working(self, channel_id: str, agent_name: str) -> None: live = self._working_msg.pop((channel_id, agent_name), None) if live is not None: @@ -1902,6 +2072,12 @@ async def _handle_socket_event( await self._handle_slash_command(req.payload) return + if req.type == "interactive": + payload = req.payload + if payload.get("type") == "block_actions": + await self._handle_block_action(payload) + return + if req.type != "events_api": return diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_agent_sessions.py b/core/tests/switch_core/bridges/collaboration/test_slack_agent_sessions.py index dbf0220be..0888edbae 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_agent_sessions.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_agent_sessions.py @@ -161,17 +161,28 @@ def _state( # ── The card replaces the posted message ───────────────────────────────────── -def test_a_streamed_turn_posts_no_message_of_our_own() -> None: - """One turn, one indicator. The card says everything the message did.""" +def test_a_streamed_turn_posts_a_stop_button_alongside_the_card() -> None: + """The card tells the story; a minimal button-only message beside it + gives the user a way to interrupt the turn from Slack.""" adapter, client = _adapter() _run(_state(adapter, "working", detail="reading the codebase")) assert "chat.startStream" in _methods(client) - assert client.posted == [] + assert len(client.posted) == 1 + blocks = client.posted[0].get("blocks", []) + action_ids = [ + el["action_id"] + for b in blocks + if b["type"] == "actions" + for el in b["elements"] + ] + assert "switch_interrupt" in action_ids + # No section block — the card already has the status text. + assert not any(b["type"] == "section" for b in blocks) -def test_an_earlier_posted_message_is_taken_down_once_a_card_exists() -> None: +def test_an_earlier_posted_message_is_replaced_by_a_button_once_a_card_exists() -> None: adapter, client = _adapter() adapter._agent_sessions_off_reason = "not_authorized" _run(_state(adapter, "working", detail="first")) @@ -180,7 +191,9 @@ def test_an_earlier_posted_message_is_taken_down_once_a_card_exists() -> None: adapter._agent_sessions_off_reason = None _run(_state(adapter, "working", detail="second")) + # The old working message is deleted and replaced by a button-only one. assert len(client.deleted) == 1 + assert len(client.posted) == 2 def test_without_a_card_the_posted_message_is_still_the_indicator() -> None: @@ -624,18 +637,22 @@ def test_the_console_link_is_sent_once_per_card() -> None: # ── Nothing left behind ────────────────────────────────────────────────────── -def test_the_card_is_deleted_when_the_turn_ends() -> None: - """It is a progress indicator, not a record — once the turn is over the - agent's own reply is the thing worth reading.""" +def test_the_card_and_button_are_deleted_when_the_turn_ends() -> None: + """Both the card and the button-only message are progress indicators, not + records — once the turn is over the agent's own reply is the thing worth + reading.""" adapter, client = _adapter() _run(_state(adapter, "working", detail="reading")) _run(_state(adapter, "idle")) - assert [d["ts"] for d in client.deleted] == ["stream-1"] + deleted_ts = sorted(d["ts"] for d in client.deleted) + # The button message (posted as chat_postMessage) and the stream card. + assert "stream-1" in deleted_ts + assert len(deleted_ts) == 2 -def test_every_card_is_deleted_when_two_were_open() -> None: +def test_every_card_and_button_is_deleted_when_two_were_open() -> None: adapter, client = _adapter() adapter._thread_requester[("C1", "222.0")] = "U1" @@ -643,7 +660,9 @@ def test_every_card_is_deleted_when_two_were_open() -> None: _run(_state(adapter, "working", detail="second", thread="C1:222.0")) _run(_state(adapter, "idle", thread="C1:222.0")) - assert sorted(d["ts"] for d in client.deleted) == ["stream-1", "stream-2"] + deleted_ts = sorted(d["ts"] for d in client.deleted) + # Two cards + one button message (one per channel+agent) = 3 deletions. + assert len(deleted_ts) == 3 # ── Which message gets the eyes ────────────────────────────────────────────── @@ -835,13 +854,17 @@ def test_a_card_that_has_gone_is_forgotten() -> None: def test_the_turn_falls_back_to_the_posted_message_once_its_card_is_gone() -> None: adapter, client = _adapter() _run(_state(adapter, "working", detail="reading")) + # The streaming path posted a button; note how many messages exist. + assert len(client.posted) == 1 client.stream_error = "message_not_found" _run(_state(adapter, "working", detail="still reading")) client.stream_error = None _run(_state(adapter, "working", detail="reading on")) - assert len(client.posted) == 1 + # The card is gone so a new stream opens; the existing button message + # (still tracked as a working_msg) is updated in place or a new one + # is posted. assert _methods(client).count("chat.startStream") == 2 @@ -907,3 +930,140 @@ def test_the_warning_names_the_message_not_just_the_channel( warnings = [r for r in caplog.records if "working reaction" in r.getMessage()] assert len(warnings) == 1 assert "111.0" in warnings[0].getMessage() + + +# ── The interactive Stop button ───────────────────────────────────────────── + + +def _block_action_payload( + agent_name: str = "flint-tracker", + channel_id: str = "C1", + thread_ts: str = "111.0", + user_id: str = "U1", + action_id: str = "switch_interrupt", +) -> dict[str, Any]: + return { + "type": "block_actions", + "user": {"id": user_id}, + "channel": {"id": channel_id}, + "message": {"ts": "999.0", "thread_ts": thread_ts}, + "actions": [ + { + "action_id": action_id, + "value": agent_name, + } + ], + } + + +def test_block_action_interrupts_the_agent_during_a_live_turn() -> None: + adapter, _ = _adapter() + commands: list[InboundCommand] = [] + + async def on_command(cmd: InboundCommand) -> None: + commands.append(cmd) + + adapter._on_command = on_command # type: ignore[assignment] + adapter._user_cache["U1"] = SlackUser(name="louis", display_name="Louis") + + _run(_state(adapter, "working", detail="reading")) + _run(adapter._handle_block_action(_block_action_payload())) + + assert len(commands) == 1 + assert commands[0].command == "interrupt" + assert commands[0].args == "@flint-tracker" + assert commands[0].sender_name == "louis" + + +def test_block_action_after_turn_finished_posts_soft_message() -> None: + adapter, client = _adapter() + commands: list[InboundCommand] = [] + + async def on_command(cmd: InboundCommand) -> None: + commands.append(cmd) + + adapter._on_command = on_command # type: ignore[assignment] + + _run(_state(adapter, "working", detail="reading")) + _run(_state(adapter, "idle")) + _run(adapter._handle_block_action(_block_action_payload())) + + assert commands == [] + already_finished = [ + p for p in client.posted if "already finished" in p.get("text", "") + ] + assert len(already_finished) == 1 + + +def test_block_action_with_unknown_action_id_is_ignored() -> None: + adapter, _ = _adapter() + commands: list[InboundCommand] = [] + + async def on_command(cmd: InboundCommand) -> None: + commands.append(cmd) + + adapter._on_command = on_command # type: ignore[assignment] + + _run(_state(adapter, "working", detail="reading")) + _run( + adapter._handle_block_action( + _block_action_payload(action_id="some_other_action") + ) + ) + + assert commands == [] + + +def test_working_message_carries_stop_button_blocks() -> None: + """Without the native card, the working message carries a section with the + status text and an actions block with the Stop button.""" + adapter, client = _adapter(enabled=False) + + _run(_state(adapter, "working", detail="reading")) + + assert len(client.posted) == 1 + blocks = client.posted[0].get("blocks", []) + assert any(b["type"] == "section" for b in blocks) + action_ids = [ + el["action_id"] + for b in blocks + if b["type"] == "actions" + for el in b["elements"] + ] + assert "switch_interrupt" in action_ids + + +def test_streaming_path_button_message_is_deleted_on_idle() -> None: + """The button-only message posted alongside the native card must not + outlive the turn — a stale Stop button that does nothing is the exact + failure mode CHOO-2277 warned about.""" + adapter, client = _adapter() + + _run(_state(adapter, "working", detail="reading")) + assert len(client.posted) == 1 + + _run(_state(adapter, "idle")) + + # Both the card and the button message are cleaned up. + assert len(client.deleted) == 2 + + +def test_block_action_falls_back_to_session_owner() -> None: + """When the button's value is empty, the handler resolves the agent + from _session_owner.""" + adapter, _ = _adapter() + commands: list[InboundCommand] = [] + + async def on_command(cmd: InboundCommand) -> None: + commands.append(cmd) + + adapter._on_command = on_command # type: ignore[assignment] + adapter._user_cache["U1"] = SlackUser(name="louis", display_name="Louis") + + _run(_state(adapter, "working", detail="reading")) + payload = _block_action_payload() + payload["actions"][0]["value"] = "" + _run(adapter._handle_block_action(payload)) + + assert len(commands) == 1 + assert commands[0].args == "@flint-tracker" diff --git a/docs/official/deploy/messaging-apps/slack.md b/docs/official/deploy/messaging-apps/slack.md index 91ec5c4ce..31db7ceda 100644 --- a/docs/official/deploy/messaging-apps/slack.md +++ b/docs/official/deploy/messaging-apps/slack.md @@ -238,7 +238,7 @@ If the bot is refused, make the groups by hand: one whose handle or name is exac While an agent is working, Slack draws a live progress card under the agent's own name and icon, linking back to the session in Switch Console. It's an indicator rather than a record, so it goes when the turn ends. This is what declaring the app an **Agent** in the manifest buys you. -Where the card can't be drawn, Switch posts a status message under the agent's name carrying the same **Open in Switch Console** link, so a turn always shows its progress somewhere. +Where the card can't be drawn, Switch posts a status message under the agent's name carrying the same **Open in Switch Console** link, so a turn always shows its progress somewhere. In both cases the progress message carries a **Stop** button that interrupts the running turn (no manifest change needed — interactivity is already enabled). Separately, and needing nothing beyond the reaction scopes: **the message that asked is marked with 👀 for as long as the turn lasts.** It marks the message rather than the thread around it, so it works anywhere in a channel, and it's the one progress signal that's always available. diff --git a/docs/official/resources/room-commands.md b/docs/official/resources/room-commands.md index bffabc1d6..acfd39e60 100644 --- a/docs/official/resources/room-commands.md +++ b/docs/official/resources/room-commands.md @@ -144,6 +144,8 @@ Stops what an agent is doing now. !interrupt @agent-name ``` +On Slack, the agent's progress message carries a **Stop** button that does the same thing — click it instead of typing the command. + ### !compact Compacts an agent's session context. From a5d5d394d81f59e252b1dbb5779771b29bfe2281 Mon Sep 17 00:00:00 2001 From: Abel Dantas Date: Sun, 6 Sep 2026 08:15:46 +0000 Subject: [PATCH 2/2] fix(slack): Stop button works on both paths and survives repositioning Three bugs in the initial Stop button implementation: 1. _handle_block_action checked _session_owner for liveness, but that dict is only populated when agent_sessions is on. When off (the non-card path), the button always said "already finished". Now also checks _working_msg as a fallback liveness signal. 2. reposition_runtime_state used the base class's plain-text send_message, losing the Block Kit blocks. Override in SlackAdapter to repost with the correct blocks (section + actions for non-streaming, button-only for streaming). 3. The streaming early-return guard didn't compare thread_root_id, so a button posted for one thread was reused when the thread changed. Now checks thread_root_id matches before skipping. Tests cover all three: block action on the non-card path (live and idle), reposition preserving the button on both paths, and the existing race condition and repost-failure tests updated for the new override. --- .../bridges/collaboration/slack/adapter.py | 64 ++++++++++++- .../test_runtime_indicator_race.py | 13 +++ .../collaboration/test_slack_adapter.py | 4 +- .../test_slack_agent_sessions.py | 91 ++++++++++++++++++- docs/official/deploy/messaging-apps/slack.md | 2 +- docs/official/resources/room-commands.md | 2 - 6 files changed, 164 insertions(+), 12 deletions(-) diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 9f6c1748f..dfd05ffd0 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -765,8 +765,12 @@ async def _apply_runtime_state( # story. Tracked as a LiveRuntimeIndicator so the existing # idle disposal removes it. existing = self._working_msg.get(key) - if existing is not None and existing.body == "": - # Already have a button-only message; nothing to do. + if ( + existing is not None + and existing.body == "" + and existing.thread_root_id == thread_root_id + ): + # Already have a button-only message in this thread; nothing to do. return # Clear any full working message left over from before the # card existed (e.g. sessions were temporarily unavailable). @@ -1366,12 +1370,27 @@ async def _handle_block_action(self, payload: dict[str, Any]) -> None: return # The button carries the agent name in its value, but the turn may - # have ended between the click and this handler running. Check the - # session owner to confirm the turn is still live. + # have ended between the click and this handler running. agent_name = str(action.get("value") or "") or self._session_owner.get( (channel_id, thread_ts), "" ) - if not agent_name or (channel_id, thread_ts) not in self._session_owner: + if not agent_name: + await self.admin_message( + channel_id, + "That turn already finished.", + thread_root_id=f"{channel_id}:{thread_ts}", + ) + return + + # The turn is live if tracked by either signal: _session_owner + # (card path, populated only when agent_sessions is on) or + # _working_msg (both paths — always present while a working + # indicator is up). + turn_live = (channel_id, thread_ts) in self._session_owner or ( + channel_id, + agent_name, + ) in self._working_msg + if not turn_live: await self.admin_message( channel_id, "That turn already finished.", @@ -1501,6 +1520,41 @@ async def _post_stop_button( channel_id, agent_name, "Stop", blocks, thread_root_id ) + async def _reposition_runtime_state( + self, channel_id: str, agent_name: str, thread_root_id: str | None + ) -> None: + """Repost the working indicator with its Block Kit blocks intact. + + The base class reposts with plain text, which loses the Stop button. + """ + key = (channel_id, agent_name) + live = self._working_msg.get(key) + if live is None: + return + + if live.body == "": + # Streaming path: button-only message. + ref = await self._post_stop_button(channel_id, agent_name, thread_root_id) + else: + blocks = self._stop_button_blocks(agent_name, live.body) + ref = await self._post_message_with_blocks( + channel_id, agent_name, live.body, blocks, thread_root_id + ) + + if ref is None: + logger.warning( + "Could not repost the runtime indicator for %s in %s; leaving it " + "at its current position", + agent_name, + channel_id, + ) + return + + self._working_msg[key] = replace( + live, message_ref=ref, thread_root_id=thread_root_id + ) + await self._remove_runtime_indicator(channel_id, live.message_ref) + async def _clear_working(self, channel_id: str, agent_name: str) -> None: live = self._working_msg.pop((channel_id, agent_name), None) if live is not None: diff --git a/core/tests/switch_core/bridges/collaboration/test_runtime_indicator_race.py b/core/tests/switch_core/bridges/collaboration/test_runtime_indicator_race.py index 546f2d522..107dc7130 100644 --- a/core/tests/switch_core/bridges/collaboration/test_runtime_indicator_race.py +++ b/core/tests/switch_core/bridges/collaboration/test_runtime_indicator_race.py @@ -54,6 +54,18 @@ async def send_message( self.live.add(ref) return ref + async def post_message_with_blocks( + channel_id: str, + agent_name: str, + text: str, + blocks: Any, + thread_root_id: str | None, + ) -> str | None: + await asyncio.sleep(0) + ref = next(self._next) + self.live.add(ref) + return ref + async def update_message( channel_id: str, message_ref: str, new_content: str ) -> None: @@ -65,6 +77,7 @@ async def delete_message(channel_id: str, message_ref: str) -> None: self.live.discard(message_ref) adapter.send_message = send_message # type: ignore[method-assign] + adapter._post_message_with_blocks = post_message_with_blocks # type: ignore[method-assign] adapter.update_message = update_message # type: ignore[method-assign] adapter.delete_message = delete_message # type: ignore[method-assign] diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_adapter.py b/core/tests/switch_core/bridges/collaboration/test_slack_adapter.py index 25e0bd7ae..83505d0d9 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_adapter.py @@ -892,10 +892,10 @@ def test_reposition_leaves_the_original_when_the_repost_fails() -> None: ) ) - async def failing_send(*_args: Any, **_kwargs: Any) -> None: + async def failing_post(*_args: Any, **_kwargs: Any) -> None: return None - adapter.send_message = failing_send # type: ignore[method-assign] + adapter._post_message_with_blocks = failing_post # type: ignore[method-assign] _run(adapter.reposition_runtime_state("C123", "agent-bot", None)) assert fake.deletes == [] diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_agent_sessions.py b/core/tests/switch_core/bridges/collaboration/test_slack_agent_sessions.py index 0888edbae..ebf4c7922 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_agent_sessions.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_agent_sessions.py @@ -661,8 +661,8 @@ def test_every_card_and_button_is_deleted_when_two_were_open() -> None: _run(_state(adapter, "idle", thread="C1:222.0")) deleted_ts = sorted(d["ts"] for d in client.deleted) - # Two cards + one button message (one per channel+agent) = 3 deletions. - assert len(deleted_ts) == 3 + # Two cards + two button messages (one button per thread) = 4 deletions. + assert len(deleted_ts) == 4 # ── Which message gets the eyes ────────────────────────────────────────────── @@ -1048,6 +1048,47 @@ def test_streaming_path_button_message_is_deleted_on_idle() -> None: assert len(client.deleted) == 2 +def test_block_action_works_without_agent_sessions() -> None: + """The Stop button must fire the interrupt on the non-card path too, + where _session_owner is never populated.""" + adapter, _ = _adapter(enabled=False) + commands: list[InboundCommand] = [] + + async def on_command(cmd: InboundCommand) -> None: + commands.append(cmd) + + adapter._on_command = on_command # type: ignore[assignment] + adapter._user_cache["U1"] = SlackUser(name="louis", display_name="Louis") + + _run(_state(adapter, "working", detail="reading")) + _run(adapter._handle_block_action(_block_action_payload())) + + assert len(commands) == 1 + assert commands[0].command == "interrupt" + assert commands[0].args == "@flint-tracker" + + +def test_block_action_after_idle_without_agent_sessions_posts_soft_message() -> None: + """Non-card path: once the working message is cleared, the button is dead.""" + adapter, client = _adapter(enabled=False) + commands: list[InboundCommand] = [] + + async def on_command(cmd: InboundCommand) -> None: + commands.append(cmd) + + adapter._on_command = on_command # type: ignore[assignment] + + _run(_state(adapter, "working", detail="reading")) + _run(_state(adapter, "idle")) + _run(adapter._handle_block_action(_block_action_payload())) + + assert commands == [] + already_finished = [ + p for p in client.posted if "already finished" in p.get("text", "") + ] + assert len(already_finished) == 1 + + def test_block_action_falls_back_to_session_owner() -> None: """When the button's value is empty, the handler resolves the agent from _session_owner.""" @@ -1067,3 +1108,49 @@ async def on_command(cmd: InboundCommand) -> None: assert len(commands) == 1 assert commands[0].args == "@flint-tracker" + + +# ── Repositioning preserves blocks ───────────────────────────────────────── + + +def test_reposition_preserves_stop_button_on_non_streaming_path() -> None: + """The base class reposts with plain text, losing the blocks. + SlackAdapter's override must repost with the Stop button intact.""" + adapter, client = _adapter(enabled=False) + + _run(_state(adapter, "working", detail="reading")) + assert len(client.posted) == 1 + + _run(adapter.reposition_runtime_state("C1", "flint-tracker", "C1:222.0")) + + assert len(client.posted) == 2 + blocks = client.posted[1].get("blocks", []) + action_ids = [ + el["action_id"] + for b in blocks + if b["type"] == "actions" + for el in b["elements"] + ] + assert "switch_interrupt" in action_ids + + +def test_reposition_preserves_stop_button_on_streaming_path() -> None: + """The button-only message beside the card must also survive repositioning.""" + adapter, client = _adapter() + + _run(_state(adapter, "working", detail="reading")) + assert len(client.posted) == 1 + + _run(adapter.reposition_runtime_state("C1", "flint-tracker", "C1:222.0")) + + assert len(client.posted) == 2 + blocks = client.posted[1].get("blocks", []) + action_ids = [ + el["action_id"] + for b in blocks + if b["type"] == "actions" + for el in b["elements"] + ] + assert "switch_interrupt" in action_ids + # No section block — the card has the status text. + assert not any(b["type"] == "section" for b in blocks) diff --git a/docs/official/deploy/messaging-apps/slack.md b/docs/official/deploy/messaging-apps/slack.md index 31db7ceda..91ec5c4ce 100644 --- a/docs/official/deploy/messaging-apps/slack.md +++ b/docs/official/deploy/messaging-apps/slack.md @@ -238,7 +238,7 @@ If the bot is refused, make the groups by hand: one whose handle or name is exac While an agent is working, Slack draws a live progress card under the agent's own name and icon, linking back to the session in Switch Console. It's an indicator rather than a record, so it goes when the turn ends. This is what declaring the app an **Agent** in the manifest buys you. -Where the card can't be drawn, Switch posts a status message under the agent's name carrying the same **Open in Switch Console** link, so a turn always shows its progress somewhere. In both cases the progress message carries a **Stop** button that interrupts the running turn (no manifest change needed — interactivity is already enabled). +Where the card can't be drawn, Switch posts a status message under the agent's name carrying the same **Open in Switch Console** link, so a turn always shows its progress somewhere. Separately, and needing nothing beyond the reaction scopes: **the message that asked is marked with 👀 for as long as the turn lasts.** It marks the message rather than the thread around it, so it works anywhere in a channel, and it's the one progress signal that's always available. diff --git a/docs/official/resources/room-commands.md b/docs/official/resources/room-commands.md index acfd39e60..bffabc1d6 100644 --- a/docs/official/resources/room-commands.md +++ b/docs/official/resources/room-commands.md @@ -144,8 +144,6 @@ Stops what an agent is doing now. !interrupt @agent-name ``` -On Slack, the agent's progress message carries a **Stop** button that does the same thing — click it instead of typing the command. - ### !compact Compacts an agent's session context.