From 5c36ebc89214a85af3a86c803672305aaddb7577 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Mon, 14 Sep 2026 18:31:34 +0100 Subject: [PATCH 001/120] Publish SDK sessions to Mattermost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mattermost now draws turns and requests through the rich-content seam instead of the legacy runtime-state renderer, which is switched off here (renders_legacy_runtime_state = False) so the two cannot both draw. The platform's own presentation: - One compact status post per turn, posted by the agent's own bot in the thread and edited in place. No separate tool log: Mattermost cannot collapse one, and the status carries the counts. - A problem somebody has to act on still gets its own reply, so it notifies rather than arriving as a silent edit. Edited when it clears, never deleted β€” Mattermost leaves "(message deleted)" behind. - Request cards as plain text with the typed-answer grammar spelled out against the particular form. - Per-agent πŸ‘€ on the asking message, claimed and released per agent. Shared code grew two capability flags for this. separate_attention_slot splits the attention reply from separate_activity_log, which Slack had conflated because it wants both. activity_reactions_per_agent scopes the reaction claim, the in-memory holder key and the journal's reaction_held query by agent, so one agent finishing does not strip another's mark. SessionRequestCards now carries its surface, which the publisher reads rather than being told separately. That replaces two hard-coded "slack" literals: the queued-turn acknowledgement in refresh_activity and the responder lookup in refresh, both of which were Slack-only by accident of being written for Slack first. The neutral renderer gains turn_status and request_summary, and the pieces both platforms need (CLOSED, SURFACES, NO_OPTIONS, unanswerable, example_value) are lifted out of slack.py unchanged. The legacy Mattermost renderer and its tests stay: removing them is the next task, and until then those tests drive the private methods directly. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 59 +- .../bridges/collaboration/bridge_core.py | 2 + .../collaboration/mattermost/adapter.py | 438 +++++++++++- .../collaboration/session/activity_journal.py | 16 +- .../bridges/collaboration/session/outbound.py | 49 +- .../session/renderers/__init__.py | 97 ++- .../session/renderers/neutral.py | 629 +++++++++++++++++- .../collaboration/session/renderers/slack.py | 103 +-- .../bridges/collaboration/slack/adapter.py | 14 +- core/switch_core/sessions/publication.py | 13 +- .../test_mattermost_runtime_state.py | 30 +- .../collaboration/test_mattermost_sdk_only.py | 540 +++++++++++++++ .../test_mattermost_working_reaction.py | 9 +- .../collaboration/test_rich_content_port.py | 47 +- .../test_runtime_indicator_race.py | 4 +- .../test_session_card_posting.py | 4 + .../test_session_request_lifecycle.py | 1 + .../collaboration/test_slack_sdk_only.py | 8 +- .../sessions/test_activity_durability.py | 80 ++- .../switch_core/sessions/test_publication.py | 7 +- .../sessions/test_publication_retries.py | 1 + .../test_request_post_tenant_migration.py | 1 + 22 files changed, 1972 insertions(+), 180 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 4b2e9eaed..1fd561059 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -204,7 +204,29 @@ class CollaborationAdapter(ABC): publishes_sdk_sessions: ClassVar[bool] = False # Keep the ticking status and expandable tool log in separate messages. separate_activity_log: ClassVar[bool] = False + + #: Whether a problem somebody has to act on gets a message of its own. + #: + #: One durable reply per turn, reused as the problem changes and cleared + #: when it goes away β€” never a second one. Separate from + #: `separate_activity_log` because the two answer different questions: a + #: platform can want one compact status carrying its own tool counts (so + #: no separate log) and still want a failure to arrive as something a + #: reader is notified about rather than as an edit to a message they have + #: already scrolled past. + separate_attention_slot: ClassVar[bool] = False + supports_activity_reactions: ClassVar[bool] = False + + #: Whether the work reaction belongs to the agent that added it. + #: + #: True where each agent posts as its own bot, so two agents working on one + #: message leave two independent marks and each must be claimed, held and + #: removed on its own. False where every agent shares one bot account: the + #: platform has one reaction between them, so the first turn to want it + #: puts it on and the last to finish takes it off. + activity_reactions_per_agent: ClassVar[bool] = False + renders_legacy_runtime_state: ClassVar[bool] = True #: Whether this platform can create a channel from Switch at all. @@ -588,11 +610,19 @@ def rich_fallback_text(self, content: RichContent) -> str: own. A turn falls back to `turn_summary` β€” the last thing the agent said, - and the turn's own state. A request card has no neutral form yet: - there is no off-Slack request renderer to write one against, so - `request_summary` raises rather than guess at a shape (title, detail, - per-option or per-question lines, a footer) nobody has needed yet. - Implement that alongside the first one. + and the turn's own state. A request falls back to `request_summary`: + the question, its numbered options and the typed-answer grammar for + this particular form, in whichever state the request is in. Neither + is the compact presentation a platform publishing SDK sessions wants + (`turn_status` is), which is why an adapter that does publish them + overrides this rather than inheriting it. + + The card's own extras go with it. `unavailable_reason` is the notice + that replaces the instruction on a card that cannot be answered where + it is showing, and dropping it here would leave the reader an + instruction that is about to be refused. `responder_external_id` is + not passed on: it is a platform id, and an adapter that can turn one + into a name renders the card itself. The result is ready to send as-is β€” `post_rich` and `update_rich` do not run it through `translate_outbound` again. `_rich_escape` already @@ -617,6 +647,7 @@ def rich_fallback_text(self, content: RichContent) -> str: content.reference, escape=escape, limit=self.rich_fallback_limit(), + unavailable_reason=content.unavailable_reason, ) def _rich_escape(self, label: str) -> str: @@ -668,9 +699,23 @@ async def send_typing( ) -> None: ... async def mark_activity( - self, channel_id: str, message_ref: str, *, working: bool, force: bool = False + self, + channel_id: str, + message_ref: str, + *, + agent_name: str, + working: bool, + force: bool = False, ) -> None: - """Update a platform work indicator when the adapter supports one.""" + """Update a platform work indicator when the adapter supports one. + + `agent_name` is which agent is working, and it is required rather than + optional because a platform where each agent posts as its own bot + cannot add or remove a reaction without knowing whose it is. A + platform with one shared bot ignores it β€” there is one reaction + between every agent there β€” but a caller that could not supply it + would be a caller that cannot serve the per-agent platforms at all. + """ def _runtime_lock(self, channel_id: str, agent_name: str) -> asyncio.Lock: """The lock serialising runtime-indicator work for one agent in one diff --git a/core/switch_core/bridges/collaboration/bridge_core.py b/core/switch_core/bridges/collaboration/bridge_core.py index 7679024b5..53d8535c5 100644 --- a/core/switch_core/bridges/collaboration/bridge_core.py +++ b/core/switch_core/bridges/collaboration/bridge_core.py @@ -239,6 +239,7 @@ def __init__( SessionRequestCards( adapter, bridge_id=bridge_id, + surface=bridge_type, posts=session_request_post_store, session_factory=session_factory, ) @@ -289,6 +290,7 @@ def _build_session_demo( SessionRequestCards( self._adapter, bridge_id=self._bridge_id, + surface=self._bridge_type, posts=posts, session_factory=self._session_factory, ), diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 21a0503ef..bea12753c 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -11,6 +11,7 @@ from collections import OrderedDict from collections.abc import Awaitable, Callable from dataclasses import replace +from datetime import datetime from typing import Any, ClassVar import httpx @@ -22,6 +23,10 @@ from switch_core.bridges.collaboration.adapter import ( CollaborationAdapter, LiveRuntimeIndicator, + RequestCard, + RichContent, + RichContentFailed, + TurnActivity, format_elapsed, ) from switch_core.bridges.collaboration.models import ( @@ -37,6 +42,10 @@ InboundUserJoin, OutboundAttachment, ) +from switch_core.bridges.collaboration.session.renderers.neutral import ( + request_summary, + turn_status, +) logger = logging.getLogger(__name__) @@ -55,6 +64,23 @@ # Emoji marking the message an agent is currently working on. _WORKING_REACTION = "eyes" +# The post property carrying a publication's recovery marker. Props are part of +# the post but not part of what anyone reads, which is exactly what this needs +# to be: a marker in the message body would be visible clutter, and a marker +# held only on this side is lost the moment a post's response is β€” which is the +# one case it exists for. `patch_post` leaves props it is not given alone, so +# editing the status in place cannot drop it. +_PUBLICATION_PROP = "switch_publication" + +# How far before the recorded reservation time to start looking for a post that +# may or may not exist. Covers ordinary clock skew between Switch and the +# Mattermost server without widening the search into unrelated history. +_RECOVERY_SKEW_MS = 60_000 + +# Mattermost's own default `MaxPostSize`. Servers can raise it to 16383, and a +# message cut to fit the default is a message that fits everywhere. +_MAX_POST = 4000 + # The values of `TeamSettings.TeammateNameDisplay` under which Mattermost puts # a bot's display name in the post header. Under any other value β€” including # the server default, "username" β€” the display name is stored and never shown. @@ -84,9 +110,41 @@ class MattermostConnectionConfig(BridgeConnectionConfig): class MattermostAdapter(CollaborationAdapter): + publishes_sdk_sessions: ClassVar[bool] = True + + #: One message, not two. The compact status carries its own tool counts, so + #: there is nothing left for a separate log to hold that is worth a second + #: message in the thread β€” and an expandable tool history is a later piece + #: of work, not something to approximate with an extra post now. + separate_activity_log: ClassVar[bool] = False + + #: A problem somebody has to act on still gets its own reply, so it + #: notifies rather than arriving as a silent edit to a status the reader + #: has already scrolled past. One per turn, cleared when it clears. + separate_attention_slot: ClassVar[bool] = True + + supports_activity_reactions: ClassVar[bool] = True + + #: Each agent posts and reacts as its own bot here, so two agents working + #: on one message leave two independent πŸ‘€ and each is claimed and removed + #: on its own. + activity_reactions_per_agent: ClassVar[bool] = True + + #: Off, because the SDK publication now draws the status. Leaving it on + #: would put two competing accounts of the same turn in the channel: the + #: legacy "working on it…" post edited to "βœ“ Done" beside the activity + #: message saying the same thing in more detail. The legacy renderer stays + #: in the file β€” it is what every unmigrated platform still uses β€” and this + #: flag is what stops it publishing here. + renders_legacy_runtime_state: ClassVar[bool] = False + #: Mattermost renders a thread inline under its root as well as in the #: side panel, so anchoring the status to the message being worked on keeps #: it beside the answer instead of stranding it at the channel root. + #: + #: Read only by the legacy runtime-state path, which is off above. Kept + #: because it is a true statement about the platform, and the platform is + #: what the flag describes. runtime_state_follows_anchor: ClassVar[bool] = True def __init__(self, *, config: MattermostConnectionConfig) -> None: @@ -131,6 +189,20 @@ def __init__(self, *, config: MattermostConnectionConfig) -> None: # touched. self._agent_eyes: dict[tuple[str, str], set[str]] = {} + # post id -> the agent whose bot posted it, for editing a publication + # back as itself. Bounded because it grows with every turn, and an + # entry only matters while the message it names is still being redrawn; + # past that the admin driver can patch it and Mattermost keeps the + # original author either way. + self._rich_authors: OrderedDict[str, str] = OrderedDict() + self._rich_authors_max = 1000 + + # Mattermost user id -> username, because a mention is written with the + # handle and Switch stores the id. Stable for the life of a user, so a + # hit here saves a round trip on every redraw that carries a mention. + self._usernames: OrderedDict[str, str] = OrderedDict() + self._usernames_max = 1000 + self._main_loop: asyncio.AbstractEventLoop | None = None # channel id -> channel name (URL slug), for building channel deeplinks. @@ -419,22 +491,45 @@ async def _create_post( channel_id: str, content: str, thread_root_id: str | None, + props: dict[str, str] | None = None, ) -> str | None: - loop = self._main_loop - if loop is None: - logger.error("Cannot send message: event loop not initialized") - return None - post: dict[str, str] = {"channel_id": channel_id, "message": content} - if thread_root_id is not None: - post["root_id"] = thread_root_id try: - result = await loop.run_in_executor(None, driver.posts.create_post, post) - post_id: str = result.get("id", "") - return post_id or None + return await self._post_or_raise( + driver, channel_id, content, thread_root_id, props + ) except Exception as e: logger.error("Failed to send Mattermost message to %s: %s", channel_id, e) return None + async def _post_or_raise( + self, + driver: Driver, + channel_id: str, + content: str, + thread_root_id: str | None, + props: dict[str, str] | None, + ) -> str: + """Create a post and hand back its id, or raise saying why not. + + `_create_post` is the swallowing wrapper for callers whose failure is + cosmetic. A publication's is not: the exception carries what Mattermost + actually said, which is the difference between a caller that can retry + sensibly and one that only knows it got `None`. + """ + loop = self._main_loop + if loop is None: + raise RuntimeError("Mattermost is not connected.") + post: dict[str, Any] = {"channel_id": channel_id, "message": content} + if thread_root_id is not None: + post["root_id"] = thread_root_id + if props: + post["props"] = props + result = await loop.run_in_executor(None, driver.posts.create_post, post) + post_id: str = result.get("id", "") + if not post_id: + raise RuntimeError("Mattermost accepted the post but returned no id.") + return post_id + async def update_message( self, channel_id: str, message_ref: str, new_content: str ) -> None: @@ -452,6 +547,319 @@ async def update_message( except Exception as e: logger.error("Failed to update Mattermost post %s: %s", message_ref, e) + # ── SDK session publication ────────────────────────────────────────────── + + def rich_fallback_limit(self) -> int: + return _MAX_POST + + def rich_fallback_text(self, content: RichContent) -> str: + """What a publication says, with nothing that needed looking up. + + The renderers are the same ones `post_rich` uses; what is missing is + the mention and the responder's handle, both of which come from an id + Switch holds and a call to Mattermost to turn it into a name. This is + the string that goes in a `RichContentFailed`, where a failed lookup + on top of a failed post would say nothing useful anyway. + """ + return self._draw(content, mention=None, responder=None) + + def _draw( + self, content: RichContent, *, mention: str | None, responder: str | None + ) -> str: + escape = self._rich_escape + limit = self.rich_fallback_limit() + if isinstance(content, TurnActivity): + return turn_status( + content.items, + content.turn, + escape=escape, + limit=limit, + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + mention=mention, + error_summary=content.error_summary, + ) + # The handle goes on its own line rather than in front of the heading: + # a card is a block, and a handle wedged before "**Permission needed**" + # reads as part of the heading. It is charged to the same budget, or a + # form that just fits becomes a post Mattermost refuses. + lead = f"{mention}\n" if mention else "" + body = request_summary( + content.request, + content.reference, + escape=escape, + limit=max(1, limit - len(lead)), + responder=responder, + unavailable_reason=content.unavailable_reason, + ) + return f"{lead}{body}" + + async def _render_rich(self, content: RichContent) -> str: + mention = await self._mention(content.notify_external_id) + responder = ( + await self._mention(content.responder_external_id) + if isinstance(content, RequestCard) + else None + ) + return self._draw(content, mention=mention, responder=responder) + + async def post_rich( + self, + channel_id: str, + agent_name: str, + content: RichContent, + thread_root_id: str | None = None, + ) -> str: + """Post a turn's status or a request's form as the agent's own bot. + + The recovery marker rides along in the post's props rather than in + anything a reader sees, so a post whose response was lost can be found + again by `find_request_card` instead of being sent twice. + + Raises on every failure, unlike `send_message`, which reports one by + returning `None`: a publication that silently did not happen is a + reservation that never gets retried and a turn the channel never sees. + """ + text = await self._render_rich(content) + driver = self._bot_drivers.get(agent_name) + if driver is None: + raise RichContentFailed( + f"No Mattermost bot for agent {agent_name!r}, so its activity " + f"cannot be posted in channel {channel_id}.", + text=text, + ) + token = ( + content.publication_token + if isinstance(content, TurnActivity) + else content.reference.token + ) + try: + ref = await self._post_or_raise( + driver, + channel_id, + text, + thread_root_id, + {_PUBLICATION_PROP: token} if token else None, + ) + except Exception as error: + raise RichContentFailed( + f"Mattermost could not post in channel {channel_id}: {error}", + text=text, + ) from error + self._remember_author(ref, agent_name) + return ref + + async def update_rich( + self, channel_id: str, message_ref: str, content: RichContent + ) -> None: + """Redraw a publication in place, and say so when it did not happen. + + Not `update_message`: that one logs and returns, which is right for the + legacy status line nobody is waiting on and wrong here. A card that + failed to redraw is still showing a settled request as open, and the + caller has a reply to post about it β€” but only if it is told. + + Edited as the bot that posted it where that is still known. Mattermost + keeps the original author through a patch either way, so the fallback + to the admin driver changes who a reader sees the post from not at all; + what it changes is the permission the edit is made with, and an agent + bot editing its own post is the narrower of the two. + """ + # A post notifies; an edit does not. Repeating the mention on every + # redraw would be a handle in the channel that never resolves to + # anything new for the person it names. + text = await self._render_rich(replace(content, notify_external_id=None)) + driver = ( + self._bot_drivers.get(self._rich_authors.get(message_ref, "")) + or self._admin_driver + ) + loop = self._main_loop + if driver is None or loop is None: + raise RichContentFailed( + "Mattermost is not connected, so the post could not be updated.", + text=text, + ) + try: + await loop.run_in_executor( + None, driver.posts.patch_post, message_ref, {"message": text} + ) + except Exception as error: + raise RichContentFailed( + f"Mattermost could not update post {message_ref} in channel " + f"{channel_id}: {error}", + text=text, + ) from error + + async def find_request_card( + self, + channel_id: str, + thread_root_id: str | None, + token: str, + created_at: datetime, + ) -> str | None: + """Look for a publication this bridge may already have posted. + + Asked when a post's outcome is unknown β€” the request timed out, or the + process died between sending and recording the id. The answer decides + between binding the reservation to what is there and posting a second + copy of a card somebody is meant to answer exactly once, so a lookup + that cannot be trusted must come back as "not found" rather than as a + guess: `None` keeps the reservation and asks again. + + Matched on the props marker and on the post's author, because a token + quoted back in somebody's message must not be mistaken for the post + that carries it. + """ + driver = self._admin_driver + loop = self._main_loop + if driver is None or loop is None: + logger.warning( + "Cannot look for the publication marked %s in channel %s: " + "Mattermost is not connected.", + token, + channel_id, + ) + return None + since = max(0, int(created_at.timestamp() * 1000) - _RECOVERY_SKEW_MS) + + def _read() -> dict[str, Any]: + if thread_root_id: + return dict(driver.posts.get_thread(thread_root_id)) + return dict( + driver.posts.get_posts_for_channel(channel_id, params={"since": since}) + ) + + try: + page = await loop.run_in_executor(None, _read) + except Exception as e: + logger.warning( + "Could not read %s looking for the publication marked %s: %s.", + f"thread {thread_root_id}" + if thread_root_id + else f"channel {channel_id}", + token, + e, + ) + return None + for post in (page.get("posts") or {}).values(): + if post.get("user_id") not in self._bridge_bot_ids: + continue + if (post.get("props") or {}).get(_PUBLICATION_PROP) != token: + continue + found = str(post.get("id") or "") + if found: + return found + return None + + async def is_first_reply( + self, channel_id: str, root_ref: str, message_ref: str + ) -> bool: + """Whether this message is the first thing said under a thread root. + + Mattermost's websocket event names the root but not the position in the + thread, so the thread itself is the only place the answer is. Ordered + here on `create_at` rather than trusted from the API's own `order`: a + bare "yes" deciding a permission is not worth resting on a field whose + direction is not part of the contract. + + Never raises. This is on the inbound path of every message, ahead of + the relay, so an exception out of it is not a refused answer but a + message the room never sees. + """ + driver = self._admin_driver + loop = self._main_loop + if driver is None or loop is None: + logger.warning( + "Cannot read the thread under %s in %s: Mattermost is not " + "connected. Treating %s as not the first reply.", + root_ref, + channel_id, + message_ref, + ) + return False + try: + thread = await loop.run_in_executor(None, driver.posts.get_thread, root_ref) + except Exception as e: + logger.warning( + "Could not read the thread under %s in %s: %s. Treating %s as " + "not the first reply.", + root_ref, + channel_id, + e, + message_ref, + ) + return False + replies = sorted( + ( + post + for post in (thread.get("posts") or {}).values() + if post.get("id") != root_ref and not post.get("delete_at") + ), + key=lambda post: (post.get("create_at") or 0, str(post.get("id") or "")), + ) + return bool(replies) and replies[0].get("id") == message_ref + + async def mark_activity( + self, + channel_id: str, + message_ref: str, + *, + agent_name: str, + working: bool, + force: bool = False, + ) -> None: + """Put this agent's πŸ‘€ on the message it is working on, or take it off. + + Per agent, because the reaction belongs to the bot that added it: + two agents on one message are two marks, and one finishing leaves the + other's alone. `force` is the durable publisher reconciling after a + restart, when this process's record of what is already there is empty + and wrong rather than empty and right. + """ + await self._mark_being_read( + agent_name, message_ref, working=working, force=force + ) + + def _remember_author(self, post_id: str, agent_name: str) -> None: + self._rich_authors[post_id] = agent_name + self._rich_authors.move_to_end(post_id) + while len(self._rich_authors) > self._rich_authors_max: + self._rich_authors.popitem(last=False) + + async def _mention(self, external_user_id: str | None) -> str | None: + """`@handle` for a Mattermost user id, or None if it cannot be resolved. + + None rather than the raw id: an id printed where a handle belongs + notifies nobody and reads as noise, and the message it decorates is + worth sending without it. + """ + if not external_user_id: + return None + username = self._usernames.get(external_user_id) + if username is None: + driver = self._admin_driver + loop = self._main_loop + if driver is None or loop is None: + return None + try: + user = await loop.run_in_executor( + None, driver.users.get_user, external_user_id + ) + except Exception as e: + logger.warning( + "Could not resolve Mattermost user %s to a handle: %s", + external_user_id, + e, + ) + return None + username = str(user.get("username") or "") + if not username: + return None + self._usernames[external_user_id] = username + while len(self._usernames) > self._usernames_max: + self._usernames.popitem(last=False) + return f"@{username}" + async def delete_message(self, channel_id: str, message_ref: str) -> None: if not self._admin_driver or not self._main_loop: logger.error("Cannot delete message: Mattermost client not connected") @@ -654,7 +1062,7 @@ async def _track_eyes( await self._mark_being_read(agent_name, post_id, working=False) async def _mark_being_read( - self, agent_name: str, post_id: str, *, working: bool + self, agent_name: str, post_id: str, *, working: bool, force: bool = False ) -> None: """Put πŸ‘€ on the post an agent is working on, and take it off after. @@ -663,9 +1071,15 @@ async def _mark_being_read( message read as two. This is the progress signal that always works: unlike the status post it needs no thread, and unlike the typing indicator it does not expire. + + `self._eyes` is this process's memory of what it has already done, and + a restart empties it while the reactions stay in the channel. `force` + is for the caller that knows better from the journal: skipping the + call because the set is empty would strand a πŸ‘€ on a turn that ended + while the bridge was down. """ key = (agent_name, post_id) - if working == (key in self._eyes): + if not force and working == (key in self._eyes): return bot_info = self._agent_bots.get(agent_name) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index b5161a6f8..6ee1deb7e 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -67,16 +67,26 @@ async def reaction_held( channel: str, ref: str, *, + agent_name: str | None = None, sessions: async_sessionmaker[AsyncSession], ) -> bool: + """Whether another live turn still wants the mark on this message. + + `agent_name` narrows the question to one agent's own mark, for a + platform where each agent reacts as its own bot: another agent holding + its own reaction there says nothing about whether this one's should + come off. Left None where every agent shares a bot and there is a + single reaction between them. + """ + anchor: dict[str, str] = {"channel_id": channel, "reaction_ref": ref} + if agent_name is not None: + anchor["agent_name"] = agent_name async with sessions() as db: rows = await db.scalars( select(SessionActivityPost).where( SessionActivityPost.tenant_id == require_tenant_id(), SessionActivityPost.bridge_id == self.bridge_id, - SessionActivityPost.data.contains( - {"anchor": {"channel_id": channel, "reaction_ref": ref}} - ), + SessionActivityPost.data.contains({"anchor": anchor}), ) ) return any( diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index b09601574..373742587 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -155,8 +155,14 @@ def __init__( ) self._adapter = adapter self._separate_activity_log = getattr(adapter, "separate_activity_log", False) + self._separate_attention_slot = getattr( + adapter, "separate_attention_slot", False + ) + self._reactions_per_agent = getattr( + adapter, "activity_reactions_per_agent", False + ) self._anchors: OrderedDict[tuple[str, str], _Anchor] = OrderedDict() - self._thread_turns: dict[tuple[str, str], set[tuple[str, str]]] = {} + self._thread_turns: dict[tuple[str, str, str], set[tuple[str, str]]] = {} self._attention: OrderedDict[tuple[str, str], tuple[str, str]] = OrderedDict() @property @@ -197,7 +203,7 @@ async def draw() -> bool: elapsed_seconds=elapsed_seconds, session_url=session_url, ) - if self._separate_activity_log: + if self._separate_attention_slot: await self._refresh_attention( session_id, channel_id, @@ -652,8 +658,7 @@ async def _claim_thread(self, key: tuple[str, str], anchor: _Anchor) -> None: """ if anchor.reaction_ref is None: return - thread_key = (anchor.channel_id, anchor.reaction_ref) - turns = self._thread_turns.setdefault(thread_key, set()) + turns = self._thread_turns.setdefault(self._thread_key(anchor), set()) first = not turns if not first or await self._mark_thread(anchor, working=True): turns.add(key) @@ -676,7 +681,7 @@ async def _release_thread(self, key: tuple[str, str], anchor: _Anchor) -> bool: """ if anchor.reaction_ref is None: return True - thread_key = (anchor.channel_id, anchor.reaction_ref) + thread_key = self._thread_key(anchor) turns = self._thread_turns.get(thread_key) if turns is not None: turns.discard(key) @@ -688,11 +693,26 @@ async def _release_thread(self, key: tuple[str, str], anchor: _Anchor) -> bool: key, anchor.channel_id, anchor.reaction_ref, + agent_name=anchor.agent_name if self._reactions_per_agent else None, sessions=record.sessions if record else self._journal.sessions, ): return True return await self._mark_thread(anchor, working=False) + def _thread_key(self, anchor: _Anchor) -> tuple[str, str, str]: + """Who is holding what, keyed by whose reaction it actually is. + + Where each agent reacts as its own bot the marks are independent, so + one agent finishing must not read another's claim as its own and leave + its own eyes on the message for good. Where every agent shares a bot + there is one reaction between them, and scoping the key per agent + would have the second agent's claim try to add a reaction that is + already there and the first agent's end remove one the second still + wants. + """ + agent = anchor.agent_name if self._reactions_per_agent else "" + return (anchor.channel_id, anchor.reaction_ref or "", agent) + async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: """Put `:eyes:` on the message that actually asked, or take it off. @@ -710,6 +730,7 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: await self._adapter.mark_activity( anchor.channel_id, anchor.reaction_ref, + agent_name=anchor.agent_name, working=working, **({"force": True} if self._journal else {}), ) @@ -752,15 +773,27 @@ def __init__( adapter: CollaborationAdapter, *, bridge_id: str, + surface: str, posts: SessionRequestPostStore, session_factory: async_sessionmaker[AsyncSession], ) -> None: self._adapter = adapter self._bridge_id = bridge_id + self._surface = surface self._posts = posts self._session_factory = session_factory self._reported_edit_failures: dict[str, tuple[int, str]] = {} + @property + def surface(self) -> str: + """Which platform these cards are posted on. + + Read by the publisher rather than passed to it separately: the cards + and the activity it drives are one bridge's, and two places to say + which one is two places to say it differently. + """ + return self._surface + async def post( self, request: SnapshotRequest, @@ -999,8 +1032,12 @@ async def refresh( seconds until something moves it on. """ reference = RequestReference(token=post.token, handle=post.handle) + # Only an answer given on this very platform has a handle this channel + # would recognise. Someone who answered from the console may well have + # a claimed identity here too, but naming them by it would say they + # answered where they did not. responder_external_id = None - if request.decided_by and request.decided_by.surface == "slack": + if request.decided_by and request.decided_by.surface == self._surface: async with self._session_factory() as db: responder_external_id = await db.scalar( select(ExternalUser.external_user_id) diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index bac290809..c27aebd37 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -13,7 +13,13 @@ from dataclasses import dataclass -from switch_core.sessions.contract import TURN_ENDED, Item, TurnUpsert +from switch_core.sessions.contract import ( + TURN_ENDED, + Item, + Question, + Surface, + TurnUpsert, +) # Where the turn itself has got to. One message per turn is edited in place, so # without this a turn that finished and a turn that stalled read identically: @@ -81,6 +87,95 @@ def _format_duration(elapsed_seconds: float) -> str: return f"{minutes}m {seconds}s" if minutes else f"{seconds}s" +# How a request that was never answered ended, in the words a reader sees. One +# copy rather than one per renderer: two platforms describing the same outcome +# differently is a difference a reader would take for a difference in what +# actually happened. +CLOSED = { + "cancelled": "Cancelled before it was answered.", + "expired": "Expired before it was answered.", + "interrupted": "Interrupted before it was answered.", + "provider-error": "The provider failed before it was answered.", +} + +# Where the person who answered was, in the words a reader of that platform +# would use for it. +SURFACES: dict[Surface, str] = { + "console": "the console", + "switch-web": "Switch", + "slack": "Slack", + "mattermost": "Mattermost", + "discord": "Discord", + "teams": "Teams", + "telegram": "Telegram", +} + +# An approval with nothing to choose from. The same defect as a form with no +# questions in it (see `unanswerable`) and refused the same way: there is no +# number to type, no word to say and nothing to press, so an instruction here +# would be one the resolver goes on to refuse. +NO_OPTIONS = "This card cannot be answered: it offers no options." + + +def unanswerable(questions: list[Question]) -> str | None: + """What a card says instead of an instruction, when there is no answering it. + + Two shapes reach this, and they are the same defect a question apart. A + question offering nothing to choose and taking no written answer cannot be + answered on any surface β€” there is no number to type and words are refused β€” + and because every question has to be answered for the answer to be sent at + all, one of them stops the whole form. A form with no questions in it has + nothing to say back either: there is no number, no word and no button, and + the grammar has no shape for an answer to nothing. + + Both are the host's mistake rather than the reader's, so the card says so + where a person can see the session is stuck on it, instead of printing an + instruction the resolver would then refuse. + + The contract permits both β€” `questions` has no minimum length in either + reader β€” and this is the wrong place to start forbidding them: rejecting + the event would cost the whole snapshot rather than one card, and the + Python reader would refuse a shape the TypeScript one accepts. So the + refusal is on the card, where it is visible and costs nothing else. + + Plain words with no markup in them, so every platform's renderer reads the + same refusal rather than keeping its own to drift. + """ + if not questions: + return "This card cannot be answered: it asks no questions." + stuck = [ + position + for position, question in enumerate(questions, start=1) + if not question.options and not question.allow_custom_answer + ] + if not stuck: + return None + where = ( + "" + if len(questions) == 1 + else " on " + ", ".join(f"q{position}" for position in stuck) + ) + return ( + f"This card cannot be answered: nothing to choose{where}, " + "and no written answer allowed." + ) + + +def example_value(question: Question) -> str: + """The part of a typed answer that stands for one question. + + Built from the question rather than fixed, because the shapes need + different things said: a list is answered by number, a list that takes + more than one by several, and a question with nothing to number is + answered in words. + """ + if not question.options: + return '"your answer"' + if question.multi_select and len(question.options) > 1: + return "1,2" + return "1" + + # Every platform that puts controls on a message gives each one an id it hands # straight back when it is operated. The prefix marks the ones this layer wrote, # so a control belonging to something else in the same channel is left alone. diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 29429b05d..0a0916eb5 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -1,26 +1,103 @@ -"""A turn, for a platform with no card of its own for one yet. - -Slack has a card: the activity block plus `render_activity_text`, a -twenty-message notification string bounded at Slack's own 39,000 characters, -built to stand in for blocks that usually render (`slack.py`). This is not a -smaller version of that. It is what a turn falls back to on a platform with no -card behind it at all, so it carries only what a reader actually came for: the -last thing the agent said, and whether the turn is still going. -`render_activity_text` stays exactly where it is β€” nothing here replaces it, -and nothing on Slack reads this instead. - -`escape` and `limit` are the platform's: the last thing said is host text and -needs neutralising the way that platform's body text does, and the result has -to fit inside one message rather than assume there is room to spare. +"""A turn and a request, for a platform that draws them in plain body text. + +Slack has cards: Block Kit for the turn and for the request, with the text +forms beside them as the notification string (`slack.py`). Nothing here is a +smaller version of those. This is what a platform gets when the message body +*is* the artefact β€” Mattermost today β€” so the shapes are chosen for a reader +looking at a paragraph rather than at a card, and for a writer who has one +message to say everything in. + +Three renderings live here: + +- `turn_summary` β€” the oldest and the least: the last thing the agent said and + whether the turn is still going. What a platform falls back to with no + activity presentation of its own. +- `turn_status` β€” the compact one a platform edits in place while a turn runs: + where the turn got to, how long it has been going, what it is doing now, how + the tool calls went, and one link to the Console. One message, edited, never + a second one. +- `request_summary` β€” the text form of a request, in every state it can be in, + with the typed-answer grammar the card is asking for spelled out against + this particular form. + +`escape` and `limit` are the platform's: every value that came from a host is +host text and needs neutralising the way that platform's body text does, and +the result has to fit inside one message rather than assume there is room to +spare. Markdown is assumed β€” emphasis, a numbered list, an inline link β€” which +is what the platforms without a card renderer render today; a platform that +parses something else supplies its own renderer rather than bending this one. """ from __future__ import annotations from collections.abc import Callable -from switch_core.sessions.contract import Item, SnapshotRequest, TurnUpsert +from switch_core.sessions.contract import ( + TURN_ENDED, + ApprovalContent, + ApprovalResult, + DecidedBy, + Item, + Question, + QuestionOption, + QuestionsContent, + QuestionsResult, + SnapshotRequest, + TurnUpsert, +) + +from . import ( + CLOSED, + NO_OPTIONS, + SURFACES, + RequestReference, + example_value, + turn_state, + unanswerable, +) + +# Only these reach a reader as a link. A scheme outside the set is printed as +# the plain text it is rather than wrapped in link syntax: `javascript:` in an +# anchor is the one thing a rendered URL must never become, and a platform +# that refuses an unknown scheme would render the syntax instead of the link. +_LINK_SCHEMES = ("https://", "http://", "switchdash://") + +_CONSOLE = "Open in Switch Console" + +# What a card says when it could not show all of itself. The reader is told the +# count rather than left to notice, and pointed somewhere the whole of it is. +_CUT = "…{left} more not shown. {console} to see the rest." + +# How a tool call went, in one character: read at a glance and down the left +# edge of a line rather than as a sentence. The same glyphs `slack.py` uses, +# so the two platforms do not spell the same outcome differently. +_OUTCOME = { + "in-progress": "β–Έ", + "completed": "βœ“", + "failed": "βœ—", + "declined": "⊘", +} -from . import RequestReference, turn_state +_OUTCOME_WORDS = { + "in-progress": "running", + "completed": "done", + "failed": "failed", + "declined": "declined", +} + +_HEADINGS = { + "open": "Permission needed", + "submitting": "Permission needed", + "resolved": "Permission answered", + "closed": "Permission request closed", +} + +_QUESTION_HEADINGS = { + "open": "Questions", + "submitting": "Questions", + "resolved": "Questions answered", + "closed": "Questions closed", +} def turn_summary( @@ -60,29 +137,521 @@ def turn_summary( return f"{body}\n{state}" +def turn_status( + items: list[Item], + turn: TurnUpsert, + *, + escape: Callable[[str], str], + limit: int, + elapsed_seconds: float | None = None, + session_url: str | None = None, + mention: str | None = None, + error_summary: str | None = None, +) -> str: + """A turn's progress, compact enough to live in one message that is edited. + + Deliberately not the transcript. The agent's reply reaches the channel on + its own, as a message, the way it always has; this is the thing beside it + that says the turn is still running and roughly what it is doing. Repeating + the reply here would put every paragraph in the channel twice β€” which is + what `turn_summary` does, and why a platform publishing SDK sessions wants + this instead. + + Three lines at most, and usually one: + + - where the turn got to and how long it has taken, with one Console link; + - what it is doing right now, while it is still doing something; + - how the tool calls went, once there is more than one outcome to report. + + `error_summary` is the attention slot rather than the status: a distinct + problem somebody has to act on, said in one sentence with the mention that + makes it reach them. When it is set, that is the whole message β€” the state + line under it would only be reporting a turn that is, by definition, not + getting anywhere. + + `mention` is already in the platform's own syntax and is not escaped: it is + the adapter's, resolved from an id Switch holds, never host text. + """ + if error_summary: + return _mentioned( + mention, _truncate(f"⚠️ {error_summary}", _room(limit, mention)) + ) + + budget = _room(limit, mention) + state = turn_state(items, turn, elapsed_seconds=elapsed_seconds) + head = f"**{state}**" + link = _link(_CONSOLE, session_url) + if link and len(head) + 3 + len(link) <= budget: + head = f"{head} Β· {link}" + + lines = [head] + spent = len(head) + did = [item for item in items if item.kind == "tool-activity"] + for line in _doing(did, turn, escape=escape, budget=budget): + if spent + len(line) + 1 > budget: + break + lines.append(line) + spent += len(line) + 1 + return _mentioned(mention, "\n".join(lines)) + + +def _doing( + did: list[Item], + turn: TurnUpsert, + *, + escape: Callable[[str], str], + budget: int, +) -> list[str]: + """The optional lines under the state: what is running, and how it is going. + + Both are dropped when the state line already says it. A turn that has ended + gets its total from `turn_state` ("Worked for 2m 5s. 7 tool calls."), so the + only count worth adding is one the total hides β€” a call that failed or was + declined reads as a completed turn otherwise. + """ + if not did: + return [] + lines: list[str] = [] + ended = turn.status in TURN_ENDED + if not ended: + current = next( + (item for item in reversed(did) if item.status == "in-progress"), None + ) + item = current or did[-1] + label = "Now" if current else "Last" + title = item.title or "Tool call" + lines.append(f"{label}: {_fit(title, max(1, budget // 4), escape=escape)}") + + counts = { + status: sum(1 for item in did if item.status == status) for status in _OUTCOME + } + unwell = counts["failed"] + counts["declined"] + if ended and not unwell: + return lines + tally = " Β· ".join( + f"{_OUTCOME[status]} {count} {_OUTCOME_WORDS[status]}" + for status, count in counts.items() + if count + ) + if tally: + lines.append(tally) + return lines + + def request_summary( request: SnapshotRequest, reference: RequestReference, *, escape: Callable[[str], str], limit: int, + responder: str | None = None, + unavailable_reason: str | None = None, +) -> str: + """The text form of a request: the question, the options, and how to answer. + + The same function serves the first post and every edit after it, because + the card is one message edited in place and the state it is in is the whole + of what changes. An open request offers its numbered options and says what + to type; a settled one drops them and says what became of it, because a + form still asking a settled question is a form asking for an answer that + cannot land. + + The footer always survives the budget. Everything above it can be cut β€” + with the count and a route to the whole of it said out loud β€” but the line + telling a reader how to answer is the one line the message exists for. + + `responder` names whoever is answering or answered, in the platform's own + syntax, and is not escaped: the adapter resolved it from an id Switch + holds. Without one the card falls back to the Switch identity, which is + correct but not a name anybody in the channel recognises. + + `unavailable_reason` replaces the instruction rather than joining it. It + exists for a card that cannot be answered where it is showing, and leaving + "Reply with `R42 1`" underneath would invite exactly the answer that is + about to be refused. + """ + content = request.content + if isinstance(content, ApprovalContent): + head, body, footer = _approval_form( + request, content, reference, escape=escape, limit=limit, responder=responder + ) + else: + head, body, footer = _questions_form( + request, content, reference, escape=escape, limit=limit, responder=responder + ) + if unavailable_reason and request.state in {"open", "submitting"}: + body = [] + footer = _fit(unavailable_reason, max(1, limit // 3), escape=escape) + return _compose(head, body, footer, limit=limit) + + +def _approval_form( + request: SnapshotRequest, + content: ApprovalContent, + reference: RequestReference, + *, + escape: Callable[[str], str], + limit: int, + responder: str | None, +) -> tuple[list[str], list[str], str]: + handle = escape(reference.handle) + head = [f"**{_HEADINGS[request.state]}** Β· request `{handle}`"] + head.append(_fit(content.title, _share(limit, 1500, 3), escape=escape)) + if content.detail: + head.append(_fit(content.detail, _share(limit, 1200, 4), escape=escape)) + + body: list[str] = [] + if request.state == "open": + body = [ + f"{index}. {_fit(option.label, _share(limit, 150, 8), escape=escape)}" + for index, option in enumerate(content.options, start=1) + ] + return ( + head, + body, + _approval_footer( + request, content, handle, escape=escape, limit=limit, responder=responder + ), + ) + + +def _approval_footer( + request: SnapshotRequest, + content: ApprovalContent, + handle: str, + *, + escape: Callable[[str], str], + limit: int, + responder: str | None, +) -> str: + if request.state == "open": + if not content.options: + return NO_OPTIONS + # A code span, because the reader is meant to copy this and quote marks + # around it are not part of the answer: `"R42 1"` parses as a handle of + # `"R42`, which resolves to nothing and changes nothing on the card. + # The grammar strips the backticks the span is drawn from. + return f"Reply with `{handle} 1`." + if request.state == "submitting": + return _in_flight(request, responder=responder, limit=limit, escape=escape) + if request.state == "resolved": + return _approval_answer( + request, content, escape=escape, limit=limit, responder=responder + ) + return _closed(request, escape=escape, limit=limit, responder=responder) + + +def _approval_answer( + request: SnapshotRequest, + content: ApprovalContent, + *, + escape: Callable[[str], str], + limit: int, + responder: str | None, +) -> str: + settled = request.result + result = settled.result if settled else None + by = _by(request.decided_by, responder=responder, limit=limit, escape=escape) + if not isinstance(result, ApprovalResult): + return f"Answered{by}, but the host did not say which option was chosen." + chosen = next( + (option for option in content.options if option.option_id == result.option_id), + None, + ) + # An option the content never offered is still named rather than hidden: + # the id is what the host said, and saying nothing would read as a plain + # answer to a question that was not the one asked. + label = _fit( + chosen.label if chosen else result.option_id, + _share(limit, 150, 8), + escape=escape, + ) + scope = ( + " (applies for the rest of this session)" + if chosen and chosen.decision == "acceptForSession" + else "" + ) + return f"{label}{scope} β€” chosen{by}." if by else f"{label}{scope}." + + +def _questions_form( + request: SnapshotRequest, + content: QuestionsContent, + reference: RequestReference, + *, + escape: Callable[[str], str], + limit: int, + responder: str | None, +) -> tuple[list[str], list[str], str]: + handle = escape(reference.handle) + head = [ + f"**{_QUESTION_HEADINGS[request.state]}** Β· request `{handle}`", + _fit(content.title, _share(limit, 1500, 3), escape=escape), + ] + + body: list[str] = [] + if request.state == "open": + for position, question in enumerate(content.questions, start=1): + title = ( + _fit(question.title, _share(limit, 150, 8), escape=escape) + if question.title + else "" + ) + body.append(f"**{position}. {title}**" if title else f"**{position}.**") + if question.prompt: + body.append(_fit(question.prompt, _share(limit, 800, 4), escape=escape)) + body += [ + _option_line(index, option, escape=escape, limit=limit) + for index, option in enumerate(question.options, start=1) + ] + return ( + head, + body, + _questions_footer( + request, content, handle, escape=escape, limit=limit, responder=responder + ), + ) + + +def _option_line( + index: int, + option: QuestionOption, + *, + escape: Callable[[str], str], + limit: int, ) -> str: - """The text form of a request, for a platform with no card renderer of its own. - - Not implemented. There is no off-Slack request renderer yet to write this - against, and building it now β€” title, detail, per-option or per-question - lines, a footer, each bounded to fit inside `limit` after `escape` β€” would - be the speculative renderer this plan already decided not to build (see - "Session activity in Slack β€” open questions", Β§6). Implement this - alongside the first one, using the cut-then-escape, raise-rather-than-cut - rules `turn_summary` and `_fit` already follow. Until then, `post_rich`'s - base raises through this rather than guessing at a shape. + line = f"{index}. {_fit(option.label, _share(limit, 150, 8), escape=escape)}" + if option.description: + line += f" β€” {_fit(option.description, _share(limit, 200, 8), escape=escape)}" + return line + + +def _questions_footer( + request: SnapshotRequest, + content: QuestionsContent, + handle: str, + *, + escape: Callable[[str], str], + limit: int, + responder: str | None, +) -> str: + if request.state == "open": + stuck = unanswerable(content.questions) + if stuck is not None: + return stuck + example = f"`{_example(handle, content.questions)}`" + if len(content.questions) > 1: + return f"Reply with {example} β€” every question needs an answer." + return f"Reply with {example}." + if request.state == "submitting": + return _in_flight(request, responder=responder, limit=limit, escape=escape) + if request.state == "resolved": + return _questions_answer( + request, content, escape=escape, limit=limit, responder=responder + ) + return _closed(request, escape=escape, limit=limit, responder=responder) + + +def _example(handle: str, questions: list[Question]) -> str: + """What answering this form actually looks like, typed out. + + Built from the form rather than fixed, because the shapes need different + things said: one question takes a number on its own, several need saying + which is which, and a question with nothing to number is answered in words. + Only ever called for a form that has questions and every one of which can + be answered, so there is always something for each part to say. """ - raise NotImplementedError( - "No neutral request card form yet β€” implement alongside the first " - "off-Slack request renderer, using the escape+budget pattern " - "turn_summary already uses." + values = [example_value(question) for question in questions] + if len(values) == 1: + return f"{handle} {values[0]}" + return f"{handle} " + "; ".join( + f"q{position}={value}" for position, value in enumerate(values, start=1) + ) + + +def _questions_answer( + request: SnapshotRequest, + content: QuestionsContent, + *, + escape: Callable[[str], str], + limit: int, + responder: str | None, +) -> str: + settled = request.result + result = settled.result if settled else None + by = _by(request.decided_by, responder=responder, limit=limit, escape=escape) + # An empty `answers` is as legal as a missing result and says as little: + # neither reader gives the list a minimum length, and a card settled from + # the console or by a host that answers nothing lands here. + if not isinstance(result, QuestionsResult) or not result.answers: + return f"Answered{by}, but the host did not say what was chosen." + labels = { + option.option_id: option.label + for question in content.questions + for option in question.options + } + titles = {question.question_id: question.title for question in content.questions} + + # Budgeted on the escaped text and one whole answer at a time. Cutting the + # joined string afterwards can land the cut inside whatever the escape + # produced and show the reader half of it. + label_budget = _share(limit, 150, 8) + room = _share(limit, 1800, 3) + said: list[str] = [] + spent = 0 + for position, answer in enumerate(result.answers, start=1): + chosen = [ + _fit(labels.get(x, x), label_budget, escape=escape) + for x in answer.selected_option_ids + ] + if answer.custom_text: + chosen.append(f"β€œ{_fit(answer.custom_text, label_budget, escape=escape)}”") + title = _fit( + titles.get(answer.question_id, answer.question_id), + label_budget, + escape=escape, + ) + part = f"{title}: {', '.join(chosen) if chosen else 'nothing'}" + if spent + len(part) + 2 > room: + said.append(f"…and {len(result.answers) - position + 1} more") + break + said.append(part) + spent += len(part) + 2 + answered = "; ".join(said) + return f"{answered} β€” answered{by}." if by else f"{answered}." + + +def _in_flight( + request: SnapshotRequest, + *, + responder: str | None, + limit: int, + escape: Callable[[str], str], +) -> str: + if request.decided_by is None: + return "An answer is on its way." + actor = _actor(request.decided_by, responder=responder, limit=limit, escape=escape) + return f"Answering: {actor}." + + +def _closed( + request: SnapshotRequest, + *, + escape: Callable[[str], str], + limit: int, + responder: str | None, +) -> str: + settled = request.result + if settled is None: + return "Closed without being answered." + # A closed request reporting `answered` contradicts itself. Say both rather + # than pick one, and never the word that would read as a decision. + summary = CLOSED.get( + settled.outcome, f"Closed, though the host called it {settled.outcome}." ) + if request.decided_by is not None: + actor = _actor( + request.decided_by, responder=responder, limit=limit, escape=escape + ) + summary += f" Decided by {actor}." + return summary + + +def _by( + decided_by: DecidedBy | None, + *, + responder: str | None, + limit: int, + escape: Callable[[str], str], +) -> str: + if decided_by is None: + return "" + actor = _actor(decided_by, responder=responder, limit=limit, escape=escape) + return f" by {actor}" + + +def _actor( + decided_by: DecidedBy, + *, + responder: str | None, + limit: int, + escape: Callable[[str], str], +) -> str: + """Who answered, named the way the channel knows them where that is known. + + `responder` is the platform handle the adapter resolved for this decision; + it only exists when the answer was given on this very platform, so where + there is none the Switch identity is the only true thing to say. + """ + where = SURFACES[decided_by.surface] + if responder: + return f"{responder} from {where}" + return f"{_fit(decided_by.actor_id, _share(limit, 200, 8), escape=escape)} from {where}" + + +def _compose(head: list[str], body: list[str], footer: str, *, limit: int) -> str: + """Head, as much of the body as fits, then the footer β€” which always survives. + + The body is what gets dropped because it is the part a reader can recover + elsewhere: an option they cannot see is still an option, and the notice + says where the whole list is. The footer is not recoverable that way β€” it + is the instruction for answering *here* β€” so it is measured first and the + rest is spent around it. + """ + footer = _truncate(footer, limit) + spent = len(footer) + lines: list[str] = [] + for line in head: + if spent + len(line) + 1 > limit: + break + lines.append(line) + spent += len(line) + 1 + + shown: list[str] = [] + for line in body: + if spent + len(line) + 1 > limit: + break + shown.append(line) + spent += len(line) + 1 + if len(shown) < len(body): + notice = _CUT.format(left=len(body) - len(shown), console=_CONSOLE) + while shown and spent + len(notice) + 1 > limit: + spent -= len(shown.pop()) + 1 + notice = _CUT.format(left=len(body) - len(shown), console=_CONSOLE) + if spent + len(notice) + 1 <= limit: + shown.append(notice) + return "\n".join([*lines, *shown, footer]) + + +def _mentioned(mention: str | None, body: str) -> str: + return f"{mention} {body}" if mention else body + + +def _room(limit: int, mention: str | None) -> int: + return max(1, limit - (len(mention) + 1 if mention else 0)) + + +def _link(label: str, url: str | None) -> str: + """`url` as Markdown, or nothing at all if it is not a scheme worth linking.""" + if not url or not url.startswith(_LINK_SCHEMES): + return "" + # A `)` inside the destination closes the link early and spills the rest of + # the URL into the body as text. Percent-encoding is the one transform that + # keeps the link working and cannot be read as syntax. + return f"[{label}]({url.replace(')', '%29')})" + + +def _share(limit: int, most: int, denominator: int) -> int: + """One value's budget: `most` characters, or a share of a tighter limit. + + The fixed ceilings are `slack.py`'s, so a value is cut to the same length + on either platform wherever the platform's own limit leaves room for it. + The share is what keeps a small `limit` from being spent entirely on the + first value that reaches it β€” and `_compose` still bounds the whole + message afterwards, so this is about fairness between values rather than + about the message fitting. + """ + return max(1, min(most, limit // denominator)) def _truncate(text: str, limit: int) -> str: diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 9830d05f2..aacf918d2 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -46,11 +46,19 @@ QuestionsContent, QuestionsResult, SnapshotRequest, - Surface, TurnUpsert, ) -from . import ANSWER_ACTION, RequestReference, turn_state +from . import ( + ANSWER_ACTION, + CLOSED, + NO_OPTIONS, + SURFACES, + RequestReference, + example_value, + turn_state, + unanswerable, +) # Slack's own limits. Exceeding one is rejected at the API, so it is caught here # where the offending value can still be named. @@ -166,27 +174,6 @@ "closed": "Questions closed", } -# Where the person who answered was, in the words a reader of that platform -# would use for it. -_SURFACES: dict[Surface, str] = { - "console": "the console", - "switch-web": "Switch", - "slack": "Slack", - "mattermost": "Mattermost", - "discord": "Discord", - "teams": "Teams", - "telegram": "Telegram", -} - -# Every outcome but `answered`. Each says the request was not answered, because -# a closed request that reads as answered is the one mistake this must not make. -_CLOSED = { - "cancelled": "Cancelled before it was answered.", - "expired": "Expired before it was answered.", - "interrupted": "Interrupted before it was answered.", - "provider-error": "The provider failed before it was answered.", -} - @dataclass(frozen=True) class SlackMessage: @@ -464,13 +451,10 @@ def _footer( """ if request.state == "open": if not content.options: - # The same shape as a form with no questions in it, and refused the - # same way and for the same reasons: there is no number to type, no - # word to say and nothing to press, so an instruction here would be - # one the resolver goes on to refuse. Neither reader of the contract - # gives `options` a minimum length, and the schema is still the - # wrong place to add one β€” see `_unanswerable`. - return "This card cannot be answered: it offers no options." + # Neither reader of the contract gives `options` a minimum + # length, and the schema is still the wrong place to add one β€” see + # `unanswerable`, which refuses the same defect a question apart. + return NO_OPTIONS # A code span, because the reader is meant to copy this and quote marks # around it are not part of the answer: `"R42 1"` parses as a handle of # `"R42`, which resolves to nothing and changes nothing on the card. @@ -487,7 +471,7 @@ def _footer( return "Closed without being answered." # A closed request reporting `answered` contradicts itself. Say both rather # than pick one, and never the word that would read as a decision. - summary = _CLOSED.get( + summary = CLOSED.get( settled.outcome, f"Closed, though the host called it {settled.outcome}." ) if request.decided_by is not None: @@ -519,7 +503,7 @@ def _answered(request: SnapshotRequest, content: ApprovalContent) -> str: def _actor(decided_by: DecidedBy) -> str: return ( - f"{_fit(decided_by.actor_id, _MAX_ACTOR)} from {_SURFACES[decided_by.surface]}" + f"{_fit(decided_by.actor_id, _MAX_ACTOR)} from {SURFACES[decided_by.surface]}" ) @@ -756,7 +740,7 @@ def _questions_footer( budget that measures it. """ if request.state == "open": - stuck = _unanswerable(content.questions) + stuck = unanswerable(content.questions) if stuck is not None: return stuck example = f"`{_example(reference.handle, content.questions)}`" @@ -774,7 +758,7 @@ def _questions_footer( settled = request.result if settled is None: return "Closed without being answered." - summary = _CLOSED.get( + summary = CLOSED.get( settled.outcome, f"Closed, though the host called it {settled.outcome}." ) if request.decided_by is not None: @@ -782,47 +766,6 @@ def _questions_footer( return summary -def _unanswerable(questions: list[Question]) -> str | None: - """What the card says instead of an instruction, when there is no answering it. - - Two shapes reach this, and they are the same defect a question apart. A - question offering nothing to choose and taking no written answer cannot be - answered on any surface β€” there is no number to type and words are refused β€” - and because every question has to be answered for the answer to be sent at - all, one of them stops the whole form. A form with no questions in it has - nothing to say back either: there is no number, no word and no button, and - the grammar has no shape for an answer to nothing. - - Both are the host's mistake rather than the reader's, so the card says so - where a person can see the session is stuck on it, instead of printing an - instruction the resolver would then refuse. - - The contract permits both β€” `questions` has no minimum length in either - reader β€” and this is the wrong place to start forbidding them: rejecting - the event would cost the whole snapshot rather than one card, and the - Python reader would refuse a shape the TypeScript one accepts. So the - refusal is on the card, where it is visible and costs nothing else. - """ - if not questions: - return "This card cannot be answered: it asks no questions." - stuck = [ - position - for position, question in enumerate(questions, start=1) - if not question.options and not question.allow_custom_answer - ] - if not stuck: - return None - where = ( - "" - if len(questions) == 1 - else " on " + ", ".join(f"q{position}" for position in stuck) - ) - return ( - f"This card cannot be answered: nothing to choose{where}, " - "and no written answer allowed." - ) - - def _example(handle: str, questions: list[Question]) -> str: """What answering this form actually looks like, typed out. @@ -832,7 +775,7 @@ def _example(handle: str, questions: list[Question]) -> str: Only ever called for a form that has questions and every one of which can be answered, so there is always something for each part to say. """ - values = [_example_value(question) for question in questions] + values = [example_value(question) for question in questions] if len(values) == 1: return f"{escape_mrkdwn(handle)} {values[0]}" return f"{escape_mrkdwn(handle)} " + "; ".join( @@ -840,14 +783,6 @@ def _example(handle: str, questions: list[Question]) -> str: ) -def _example_value(question: Question) -> str: - if not question.options: - return '"your answer"' - if question.multi_select and len(question.options) > 1: - return "1,2" - return "1" - - def _answered_questions(request: SnapshotRequest, content: QuestionsContent) -> str: settled = request.result result = settled.result if settled else None diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 549807bce..91e0f0c93 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -162,6 +162,7 @@ class SlackConnectionConfig(BridgeConnectionConfig): class SlackAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True separate_activity_log: ClassVar[bool] = True + separate_attention_slot: ClassVar[bool] = True supports_activity_reactions: ClassVar[bool] = True renders_legacy_runtime_state: ClassVar[bool] = False @@ -1052,12 +1053,23 @@ async def _mark_being_read( ) async def mark_activity( - self, channel_id: str, message_ref: str, *, working: bool, force: bool = False + self, + channel_id: str, + message_ref: str, + *, + agent_name: str, + working: bool, + force: bool = False, ) -> None: """Mark the asking message, accepting either a timestamp or channel:ts. The SDK publication journal owns concurrent turn claims. ``force`` reconciles Slack's reaction after a restart despite the local cache. + + `agent_name` is not used: every agent speaks as the one app here, so + there is a single reaction on the message whoever is working behind + it, and adding it twice or removing one agent's while another is + still running would both be the same mark. """ await self._mark_being_read( channel_id, self._thread_ts_of(message_ref), working=working, force=force diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index cf8dd5b3a..14690927f 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -404,6 +404,7 @@ async def refresh_activity( session_id: str, activity: SessionTurnActivity, *, + surface: str, gateway_public_url: str | None = None, retry_allowed: Callable[[str], bool] = _always_recover, retry_succeeded: Callable[[str], None] = _ignore_recovery, @@ -458,8 +459,12 @@ async def refresh_activity( turns = list(snapshot.turns) latest_turn_id = turns[-1].turn_id if turns else None known_commands = {turn.command_id for turn in turns} - # A presentation-only queued turn acknowledges accepted Slack input before - # the SDK reports a turn. Never write synthetic turns into the contract. + # A presentation-only queued turn acknowledges input this bridge itself + # accepted, before the SDK reports a turn. Scoped to commands that came + # in on `surface` because that is where the acknowledgement would go: a + # command typed in the console has no message in this channel to answer, + # and a turn drawn for it would be this bridge announcing work nobody + # here asked for. Never write synthetic turns into the contract. for pending_command in await db.scalars( select(SdkSessionCommand) .where( @@ -475,7 +480,7 @@ async def refresh_activity( if ( command.command_id in known_commands or command.epoch != row.epoch - or command.origin.surface != "slack" + or command.origin.surface != surface or command.body.type != "message.send" ): continue @@ -811,6 +816,7 @@ def __init__( self._sessions = session_factory self._gateway_public_url = gateway_public_url self._bridge_id = bridge_id + self._surface = cards.surface self._cards = cards self._activity = activity self._published: dict[str, tuple[int, bool]] = {} @@ -859,6 +865,7 @@ async def publish_pending(self) -> None: self._bridge_id, session_id, self._activity, + surface=self._surface, **( {"gateway_public_url": self._gateway_public_url} if self._gateway_public_url diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py index 62a57fa17..35db6256e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py @@ -8,6 +8,14 @@ So the rule these tests hold the adapter to is blunt: nothing it posts is ever deleted. The status line is edited into a terminal marker at the end of a turn, and it does not move while the turn runs. + +These drive `_apply_runtime_state` rather than the public entry point because +the public one no longer reaches it: Mattermost now publishes SDK sessions and +declares `renders_legacy_runtime_state = False`, so the base class stops the +legacy path before the adapter sees it. The implementation is still here and +still correct; what it no longer has is a caller. Removing it is its own task β€” +until then these keep it honest, and +`test_mattermost_sdk_only.py` covers the disabled ingress itself. """ from __future__ import annotations @@ -109,7 +117,7 @@ def test_the_indicator_stays_put_instead_of_following_the_conversation() -> None recorder.install(adapter) _seed_indicator(adapter, thread_root_id="root-9") - _run(adapter.reposition_runtime_state("chan-1", "worker", "root-42")) + _run(adapter._reposition_runtime_state("chan-1", "worker", "root-42")) assert recorder.deletes == [] assert recorder.sends == [] @@ -125,7 +133,7 @@ def test_a_finished_turn_retires_the_indicator_in_place() -> None: _seed_indicator(adapter, age_seconds=134) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None ) ) @@ -145,7 +153,7 @@ def test_the_done_marker_carries_no_session_link() -> None: _seed_indicator(adapter, age_seconds=8) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "chan-1", "worker", "idle", @@ -165,7 +173,7 @@ def test_an_operator_ping_is_resolved_rather_than_removed() -> None: adapter._input_pings[("chan-1", "worker")] = ["ping-1", "ping-2"] _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None ) ) @@ -188,7 +196,7 @@ def test_a_turn_posts_one_status_line_and_deletes_nothing() -> None: async def turn() -> None: for detail in ("Ran tool Bash", "Ran tool Edit", None): - await adapter.apply_runtime_state( + await adapter._apply_runtime_state( "chan-1", "worker", "working", @@ -196,7 +204,7 @@ async def turn() -> None: thread_root_id=None, detail=detail, ) - await adapter.apply_runtime_state( + await adapter._apply_runtime_state( "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None ) @@ -217,7 +225,7 @@ def test_idle_without_an_indicator_does_nothing() -> None: recorder.install(adapter) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None ) ) @@ -234,7 +242,7 @@ def test_the_turn_opens_with_a_typing_nudge_where_the_message_came_from() -> Non recorder.install(adapter) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "chan-1", "worker", "working", @@ -256,7 +264,7 @@ def test_typing_stays_at_the_root_when_that_is_where_the_message_was() -> None: recorder.install(adapter) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "chan-1", "worker", "working", @@ -280,7 +288,7 @@ def test_typing_is_not_repeated_on_every_activity_refresh() -> None: async def turn() -> None: for detail in (None, "Ran tool Edit", "Running tests"): - await adapter.apply_runtime_state( + await adapter._apply_runtime_state( "chan-1", "worker", "working", @@ -301,7 +309,7 @@ def test_a_retired_turn_does_not_nudge() -> None: _seed_indicator(adapter) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None ) ) diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py new file mode 100644 index 000000000..62a54d07b --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -0,0 +1,540 @@ +"""Mattermost publishes SDK sessions, and the legacy renderer no longer runs. + +What is under test here is the rich-content seam: the compact status and the +plain-text request form, posted as the agent's own bot, edited in place, found +again after an uncertain delivery, and β€” unlike the old status line β€” loud when +any of that fails. + +The old runtime-state renderer is still in the file (removing it is its own +task) but nothing routes to it any more. The first test holds that line: the +two renderers must not both draw, or every turn appears twice. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest + +from switch_core.bridges.collaboration.adapter import ( + RequestCard, + RichContentFailed, + TurnActivity, +) +from switch_core.bridges.collaboration.mattermost.adapter import ( + MattermostAdapter, + MattermostConnectionConfig, +) +from switch_core.bridges.collaboration.session.outbound import SessionTurnActivity +from switch_core.bridges.collaboration.session.renderers import RequestReference +from switch_core.bridges.collaboration.session.transport import ( + FixtureEventSource, + project, +) + +from .test_session_activity import _item, _items, _turn + +REPO_ROOT = Path(__file__).resolve().parents[5] +EXAMPLES_PATH = REPO_ROOT / "console/packages/shared/src/session-v1/examples.json" + +_MARKER = "switch_publication" + + +class _FakePosts: + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + self.patched: list[tuple[str, dict[str, Any]]] = [] + self.thread: dict[str, dict[str, Any]] = {} + self.channel: dict[str, dict[str, Any]] = {} + self.create_error: Exception | None = None + self.patch_error: Exception | None = None + self.read_error: Exception | None = None + self.thread_calls: list[str] = [] + self.channel_calls: list[tuple[str, dict[str, Any] | None]] = [] + self._next = iter(f"post-{n}" for n in range(1, 50)) + + def create_post(self, post: dict[str, Any]) -> dict[str, str]: + if self.create_error: + raise self.create_error + self.created.append(post) + return {"id": next(self._next)} + + def patch_post(self, post_id: str, body: dict[str, Any]) -> dict[str, str]: + if self.patch_error: + raise self.patch_error + self.patched.append((post_id, body)) + return {"id": post_id} + + def get_thread(self, root_id: str) -> dict[str, Any]: + self.thread_calls.append(root_id) + if self.read_error: + raise self.read_error + return {"posts": self.thread} + + def get_posts_for_channel( + self, channel_id: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + self.channel_calls.append((channel_id, params)) + if self.read_error: + raise self.read_error + return {"posts": self.channel} + + +class _FakeUsers: + def __init__(self, **users: str) -> None: + self.users = users + self.calls: list[str] = [] + self.error: Exception | None = None + + def get_user(self, user_id: str) -> dict[str, str]: + self.calls.append(user_id) + if self.error: + raise self.error + return {"id": user_id, "username": self.users[user_id]} + + +class _FakeReactions: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str, str]] = [] + + def create_reaction(self, options: dict[str, str]) -> dict[str, str]: + self.calls.append( + ("add", options["user_id"], options["post_id"], options["emoji_name"]) + ) + return options + + def delete_reaction( + self, user_id: str, post_id: str, emoji_name: str + ) -> dict[str, str]: + self.calls.append(("remove", user_id, post_id, emoji_name)) + return {"status": "OK"} + + +class _FakeDriver: + def __init__(self, posts: _FakePosts, users: _FakeUsers) -> None: + self.posts = posts + self.users = users + self.reactions = _FakeReactions() + + +def _adapter(*agents: str, **users: str) -> MattermostAdapter: + adapter = MattermostAdapter( + config=MattermostConnectionConfig( + url="http://mm", + admin_user="admin", + admin_password="pw", + team_name="team", + ) + ) + posts = _FakePosts() + directory = _FakeUsers(**users) + for name in agents or ("worker",): + adapter._agent_bots[name] = {"user_id": f"bot-{name}"} + adapter._bot_drivers[name] = _FakeDriver(posts, directory) # type: ignore[assignment] + adapter._bridge_bot_ids.add(f"bot-{name}") + adapter._admin_driver = _FakeDriver(posts, directory) # type: ignore[assignment] + adapter._main_loop = asyncio.get_event_loop() + return adapter + + +def _posts(adapter: MattermostAdapter) -> _FakePosts: + driver: Any = adapter._admin_driver + return driver.posts + + +def _users(adapter: MattermostAdapter) -> _FakeUsers: + driver: Any = adapter._admin_driver + return driver.users + + +def _activity(**kwargs: Any) -> TurnActivity: + items = [_item(kind="assistant-message", title="", text="Looking now.")] + return TurnActivity(items, _turn("running"), **kwargs) + + +async def _card(**kwargs: Any) -> RequestCard: + source = FixtureEventSource.from_examples(EXAMPLES_PATH, events=[]) + projection = await project(source, "session-demo") + request = projection.open_requests()[0] + return RequestCard(request, RequestReference(token="tok-1", handle="R7"), **kwargs) + + +# ── The legacy renderer is off ─────────────────────────────────────────────── + + +async def test_the_legacy_renderer_no_longer_draws_alongside_the_sdk_one() -> None: + """Both would draw the same turn, and the channel would show it twice.""" + adapter = _adapter() + + for state in ("working", "awaiting-input", "idle"): + await adapter.apply_runtime_state( + "chan-1", + "worker", + state, + mention_handle="@owner", + thread_root_id="root-1", + detail="Private legacy status", + ) + await adapter.reposition_runtime_state("chan-1", "worker", "root-2") + + assert _posts(adapter).created == [] + assert _posts(adapter).patched == [] + assert adapter._working_msg == {} + assert adapter._runtime_locks == {} + + +# ── Posting ────────────────────────────────────────────────────────────────── + + +async def test_a_publication_is_posted_by_the_agents_own_bot_in_its_thread() -> None: + adapter = _adapter("worker", "other") + + ref = await adapter.post_rich( + "chan-1", "worker", _activity(publication_token="tok-turn"), "root-1" + ) + + created = _posts(adapter).created + assert len(created) == 1 + assert created[0]["channel_id"] == "chan-1" + assert created[0]["root_id"] == "root-1" + assert adapter._rich_authors[ref] == "worker" + + +async def test_the_recovery_marker_travels_in_props_where_no_reader_sees_it() -> None: + """A marker in the message body would be visible noise on every status.""" + adapter = _adapter() + + await adapter.post_rich("chan-1", "worker", _activity(publication_token="tok-turn")) + + created = _posts(adapter).created[0] + assert created["props"] == {_MARKER: "tok-turn"} + assert "tok-turn" not in created["message"] + + +async def test_a_card_carries_the_token_a_recovery_search_looks_for() -> None: + adapter = _adapter() + + await adapter.post_rich("chan-1", "worker", await _card()) + + assert _posts(adapter).created[0]["props"] == {_MARKER: "tok-1"} + + +async def test_an_agent_with_no_bot_is_a_failure_and_not_a_quiet_skip() -> None: + """`send_message` would fall back to the admin account. A publication must + not: the post would be attributed to Switch rather than to the agent whose + turn it is, and every later edit would look for a bot that is not there.""" + adapter = _adapter("worker") + + with pytest.raises(RichContentFailed) as excinfo: + await adapter.post_rich("chan-1", "ghost", _activity()) + + assert "ghost" in str(excinfo.value) + assert excinfo.value.text + assert _posts(adapter).created == [] + + +async def test_a_refused_post_raises_with_what_mattermost_said() -> None: + adapter = _adapter() + _posts(adapter).create_error = RuntimeError("403 permission denied") + + with pytest.raises(RichContentFailed) as excinfo: + await adapter.post_rich("chan-1", "worker", _activity()) + + assert isinstance(excinfo.value.__cause__, RuntimeError) + assert "403" in str(excinfo.value) + + +# ── Editing ────────────────────────────────────────────────────────────────── + + +async def test_a_redraw_is_patched_by_the_bot_that_posted_it() -> None: + adapter = _adapter("worker", "other") + ref = await adapter.post_rich("chan-1", "worker", _activity()) + + await adapter.update_rich("chan-1", ref, _activity()) + + driver: Any = adapter._bot_drivers["worker"] + assert driver.posts.patched[0][0] == ref + + +async def test_a_failed_redraw_raises_rather_than_leaving_a_stale_card() -> None: + """`update_message` logs and returns, which would leave a settled request + showing its open form with nobody told.""" + adapter = _adapter() + ref = await adapter.post_rich("chan-1", "worker", await _card()) + _posts(adapter).patch_error = RuntimeError("404 post not found") + + with pytest.raises(RichContentFailed) as excinfo: + await adapter.update_rich("chan-1", ref, await _card()) + + assert isinstance(excinfo.value.__cause__, RuntimeError) + assert excinfo.value.text + + +async def test_a_redraw_does_not_mention_the_recipient_a_second_time() -> None: + """An edit does not notify, so repeating the handle only adds noise to a + message the person it names has already been told about.""" + adapter = _adapter(**{"u-owner": "owner"}) + card = await _card(notify_external_id="u-owner") + ref = await adapter.post_rich("chan-1", "worker", card) + assert "@owner" in _posts(adapter).created[0]["message"] + + await adapter.update_rich("chan-1", ref, card) + + assert "@owner" not in _posts(adapter).patched[0][1]["message"] + + +async def test_an_unresolvable_mention_is_dropped_rather_than_shown_raw() -> None: + """A Mattermost user id in the message notifies nobody and reads as noise.""" + adapter = _adapter() + _users(adapter).error = RuntimeError("404 user not found") + + await adapter.post_rich( + "chan-1", "worker", await _card(notify_external_id="u-missing") + ) + + assert "u-missing" not in _posts(adapter).created[0]["message"] + + +async def test_a_resolved_handle_is_looked_up_once_and_then_remembered() -> None: + adapter = _adapter(**{"u-owner": "owner"}) + card = await _card(notify_external_id="u-owner") + + await adapter.post_rich("chan-1", "worker", card) + await adapter.post_rich("chan-1", "worker", card) + + assert _users(adapter).calls == ["u-owner"] + + +# ── Recovery ───────────────────────────────────────────────────────────────── + + +async def test_an_uncertain_post_is_found_again_by_its_marker() -> None: + adapter = _adapter() + _posts(adapter).thread = { + "post-9": {"id": "post-9", "user_id": "bot-worker", "props": {_MARKER: "tok-1"}} + } + + found = await adapter.find_request_card( + "chan-1", "root-1", "tok-1", datetime.now(UTC) + ) + + assert found == "post-9" + assert _posts(adapter).thread_calls == ["root-1"] + + +async def test_a_token_quoted_by_a_person_is_not_the_post_that_carries_it() -> None: + """Props are settable by anyone posting through the API. Matching the + author as well is what keeps a forged or echoed marker from binding a + reservation to a message the bridge never sent.""" + adapter = _adapter() + _posts(adapter).thread = { + "post-8": {"id": "post-8", "user_id": "u-someone", "props": {_MARKER: "tok-1"}} + } + + assert ( + await adapter.find_request_card("chan-1", "root-1", "tok-1", datetime.now(UTC)) + is None + ) + + +async def test_a_rootless_search_asks_the_channel_from_just_before_the_post() -> None: + adapter = _adapter() + created_at = datetime(2026, 9, 14, 12, 0, tzinfo=UTC) + + await adapter.find_request_card("chan-1", None, "tok-1", created_at) + + channel_id, params = _posts(adapter).channel_calls[0] + assert channel_id == "chan-1" + assert params is not None + assert params["since"] == int(created_at.timestamp() * 1000) - 60_000 + + +async def test_a_search_that_could_not_run_is_not_found_rather_than_a_guess() -> None: + """`None` keeps the reservation and asks again. Anything else risks a + second copy of a card that is meant to be answered exactly once.""" + adapter = _adapter() + _posts(adapter).read_error = RuntimeError("500 server error") + + assert ( + await adapter.find_request_card("chan-1", "root-1", "tok-1", datetime.now(UTC)) + is None + ) + + +# ── Reactions ──────────────────────────────────────────────────────────────── + + +async def test_two_agents_on_one_message_are_two_independent_marks() -> None: + adapter = _adapter("worker", "other") + + await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=True) + await adapter.mark_activity("chan-1", "post-1", agent_name="other", working=True) + await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=False) + + worker: Any = adapter._bot_drivers["worker"] + other: Any = adapter._bot_drivers["other"] + assert worker.reactions.calls == [ + ("add", "bot-worker", "post-1", "eyes"), + ("remove", "bot-worker", "post-1", "eyes"), + ] + assert other.reactions.calls == [("add", "bot-other", "post-1", "eyes")] + + +async def test_a_mark_left_over_from_before_a_restart_is_still_cleared() -> None: + """After a restart the in-process record is empty, but the πŸ‘€ is still in + the channel. Without `force` the removal is skipped as already done.""" + adapter = _adapter() + + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", working=False, force=True + ) + + driver: Any = adapter._bot_drivers["worker"] + assert driver.reactions.calls == [("remove", "bot-worker", "post-1", "eyes")] + + +# ── Bare answers in a thread ───────────────────────────────────────────────── + + +async def test_the_first_reply_is_the_oldest_one_not_the_first_returned() -> None: + """Thread order is not part of the API's contract, and a bare "yes" + deciding a permission is not worth resting on a field that may change.""" + adapter = _adapter() + _posts(adapter).thread = { + "post-c": {"id": "post-c", "create_at": 300}, + "post-a": {"id": "post-a", "create_at": 100}, + "root-1": {"id": "root-1", "create_at": 50}, + } + + assert await adapter.is_first_reply("chan-1", "root-1", "post-a") + assert not await adapter.is_first_reply("chan-1", "root-1", "post-c") + + +async def test_a_deleted_first_reply_does_not_hold_the_position() -> None: + adapter = _adapter() + _posts(adapter).thread = { + "root-1": {"id": "root-1", "create_at": 50}, + "post-a": {"id": "post-a", "create_at": 100, "delete_at": 120}, + "post-b": {"id": "post-b", "create_at": 200}, + } + + assert await adapter.is_first_reply("chan-1", "root-1", "post-b") + + +async def test_an_unreadable_thread_is_not_a_dropped_message( + caplog: pytest.LogCaptureFixture, +) -> None: + """This runs ahead of the relay on every message. Raising here would lose + the message itself, which is a great deal worse than refusing a bare + "yes".""" + adapter = _adapter() + _posts(adapter).read_error = RuntimeError("500 server error") + + with caplog.at_level(logging.WARNING): + assert not await adapter.is_first_reply("chan-1", "root-1", "post-a") + + assert caplog.records + + +# ── How much of the channel a turn takes up ────────────────────────────────── + + +def _turn_kwargs() -> dict[str, Any]: + return { + "session_id": "session-1", + "channel_id": "chan-1", + "thread_root_id": "root-1", + "asked_on": "root-1", + "agent_name": "worker", + "session_url": None, + } + + +async def test_a_whole_turn_is_one_post_edited_rather_than_a_thread_of_them() -> None: + """Slack splits the ticking status from the expandable tool log, because it + can collapse the second one. Mattermost cannot, so a second post would be + the tool list sitting open in the thread for good β€” and the compact status + already carries the counts. One post, edited until the turn ends. + """ + adapter = _adapter() + activity = SessionTurnActivity(adapter) + items = await _items() + + for elapsed, status in ((1, "running"), (30, "running"), (44, "completed")): + await activity.publish( + items, _turn(status), elapsed_seconds=elapsed, **_turn_kwargs() + ) + + assert len(_posts(adapter).created) == 1 + assert {ref for ref, _ in _posts(adapter).patched} == {"post-1"} + + +async def test_a_failure_gets_its_own_reply_and_is_retired_without_deleting_it() -> ( + None +): + """An edit to a status the reader has already scrolled past notifies + nobody, so a problem somebody has to act on arrives as a reply of its own. + Once it clears, that reply is edited rather than removed: Mattermost leaves + "(message deleted)" behind, which is worse than a settled status line. + """ + adapter = _adapter() + activity = SessionTurnActivity(adapter) + items = await _items() + + await activity.publish( + items, + _turn("running"), + elapsed_seconds=2, + error_summary="The session host is offline.", + **_turn_kwargs(), + ) + assert len(_posts(adapter).created) == 2 + assert "The session host is offline." in _posts(adapter).created[1]["message"] + + await activity.publish( + items, _turn("completed"), elapsed_seconds=9, **_turn_kwargs() + ) + + assert len(_posts(adapter).created) == 2 + retired = [body for ref, body in _posts(adapter).patched if ref == "post-2"] + assert retired + assert "The session host is offline." not in retired[-1]["message"] + + +# ── What a reader actually sees ────────────────────────────────────────────── + + +async def test_a_request_form_says_its_handle_and_how_to_answer_it() -> None: + adapter = _adapter() + + await adapter.post_rich("chan-1", "worker", await _card()) + + message = _posts(adapter).created[0]["message"] + assert "`R7`" in message + assert "Reply with `R7 " in message + + +async def test_a_status_stays_inside_one_mattermost_post() -> None: + adapter = _adapter() + items = [_item(kind="assistant-message", title="", text="x" * 9000)] + + await adapter.post_rich("chan-1", "worker", TurnActivity(items, _turn("running"))) + + assert len(_posts(adapter).created[0]["message"]) <= adapter.rich_fallback_limit() + + +async def test_a_turn_that_failed_says_so_instead_of_listing_its_tools() -> None: + adapter = _adapter() + content = replace( + _activity(), error_summary="The session host is offline.", session_url=None + ) + + await adapter.post_rich("chan-1", "worker", content) + + assert "The session host is offline." in _posts(adapter).created[0]["message"] diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py index 8112599b2..b672885f6 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py @@ -7,6 +7,11 @@ It is added by the agent's own bot rather than a shared bridge account, so two agents working on one message show as two marks. + +The two cases here that come in through `_apply_runtime_state` do so directly: +the legacy path that used to call it is disabled now that Mattermost publishes +SDK sessions, and the mark arrives through `mark_activity` instead. Both routes +end in `_mark_being_read`, which is what these are really about. """ from __future__ import annotations @@ -98,7 +103,7 @@ def _run(adapter: MattermostAdapter, *states: tuple[str, str | None]) -> None: async def _body() -> None: adapter._main_loop = asyncio.get_running_loop() for state, thread_root_id in states: - await adapter.apply_runtime_state( + await adapter._apply_runtime_state( "chan-1", "worker", state, @@ -195,7 +200,7 @@ def test_two_agents_on_one_message_each_leave_their_own_mark() -> None: async def _body() -> None: adapter._main_loop = asyncio.get_running_loop() for agent in ("worker", "reviewer"): - await adapter.apply_runtime_state( + await adapter._apply_runtime_state( "chan-1", agent, "working", diff --git a/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py b/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py index d94bed8b9..c6b81c814 100644 --- a/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py +++ b/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py @@ -11,6 +11,7 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import replace from pathlib import Path from typing import Any @@ -173,9 +174,11 @@ async def test_update_rich_falls_back_the_same_way() -> None: async def test_update_rich_does_not_raise_when_the_platform_only_swallows() -> None: - """Mattermost, Discord and (mostly) Telegram log their own update failure - and return normally β€” there is nothing here for the base to detect or - raise on, which is the existing runtime-status contract.""" + """Discord and (mostly) Telegram log their own update failure and return + normally β€” there is nothing here for the base to detect or raise on, which + is the existing runtime-status contract. A platform that publishes SDK + sessions cannot leave it there and overrides this seam to raise; Mattermost + does, and its own tests cover it.""" adapter = _BareAdapter() items = [_item(kind="assistant-message", title="", text="Looking now.")] @@ -224,15 +227,41 @@ def _html_escape(content: str) -> str: assert "&" in content -async def test_a_request_card_has_no_neutral_form_yet() -> None: - """Stubbed on purpose (CHOO-2621): there is no off-Slack request renderer - to write one against yet. `NotImplementedError`, not `RichContentFailed` - β€” this is a missing implementation, not an ordinary posting failure.""" +async def test_a_request_card_falls_back_to_a_form_that_can_be_answered() -> None: + """A card the platform cannot draw as buttons still has to be answerable. + + The typed-answer grammar is the whole fallback: without the handle and an + example of what to type, a reader is looking at a question with no way to + reply to it. + """ adapter = _BareAdapter() content = await _request_card() - with pytest.raises(NotImplementedError): - await adapter.post_rich("C1", "agent", content) + ref = await adapter.post_rich("C1", "agent", content) + + assert ref == "C1:1.0" + text = adapter.sent[0][2] + assert "`R1`" in text + assert "Reply with `R1 " in text + assert "1." in text + assert content.request.content.title in text + + +async def test_a_settled_request_says_what_was_decided_rather_than_how_to_answer() -> ( + None +): + """The form is for answering. Once it is answered it is a record, and + telling a reader to reply to it would be inviting them to answer twice.""" + adapter = _BareAdapter() + card = await _request_card() + content = replace( + card, request=card.request.model_copy(update={"state": "resolved"}) + ) + + await adapter.post_rich("C1", "agent", content) + + text = adapter.sent[0][2] + assert "Reply with" not in text def test_rich_fallback_limit_defaults_to_discords() -> 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 cadc9aeef..b416a8cf3 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 @@ -74,9 +74,7 @@ async def delete_message(channel_id: str, message_ref: str) -> None: def _adapter() -> tuple[TelegramAdapter, _Platform]: adapter = TelegramAdapter( - config=TelegramConnectionConfig( - bot_token="test", bot_username="test_bot" - ) + config=TelegramConnectionConfig(bot_token="test", bot_username="test_bot") ) adapter._working_msg[KEY] = LiveRuntimeIndicator( message_ref="msg-1", diff --git a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py index 7fbbce9ca..52b6b8321 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py @@ -118,6 +118,7 @@ async def _cards( cards = SessionRequestCards( _adapter(client), bridge_id=bridge_id, + surface="slack", posts=SessionRequestPostStore(), session_factory=session_factory, ) @@ -134,6 +135,7 @@ async def _demo( cards = SessionRequestCards( adapter, bridge_id=bridge_id, + surface="slack", posts=SessionRequestPostStore(), session_factory=session_factory, ) @@ -383,6 +385,7 @@ async def test_a_freed_handle_is_the_one_the_next_card_takes( working = SessionRequestCards( _adapter(client), bridge_id=bridge_id, + surface="slack", posts=SessionRequestPostStore(), session_factory=session_factory, ) @@ -444,6 +447,7 @@ async def test_losing_the_race_is_reported_as_the_repeat_it_is( racing = SessionRequestCards( _adapter(client), bridge_id=bridge_id, + surface="slack", posts=_RivalPoster( session_factory, bridge_id=bridge_id, diff --git a/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py index 84ed85f9f..50886370e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py @@ -301,6 +301,7 @@ def _cards(adapter: SlackAdapter, post: SessionRequestPost) -> SessionRequestCar bridge_id="bridge-1", posts=_JustThisRow(post), session_factory=cast(Any, _NoDatabase), + surface="slack", ) diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py index 05b05048c..84f98278d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py @@ -72,16 +72,18 @@ async def test_native_stop_event_is_acknowledged_without_interrupting_an_sdk_tur async def test_reaction_cache_handles_expected_slack_refusals_quietly(error, caplog): slack, client = adapter() client.reaction_error = error - await slack.mark_activity("C1", "C1:1.0", working=True) + await slack.mark_activity("C1", "C1:1.0", agent_name="worker", working=True) client.reaction_error = None - await slack.mark_activity("C1", "1.0", working=True) + await slack.mark_activity("C1", "1.0", agent_name="worker", working=True) assert not client.reactions assert not caplog.records async def test_reaction_force_reconciles_after_restart(): slack, client = adapter() - await slack.mark_activity("C1", "C1:1.0", working=False, force=True) + await slack.mark_activity( + "C1", "C1:1.0", agent_name="worker", working=False, force=True + ) assert client.reactions == [("remove", "1.0", "eyes")] diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 85d87e585..f04ea6d0b 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -56,19 +56,43 @@ async def find_request_card(self, channel, thread, token, created_at): return ref return None - async def mark_activity(self, channel, ref, *, working, force=False): + async def mark_activity(self, channel, ref, *, agent_name, working, force=False): if working: self.reactions.add(ref) else: self.reactions.discard(ref) +class PerAgentSlack(ActivitySlack): + """A platform where each agent marks the message as its own bot. + + Mattermost is the real one: an agent's `:eyes:` is added by that agent's + own bot account, so two agents working the same message leave two separate + marks. Slack's single bot leaves one between them, which is why this is a + capability and not the default. + """ + + activity_reactions_per_agent = True + + def __init__(self): + super().__init__() + self.reactions = set() + + async def mark_activity(self, channel, ref, *, agent_name, working, force=False): + if working: + self.reactions.add((agent_name, ref)) + else: + self.reactions.discard((agent_name, ref)) + + def activity(factory, platform): return SessionTurnActivity(platform, journal=ActivityJournal(factory, "bridge")) -async def publish(renderer, status="running", *, tools=True): - turn = _turn(status).model_copy(update={"command_id": "message-demo"}) +async def publish( + renderer, status="running", *, tools=True, agent="Agent", command="message-demo" +): + turn = _turn(status).model_copy(update={"command_id": command}) return await renderer.publish( await _items() if tools else [], turn, @@ -76,7 +100,7 @@ async def publish(renderer, status="running", *, tools=True): channel_id="channel-demo", thread_root_id="channel-demo:root", asked_on="channel-demo:question", - agent_name="Agent", + agent_name=agent, elapsed_seconds=12, ) @@ -444,6 +468,54 @@ async def test_failed_final_edit_releases_reaction_across_restart(session_factor ) +async def test_one_agents_finished_turn_leaves_another_agents_mark_alone( + session_factory, +): + """Each agent's mark is its own bot's, so each has to come off on its own. + + Two turns on one message is the shared case the journal exists for β€” but + scoped per agent, the other agent still working says nothing about whether + this one's mark should stay. Unscoped, the first agent to finish reads the + second's live anchor as a reason to hold, and its own eyes stay on the + message for good. + + Both turns run under one session here because the journal keys rows by + session and command together; two commands is what makes two turns, and + which session they belong to is not what the scoping reads. + """ + await setup(session_factory) + platform = PerAgentSlack() + await publish(activity(session_factory, platform)) + await publish(activity(session_factory, platform), agent="Other", command="other") + assert platform.reactions == { + ("Agent", "channel-demo:question"), + ("Other", "channel-demo:question"), + } + + await publish(activity(session_factory, platform), "completed") + + assert platform.reactions == {("Other", "channel-demo:question")} + + +async def test_a_shared_bots_single_mark_survives_one_of_two_turns_ending( + session_factory, +): + """The inverse, and why the scoping is a capability rather than the rule. + + Where every agent reacts as the same bot there is one mark between them, + and taking it off when the first turn ends would strip it from a turn that + is still running. + """ + await setup(session_factory) + platform = ActivitySlack() + await publish(activity(session_factory, platform)) + await publish(activity(session_factory, platform), agent="Other", command="other") + + await publish(activity(session_factory, platform), "completed") + + assert platform.reactions == {"channel-demo:question"} + + async def test_busy_journal_is_skipped_until_next_sweep(session_factory): await setup(session_factory) journal = ActivityJournal(session_factory, "bridge") diff --git a/core/tests/switch_core/sessions/test_publication.py b/core/tests/switch_core/sessions/test_publication.py index 3935fc43d..1084af41f 100644 --- a/core/tests/switch_core/sessions/test_publication.py +++ b/core/tests/switch_core/sessions/test_publication.py @@ -68,7 +68,11 @@ async def test_card_callback_reservation_and_confirmed_settlement(session_factor platform = Platform() posts = SessionRequestPostStore() cards = SessionRequestCards( - platform, bridge_id="bridge", posts=posts, session_factory=session_factory + platform, + bridge_id="bridge", + surface="slack", + posts=posts, + session_factory=session_factory, ) await refresh_cards(session_factory, "bridge", "session-demo", cards) await refresh_cards(session_factory, "bridge", "session-demo", cards) @@ -167,6 +171,7 @@ async def test_permission_uses_activity_thread_and_persists_it(session_factory, cards = SessionRequestCards( platform, bridge_id="bridge", + surface="slack", posts=SessionRequestPostStore(), session_factory=session_factory, ) diff --git a/core/tests/switch_core/sessions/test_publication_retries.py b/core/tests/switch_core/sessions/test_publication_retries.py index e063c49e6..28af84103 100644 --- a/core/tests/switch_core/sessions/test_publication_retries.py +++ b/core/tests/switch_core/sessions/test_publication_retries.py @@ -55,6 +55,7 @@ def cards_for(factory, platform): return SessionRequestCards( platform, bridge_id="bridge", + surface="slack", posts=SessionRequestPostStore(), session_factory=factory, ) diff --git a/core/tests/switch_core/sessions/test_request_post_tenant_migration.py b/core/tests/switch_core/sessions/test_request_post_tenant_migration.py index 085f5e606..c882357f5 100644 --- a/core/tests/switch_core/sessions/test_request_post_tenant_migration.py +++ b/core/tests/switch_core/sessions/test_request_post_tenant_migration.py @@ -23,6 +23,7 @@ async def test_request_post_tenant_backfill_preserves_card_and_answer_destinatio cards = SessionRequestCards( Platform(), bridge_id="bridge", + surface="slack", posts=SessionRequestPostStore(), session_factory=session_factory, ) From edfd67e8ec0d25f71f99fd7447b930a77157fe52 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Mon, 14 Sep 2026 19:57:56 +0100 Subject: [PATCH 002/120] Mattermost: preserve uncertain sends, truthful cleanup, faithful forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review findings on the Mattermost SDK parity work. Uncertain sends. `RichContentFailed` is the publisher's licence to drop a reservation and post again, so the adapter now raises it only for a refusal Mattermost actually gave: the `mattermostdriver` exceptions that map to 4xx statuses it understood. A 429 becomes `RichContentThrottled` carrying `Retry-After`. Everything else β€” a timeout, a dropped connection, a 5xx β€” propagates as itself, the reservation survives, and `find_request_card` settles whether the post landed. Reaction cleanup. `mark_activity` went through a handler that logged and returned, so durable publication wrote a completion receipt for a turn that still had eyes on it, and a missing bot read as success. The raising `_react_or_raise` is now the SDK seam; the swallowing wrapper stays for the legacy runtime path, which has nothing to retry with. A delete Mattermost says is not there is still success β€” the channel is already as asked. Approval and question forms. Option labels were clipped to 150 characters while the form still said "Reply with `R42 1`", so two options differing past the ceiling were the same choice under two numbers. Labels now get a share of what the message can hold, an "accept for this session" option says its scope on the form, and a form that could not be shown whole β€” a cut label, a question left out, a body `_compose` had to trim β€” drops the instruction and names Console instead. Timer redraws. `redraws_for_elapsed_time` decides whether the clock alone is reason to republish a running turn. Slack opts in, where the status is a small line of its own; Mattermost does not, where it is the turn's one post. The duplicate-draw gate in `_edit` now applies everywhere rather than only where the activity log is separate, keyed on what the status actually shows. Notification recipient. `notifies_only_by_mention` names the agent's owner ahead of whoever typed the command, on platforms where an unnamed reader is an unnotified one. The other is always the fallback. An attention post with nobody to name says so, charged to the same character budget, restoring the explanation the legacy ping gave. Trigger-location typing. `notify_working` is a one-shot nudge sent as a turn opens, at the place the work was asked for and never on a redraw. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 63 +++ .../collaboration/mattermost/adapter.py | 242 +++++++++--- .../bridges/collaboration/session/outbound.py | 98 ++++- .../session/renderers/neutral.py | 139 +++++-- .../bridges/collaboration/slack/adapter.py | 1 + core/switch_core/sessions/presentation.py | 43 ++- core/switch_core/sessions/publication.py | 36 +- .../collaboration/test_mattermost_sdk_only.py | 364 +++++++++++++++++- .../test_session_neutral_forms.py | 183 +++++++++ .../collaboration/test_slack_sdk_only.py | 1 + .../sessions/test_activity_durability.py | 87 +++++ .../sessions/test_session_presentation.py | 51 ++- .../test_turn_activity_publication.py | 94 +++++ 13 files changed, 1273 insertions(+), 129 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 1fd561059..55dd970b4 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -134,6 +134,11 @@ class TurnActivity: publication_token: str | None = None session_url: str | None = None notify_external_id: str | None = None + # There was someone who should be told and nobody here to name: the agent + # has no owner, or an owner who has claimed no account on this platform. + # Distinct from `notify_external_id` being None on a redraw, which means + # the mention has already been made and must not be repeated. + notify_unreachable: bool = False # Canned, room-safe attention message. Never raw host/provider output. error_summary: str | None = None @@ -216,6 +221,31 @@ class CollaborationAdapter(ABC): #: already scrolled past. separate_attention_slot: ClassVar[bool] = False + #: Whether a mention is the only way an attention post reaches anyone. + #: + #: True where nobody follows a thread they are not already in, so a post + #: that names no one is read by no one. Two things follow from that. The + #: agent's owner leads the naming β€” they are the person who can open + #: Console and act on a stalled session, where whoever happened to type + #: the command may be able to do nothing about it. And an attention post + #: with nobody to name says so, because one that notified no one otherwise + #: looks exactly like one that notified the right person. + #: + #: False where the platform's own following does that work: a Slack + #: participant gets the threaded reply without being named, and naming + #: them is a notification they already had. + notifies_only_by_mention: ClassVar[bool] = False + + #: Whether a ticking clock is reason enough to redraw a running turn. + #: + #: True where the status is a small line of its own that a reader watches + #: for exactly that, so the seconds advancing is the message doing its job. + #: False where the status is the turn's one post: there the elapsed time + #: rides along with the next real change β€” a tool, a state, the ending β€” + #: rather than rewriting the post a reader is in the middle of, and the + #: final update still shows what the turn actually took. + redraws_for_elapsed_time: ClassVar[bool] = False + supports_activity_reactions: ClassVar[bool] = False #: Whether the work reaction belongs to the agent that added it. @@ -717,6 +747,26 @@ async def mark_activity( would be a caller that cannot serve the per-agent platforms at all. """ + async def notify_working( + self, channel_id: str, agent_name: str, thread_root_id: str | None + ) -> None: + """Signal once, where the work was asked for, that the agent has begun. + + A platform's own ephemeral "typing" affordance, which expires by itself + and so is a nudge rather than a state to switch off. Called once as a + turn opens and never on a redraw: repeated, it would claim the agent + was typing for as long as the turn ran. + + `thread_root_id` is where the *asking* happened, which is not + necessarily where the status went β€” someone who wrote at the channel + root is watching the root, not a thread they have not opened yet. None + means the channel root. + + Best effort by nature. Nothing is waiting on it and the posted status + carries the state from here on, so an adapter that cannot send one + does nothing and says nothing. + """ + def _runtime_lock(self, channel_id: str, agent_name: str) -> asyncio.Lock: """The lock serialising runtime-indicator work for one agent in one channel. @@ -959,6 +1009,19 @@ async def _ping_operator( body = self.translate_outbound(text + self._deeplink_suffix(deeplink_url)) return await self.send_message(channel_id, agent_name, body, thread_root_id) + def unnotified_notice(self) -> str: + """Why an attention post named nobody, for a platform that says so. + + The same explanation `_ping_operator` gives, for the SDK publication + that replaces it: the reader is told this reached no one and what to + do so the next one does, rather than being left to assume the person + who can act has already seen it. + """ + return ( + "Nobody here is linked to this agent's owner, so this notified no one. " + f"Link your {self.platform_name} account in Switch Console to be notified." + ) + @abstractmethod async def create_channel( self, diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index bea12753c..4cf5b54f3 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -17,7 +17,15 @@ import httpx import requests as sync_requests from mattermostdriver import Driver -from mattermostdriver.exceptions import NoAccessTokenProvided +from mattermostdriver.exceptions import ( + ContentTooLarge, + FeatureDisabled, + InvalidOrMissingParameters, + MethodNotAllowed, + NoAccessTokenProvided, + NotEnoughPermissions, + ResourceNotFound, +) from switch_core.agent_icon import default_icon_url from switch_core.bridges.collaboration.adapter import ( @@ -26,6 +34,7 @@ RequestCard, RichContent, RichContentFailed, + RichContentThrottled, TurnActivity, format_elapsed, ) @@ -86,6 +95,68 @@ # the server default, "username" β€” the display name is stored and never shown. _NAME_DISPLAY_SHOWS_LABEL = frozenset({"full_name", "nickname_full_name"}) +# The errors that mean Mattermost read the request and refused it. `mattermostdriver` +# maps these statuses to named exceptions; every one of them says the post does +# not exist and sending it again unchanged would be refused again. +# +# Everything else β€” a timeout, a dropped connection, a 5xx β€” leaves it unknown +# whether the post is on the server with only its response lost, and that is a +# different answer entirely: see `_as_rich_failure`. +_DEFINITE_REFUSALS = ( + InvalidOrMissingParameters, + NoAccessTokenProvided, + NotEnoughPermissions, + ResourceNotFound, + MethodNotAllowed, + ContentTooLarge, + FeatureDisabled, +) + +# How long to wait after a rate limit that names no interval of its own. +# Matches the Slack adapter's fallback, for the same reason: long enough not to +# walk straight back into the limit, short enough that a live turn still moves. +_THROTTLE_FALLBACK_SECONDS = 30.0 + + +def _throttle_delay(error: Exception) -> float | None: + """Seconds Mattermost asked us to wait, or None if it did not ask. + + `mattermostdriver` has no exception for 429, so the underlying + `requests.HTTPError` arrives with its response still attached β€” which is + what carries the interval. + """ + response = getattr(error, "response", None) + if response is None or getattr(response, "status_code", None) != 429: + return None + headers = getattr(response, "headers", None) or {} + try: + return max(0.0, float(headers.get("Retry-After", ""))) + except (TypeError, ValueError): + return _THROTTLE_FALLBACK_SECONDS + + +def _as_rich_failure( + error: Exception, *, description: str, text: str +) -> RichContentFailed | None: + """What a publication should make of a Mattermost error, or nothing. + + `None` means the outcome is unknown and the caller must let the error + propagate: a post whose response was lost may be sitting in the channel, + and `RichContentFailed` would have the caller drop its reservation and + post a second copy of something a reader is meant to see once. The + reservation survives instead, and `find_request_card` settles it. + + Only a server that understood the request and refused it becomes + `RichContentFailed`, and only one asking us to slow down becomes + `RichContentThrottled`. + """ + retry_after = _throttle_delay(error) + if retry_after is not None: + return RichContentThrottled(retry_after=retry_after, text=text) + if isinstance(error, _DEFINITE_REFUSALS): + return RichContentFailed(f"{description}: {error}", text=text) + return None + class MattermostConnectionConfig(BridgeConnectionConfig): url: str @@ -123,6 +194,17 @@ class MattermostAdapter(CollaborationAdapter): #: has already scrolled past. One per turn, cleared when it clears. separate_attention_slot: ClassVar[bool] = True + #: A Mattermost thread notifies only the people named in it, so the agent's + #: owner leads β€” they are who can open Console and act β€” and an attention + #: post with nobody to name says as much. This is the legacy ping's policy, + #: carried over: it is the one that reaches the person who can do something. + notifies_only_by_mention: ClassVar[bool] = True + + #: The status is the turn's one post, not a line beside it, so the clock + #: advancing is not on its own worth rewriting what a reader is reading. + #: Elapsed time goes out with the next real change and with the ending. + redraws_for_elapsed_time: ClassVar[bool] = False + supports_activity_reactions: ClassVar[bool] = True #: Each agent posts and reacts as its own bot here, so two agents working @@ -569,15 +651,22 @@ def _draw( escape = self._rich_escape limit = self.rich_fallback_limit() if isinstance(content, TurnActivity): - return turn_status( - content.items, - content.turn, - escape=escape, - limit=limit, - elapsed_seconds=content.elapsed_seconds, - session_url=content.session_url, - mention=mention, - error_summary=content.error_summary, + # Charged to the same budget as the status it follows: a post that + # just fits, plus a line saying it reached nobody, is a post + # Mattermost refuses. + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + return ( + turn_status( + content.items, + content.turn, + escape=escape, + limit=max(1, limit - len(tail)), + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + mention=mention, + error_summary=content.error_summary, + ) + + tail ) # The handle goes on its own line rather than in front of the heading: # a card is a block, and a handle wedged before "**Permission needed**" @@ -619,6 +708,10 @@ async def post_rich( Raises on every failure, unlike `send_message`, which reports one by returning `None`: a publication that silently did not happen is a reservation that never gets retried and a turn the channel never sees. + What it raises is the point β€” `RichContentFailed` is the caller's + licence to discard the reservation, so it is reserved for a refusal + Mattermost actually gave. A send whose outcome nobody knows raises the + transport's own error and keeps the reservation. """ text = await self._render_rich(content) driver = self._bot_drivers.get(agent_name) @@ -642,10 +735,14 @@ async def post_rich( {_PUBLICATION_PROP: token} if token else None, ) except Exception as error: - raise RichContentFailed( - f"Mattermost could not post in channel {channel_id}: {error}", + failure = _as_rich_failure( + error, + description=f"Mattermost refused the post in channel {channel_id}", text=text, - ) from error + ) + if failure is None: + raise + raise failure from error self._remember_author(ref, agent_name) return ref @@ -664,6 +761,10 @@ async def update_rich( to the admin driver changes who a reader sees the post from not at all; what it changes is the permission the edit is made with, and an agent bot editing its own post is the narrower of the two. + + Says "did not happen" only where Mattermost refused the edit. An edit + whose outcome is unknown may well have landed, and reporting it as a + refusal buys a fallback reply about a card that is already correct. """ # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the channel that never resolves to @@ -684,11 +785,17 @@ async def update_rich( None, driver.posts.patch_post, message_ref, {"message": text} ) except Exception as error: - raise RichContentFailed( - f"Mattermost could not update post {message_ref} in channel " - f"{channel_id}: {error}", + failure = _as_rich_failure( + error, + description=( + f"Mattermost refused the edit to post {message_ref} in " + f"channel {channel_id}" + ), text=text, - ) from error + ) + if failure is None: + raise + raise failure from error async def find_request_card( self, @@ -815,11 +922,26 @@ async def mark_activity( other's alone. `force` is the durable publisher reconciling after a restart, when this process's record of what is already there is empty and wrong rather than empty and right. + + Raises when the mark did not happen, including when there is no bot to + make it with. The publisher retries on that and records completion + only once the channel actually shows what it says it shows. """ - await self._mark_being_read( + await self._react_or_raise( agent_name, message_ref, working=working, force=force ) + async def notify_working( + self, channel_id: str, agent_name: str, thread_root_id: str | None + ) -> None: + """The one-shot typing nudge, at the place the agent was asked. + + What the legacy runtime path sent as a turn opened, kept for the SDK + one: Mattermost expires it after a few seconds, so it costs the channel + nothing and it is the only signal that arrives before the first post. + """ + await self._post_typing(channel_id, agent_name, thread_root_id) + def _remember_author(self, post_id: str, agent_name: str) -> None: self._rich_authors[post_id] = agent_name self._rich_authors.move_to_end(post_id) @@ -1063,6 +1185,29 @@ async def _track_eyes( async def _mark_being_read( self, agent_name: str, post_id: str, *, working: bool, force: bool = False + ) -> None: + """Best-effort πŸ‘€ for the legacy runtime path, which cannot act on failure. + + Nothing on that path retries and nothing records what it did, so a + failure here is cosmetic and is logged rather than raised. The SDK seam + goes through `_react_or_raise`: its publisher writes a completion + receipt on the strength of what it is told, and a swallowed failure + there leaves πŸ‘€ on a finished turn for good. + """ + try: + await self._react_or_raise( + agent_name, post_id, working=working, force=force + ) + except Exception as e: + logger.warning( + "Could not %s the working reaction on %s: %s", + "add" if working else "remove", + post_id, + e, + ) + + async def _react_or_raise( + self, agent_name: str, post_id: str, *, working: bool, force: bool ) -> None: """Put πŸ‘€ on the post an agent is working on, and take it off after. @@ -1077,6 +1222,11 @@ async def _mark_being_read( is for the caller that knows better from the journal: skipping the call because the set is empty would strand a πŸ‘€ on a turn that ended while the bridge was down. + + A failure leaves that memory alone, so the next attempt is a real + attempt rather than one the record talks out of trying. Removing a + reaction Mattermost says is not there is the exception: the channel is + already in the state being asked for, and there is nothing to retry. """ key = (agent_name, post_id) if not force and working == (key in self._eyes): @@ -1086,46 +1236,32 @@ async def _mark_being_read( driver = self._bot_drivers.get(agent_name) loop = self._main_loop if not bot_info or driver is None or loop is None: - logger.warning( - "Cannot %s the working reaction for %s: no connected bot", - "add" if working else "remove", - agent_name, - ) - return + raise RuntimeError(f"no connected bot for {agent_name!r}") user_id = bot_info["user_id"] + if working: + await loop.run_in_executor( + None, + driver.reactions.create_reaction, + { + "user_id": user_id, + "post_id": post_id, + "emoji_name": _WORKING_REACTION, + }, + ) + self._eyes.add(key) + return try: - if working: - await loop.run_in_executor( - None, - driver.reactions.create_reaction, - { - "user_id": user_id, - "post_id": post_id, - "emoji_name": _WORKING_REACTION, - }, - ) - self._eyes.add(key) - else: - await loop.run_in_executor( - None, - driver.reactions.delete_reaction, - user_id, - post_id, - _WORKING_REACTION, - ) - self._eyes.discard(key) - except Exception as e: - # Cosmetic, and the post may simply be gone. Record nothing and - # keep the turn going. - logger.warning( - "Could not %s the working reaction on %s: %s", - "add" if working else "remove", + await loop.run_in_executor( + None, + driver.reactions.delete_reaction, + user_id, post_id, - e, + _WORKING_REACTION, ) - if not working: - self._eyes.discard(key) + except ResourceNotFound: + pass + self._eyes.discard(key) async def _reposition_runtime_state( self, channel_id: str, agent_name: str, thread_root_id: str | None diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 373742587..f8301c80c 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -161,6 +161,8 @@ def __init__( self._reactions_per_agent = getattr( adapter, "activity_reactions_per_agent", False ) + self._timer_redraws = getattr(adapter, "redraws_for_elapsed_time", False) + self._only_mentions_notify = getattr(adapter, "notifies_only_by_mention", False) self._anchors: OrderedDict[tuple[str, str], _Anchor] = OrderedDict() self._thread_turns: dict[tuple[str, str, str], set[tuple[str, str]]] = {} self._attention: OrderedDict[tuple[str, str], tuple[str, str]] = OrderedDict() @@ -169,6 +171,47 @@ def __init__( def durable(self) -> bool: return self._journal is not None + @property + def notifies_only_by_mention(self) -> bool: + """Whether naming someone is the only way this platform reaches them. + + Read by the caller that resolves who to name, which is where the + agent's owner is preferred to whoever started the turn and where an + unreachable owner becomes something the post admits to. + """ + return self._only_mentions_notify + + @property + def redraws_for_elapsed_time(self) -> bool: + """Whether a running turn is worth redrawing for the clock alone. + + Read by the caller that decides how often to publish at all, so a + platform that does not redraw for the clock is not asked to. + """ + return self._timer_redraws + + def _status_state( + self, + turn: TurnUpsert, + items: list[Item], + elapsed_seconds: float | None, + session_url: str | None, + ) -> tuple[str, str, str | None]: + """What the status message is already showing. + + Two publishes with the same answer would draw the same message, and + the second is an edit nobody would see. The clock counts only where + the platform redraws for it; elsewhere what the status shows is the + turn's state and its tools, and the elapsed time goes out with the + next change to either. + """ + drawn = ( + f"{int(elapsed_seconds) if elapsed_seconds is not None else ''}" + if self._timer_redraws + else ",".join(f"{item.item_id}:{item.revision}" for item in items) + ) + return (turn.turn_id, f"{turn.status}:{drawn}", session_url) + async def recorded_commands(self, session_id: str) -> set[str]: return ( await self._journal.recorded_commands(session_id) @@ -189,6 +232,7 @@ async def publish( elapsed_seconds: float | None, session_url: str | None = None, notify_external_id: str | None = None, + notify_unreachable: bool = False, error_summary: str | None = None, ) -> bool: async def draw() -> bool: @@ -211,6 +255,7 @@ async def draw() -> bool: thread_root_id, turn, notify_external_id, + notify_unreachable, error_summary, ) return drawn @@ -273,6 +318,7 @@ async def _refresh_attention( thread_root_id: str | None, turn: TurnUpsert, notify_external_id: str | None, + notify_unreachable: bool, error_summary: str | None, ) -> None: """One attention reply per command; update it when the problem clears.""" @@ -297,6 +343,7 @@ async def _refresh_attention( [], turn, status_only=True, + notify_unreachable=notify_unreachable, error_summary=error_summary, ) if ref is None: @@ -516,7 +563,24 @@ async def _begin( The platform can refuse it, and then the channel is told rather than left with a turn that silently never appeared, and `None` says so to the caller. + + Runs once per turn, which is why the "agent has started" nudge belongs + here: an anchor that already exists is a turn already announced, and a + platform told again on every redraw would show the agent as typing for + as long as it ran. """ + if turn.status not in TURN_ENDED: + await self._adapter.notify_working( + channel_id, + agent_name, + # The asking message and the thread the status goes into are + # the same message exactly when the command was addressed at + # the channel root β€” the turn threads under what was said. So + # whoever is waiting is watching the root, not a thread they + # have not opened; anything else means they are watching the + # thread the command came from. + None if asked_on == thread_root_id else thread_root_id, + ) try: posted = await self._post_activity( channel_id, @@ -551,12 +615,8 @@ async def _begin( reaction_ref=asked_on, agent_name=agent_name, session_url=session_url, - status_state=( - turn.turn_id, - f"{turn.status}:{int(elapsed_seconds) if elapsed_seconds is not None else ''}", - session_url, - ) - if self._separate_activity_log and self._journal is None + status_state=self._status_state(turn, items, elapsed_seconds, session_url) + if self._journal is None else None, log_state=tuple((item.item_id, item.revision) for item in items) + ((turn.status, 0),), @@ -573,14 +633,12 @@ async def _edit( elapsed_seconds: float | None, ) -> bool: """Rewrite the posted message with the turn as it now stands.""" - state = ( - turn.turn_id, - f"{turn.status}:{int(elapsed_seconds) if elapsed_seconds is not None else ''}", - anchor.session_url, - ) - if self._separate_activity_log and not ended and anchor.status_state == state: - # The timer and visible link share a compact status line. Tool-only - # changes belong to the separate log, not another status edit. + state = self._status_state(turn, items, elapsed_seconds, anchor.session_url) + if not ended and anchor.status_state == state: + # Nothing this message shows has changed. Where the status is a + # line of its own, tool-only changes belong to the separate log; + # where it is the turn's one post, a clock that has moved on its + # own is not a change a reader wanted the post rewritten for. return True try: await self._adapter.update_rich( @@ -610,7 +668,7 @@ async def _edit( else "The next change to the turn will try the same message.", ) return False - if self._separate_activity_log and not ended: + if not ended: anchor.status_state = state return True @@ -794,6 +852,16 @@ def surface(self) -> str: """ return self._surface + @property + def notifies_only_by_mention(self) -> bool: + """Whether naming someone is the only way this platform reaches them. + + Read where a card's one notification is resolved, for the same reason + `SessionTurnActivity` exposes it: the agent's owner leads on a + platform where an unnamed reader is an unnotified one. + """ + return bool(getattr(self._adapter, "notifies_only_by_mention", False)) + async def post( self, request: SnapshotRequest, diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 0a0916eb5..78cc95fb3 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -35,6 +35,7 @@ from switch_core.sessions.contract import ( TURN_ENDED, ApprovalContent, + ApprovalOption, ApprovalResult, DecidedBy, Item, @@ -65,8 +66,19 @@ _CONSOLE = "Open in Switch Console" # What a card says when it could not show all of itself. The reader is told the -# count rather than left to notice, and pointed somewhere the whole of it is. -_CUT = "…{left} more not shown. {console} to see the rest." +# count rather than left to notice; where the rest of it is comes from the +# footer, which a cut body always replaces with `_TOO_BIG`. +_CUT = "…{left} more not shown." + +# What an open request says in place of "Reply with `R42 1`" when it could not +# be shown faithfully β€” an option cut short of what distinguishes it, a +# question left out. Answering by number means answering the numbers on the +# screen, so a form that is not all on the screen stops asking to be answered +# there and names the place it can be. +_TOO_BIG = ( + "Too long to show in full here, so it cannot be answered from this " + f"message. {_CONSOLE} to read and answer it." +) # How a tool call went, in one character: read at a glance and down the left # edge of a line rather than as a sentence. The same glyphs `slack.py` uses, @@ -92,6 +104,11 @@ "closed": "Permission request closed", } +# How far an "accept for this session" option reaches. One copy, because the +# open form and the answered one are describing the same thing and a reader +# comparing them should not have to work out that they agree. +_FOR_SESSION = " (applies for the rest of this session)" + _QUESTION_HEADINGS = { "open": "Questions", "submitting": "Questions", @@ -269,20 +286,34 @@ def request_summary( exists for a card that cannot be answered where it is showing, and leaving "Reply with `R42 1`" underneath would invite exactly the answer that is about to be refused. + + A form that does not fit refuses the same way, and for the same reason. + An instruction to answer by number under options a reader can only see + part of invites the wrong number: the form drops the instruction, says it + is too long, and names Console. Only an instruction is replaced this way + β€” a card that already says why it cannot be answered says it better than + this would. """ content = request.content if isinstance(content, ApprovalContent): - head, body, footer = _approval_form( + head, body, footer, invites_answer = _approval_form( request, content, reference, escape=escape, limit=limit, responder=responder ) else: - head, body, footer = _questions_form( + head, body, footer, invites_answer = _questions_form( request, content, reference, escape=escape, limit=limit, responder=responder ) if unavailable_reason and request.state in {"open", "submitting"}: body = [] footer = _fit(unavailable_reason, max(1, limit // 3), escape=escape) - return _compose(head, body, footer, limit=limit) + invites_answer = False + return _compose( + head, + body, + footer, + limit=limit, + if_cut=_TOO_BIG if invites_answer else footer, + ) def _approval_form( @@ -293,7 +324,7 @@ def _approval_form( escape: Callable[[str], str], limit: int, responder: str | None, -) -> tuple[list[str], list[str], str]: +) -> tuple[list[str], list[str], str, bool]: handle = escape(reference.handle) head = [f"**{_HEADINGS[request.state]}** Β· request `{handle}`"] head.append(_fit(content.title, _share(limit, 1500, 3), escape=escape)) @@ -301,17 +332,29 @@ def _approval_form( head.append(_fit(content.detail, _share(limit, 1200, 4), escape=escape)) body: list[str] = [] + whole = True if request.state == "open": + budget = _label_budget(limit) + whole = all( + _shows_whole(option.label, budget, escape=escape) + for option in content.options + ) body = [ - f"{index}. {_fit(option.label, _share(limit, 150, 8), escape=escape)}" + f"{index}. {_fit(option.label, budget, escape=escape)}{_scope(option)}" for index, option in enumerate(content.options, start=1) ] + # The one state whose footer is an instruction. `_approval_footer` says + # why the others are not: nothing to choose, or already answered. + invites_answer = request.state == "open" and bool(content.options) + if invites_answer and not whole: + return (head, body, _TOO_BIG, False) return ( head, body, _approval_footer( request, content, handle, escape=escape, limit=limit, responder=responder ), + invites_answer, ) @@ -366,11 +409,7 @@ def _approval_answer( _share(limit, 150, 8), escape=escape, ) - scope = ( - " (applies for the rest of this session)" - if chosen and chosen.decision == "acceptForSession" - else "" - ) + scope = _scope(chosen) if chosen else "" return f"{label}{scope} β€” chosen{by}." if by else f"{label}{scope}." @@ -382,7 +421,7 @@ def _questions_form( escape: Callable[[str], str], limit: int, responder: str | None, -) -> tuple[list[str], list[str], str]: +) -> tuple[list[str], list[str], str, bool]: handle = escape(reference.handle) head = [ f"**{_QUESTION_HEADINGS[request.state]}** Β· request `{handle}`", @@ -390,26 +429,30 @@ def _questions_form( ] body: list[str] = [] + whole = True if request.state == "open": + budget = _label_budget(limit) for position, question in enumerate(content.questions, start=1): title = ( - _fit(question.title, _share(limit, 150, 8), escape=escape) - if question.title - else "" + _fit(question.title, budget, escape=escape) if question.title else "" ) + whole = whole and _shows_whole(question.title, budget, escape=escape) body.append(f"**{position}. {title}**" if title else f"**{position}.**") if question.prompt: body.append(_fit(question.prompt, _share(limit, 800, 4), escape=escape)) - body += [ - _option_line(index, option, escape=escape, limit=limit) - for index, option in enumerate(question.options, start=1) - ] + for index, option in enumerate(question.options, start=1): + body.append(_option_line(index, option, escape=escape, limit=limit)) + whole = whole and _shows_whole(option.label, budget, escape=escape) + invites_answer = request.state == "open" and unanswerable(content.questions) is None + if invites_answer and not whole: + return (head, body, _TOO_BIG, False) return ( head, body, _questions_footer( request, content, handle, escape=escape, limit=limit, responder=responder ), + invites_answer, ) @@ -420,7 +463,7 @@ def _option_line( escape: Callable[[str], str], limit: int, ) -> str: - line = f"{index}. {_fit(option.label, _share(limit, 150, 8), escape=escape)}" + line = f"{index}. {_fit(option.label, _label_budget(limit), escape=escape)}" if option.description: line += f" β€” {_fit(option.description, _share(limit, 200, 8), escape=escape)}" return line @@ -589,17 +632,26 @@ def _actor( return f"{_fit(decided_by.actor_id, _share(limit, 200, 8), escape=escape)} from {where}" -def _compose(head: list[str], body: list[str], footer: str, *, limit: int) -> str: +def _compose( + head: list[str], body: list[str], footer: str, *, limit: int, if_cut: str +) -> str: """Head, as much of the body as fits, then the footer β€” which always survives. The body is what gets dropped because it is the part a reader can recover elsewhere: an option they cannot see is still an option, and the notice - says where the whole list is. The footer is not recoverable that way β€” it - is the instruction for answering *here* β€” so it is measured first and the + says how many are missing. The footer is not recoverable that way β€” it is + the instruction for answering *here* β€” so it is measured first and the rest is spent around it. + + A body that had to be cut changes what the footer can honestly say, which + is what `if_cut` is: answering by number means answering the numbers on + the screen, and some of them are not. Both footers are measured, so the + one that ends up being used is the one there was room for and the choice + between them cannot change how much body fits. """ footer = _truncate(footer, limit) - spent = len(footer) + if_cut = _truncate(if_cut, limit) + spent = max(len(footer), len(if_cut)) lines: list[str] = [] for line in head: if spent + len(line) + 1 > limit: @@ -613,14 +665,15 @@ def _compose(head: list[str], body: list[str], footer: str, *, limit: int) -> st break shown.append(line) spent += len(line) + 1 - if len(shown) < len(body): - notice = _CUT.format(left=len(body) - len(shown), console=_CONSOLE) + cut = len(shown) < len(body) + if cut: + notice = _CUT.format(left=len(body) - len(shown)) while shown and spent + len(notice) + 1 > limit: spent -= len(shown.pop()) + 1 - notice = _CUT.format(left=len(body) - len(shown), console=_CONSOLE) + notice = _CUT.format(left=len(body) - len(shown)) if spent + len(notice) + 1 <= limit: shown.append(notice) - return "\n".join([*lines, *shown, footer]) + return "\n".join([*lines, *shown, if_cut if cut else footer]) def _mentioned(mention: str | None, body: str) -> str: @@ -641,6 +694,34 @@ def _link(label: str, url: str | None) -> str: return f"[{label}]({url.replace(')', '%29')})" +def _label_budget(limit: int) -> int: + """How much room a thing a reader picks between gets. + + Generous where the short ceilings elsewhere are not, because this is the + text the choice is made on: a permission label is routinely a whole + command line, and two options that differ only past a short ceiling render + as the same choice under two numbers. What the message can hold still + bounds it β€” and a label that does not fit even this stops the card + inviting an answer at all, rather than being quietly shortened into one. + """ + return _share(limit, 1500, 3) + + +def _shows_whole(text: str, limit: int, *, escape: Callable[[str], str]) -> bool: + """Whether `_fit` will show all of `text`, or have to cut it.""" + return len(escape(text)) <= limit + + +def _scope(option: ApprovalOption) -> str: + """How far an approval reaches, where the label may not have said. + + The same wording the answered card uses, on the form itself: two options + can be labelled the same and mean "this once" and "from now on", and a + reader choosing between them by number needs the difference said. + """ + return _FOR_SESSION if option.decision == "acceptForSession" else "" + + def _share(limit: int, most: int, denominator: int) -> int: """One value's budget: `most` characters, or a share of a tighter limit. diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 91e0f0c93..128df4ad3 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -163,6 +163,7 @@ class SlackAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True separate_activity_log: ClassVar[bool] = True separate_attention_slot: ClassVar[bool] = True + redraws_for_elapsed_time: ClassVar[bool] = True supports_activity_reactions: ClassVar[bool] = True renders_legacy_runtime_state: ClassVar[bool] = False diff --git a/core/switch_core/sessions/presentation.py b/core/switch_core/sessions/presentation.py index dbf4fb819..077846f6a 100644 --- a/core/switch_core/sessions/presentation.py +++ b/core/switch_core/sessions/presentation.py @@ -43,9 +43,19 @@ async def notification_recipient( origin: Origin, agent: Agent, thread_id: str | None, + prefer_owner: bool, ) -> str | None: """Slack participants follow replies by default; mention only other origins. + `prefer_owner` names the agent's owner ahead of whoever started the turn. + It is for the platforms where a mention is the whole notification: the + owner is the person who can open Console and act on a stalled session, + and whoever typed the command may not be able to do anything about it. + Where the platform's own following reaches the participants anyway, the + person who asked leads instead β€” they are the one waiting on an answer. + Either way the other is the fallback, so an agent with no owner, or an + owner who has claimed no account here, still reaches somebody. + Membership and bridge checks prevent mentioning identities from another room or workspace. No follower API is needed for the usual threaded case. """ @@ -56,19 +66,12 @@ async def notification_recipient( .join(ClientRoom, ClientRoom.client_id == ExternalUser.client_id) .where(ExternalUser.bridge_id == bridge_id, ClientRoom.room_id == room_id) ) - actor = await db.scalar( - members.join(Client, Client.id == ExternalUser.client_id) - .where(Client.matrix_user_id == origin.actor_id) - .order_by(ExternalUser.id) - .limit(1) - ) - if actor: - return actor - # Console commands identify their user directly rather than a puppet. - for user_id in dict.fromkeys([origin.actor_id, agent.owner_id]): + + async def claimed_by(user_id: str | None) -> str | None: + # Console commands identify their user directly rather than a puppet. if not user_id: - continue - recipient = await db.scalar( + return None + return await db.scalar( members.join( ExternalUserClaim, ExternalUserClaim.external_user_id == ExternalUser.id ) @@ -76,9 +79,19 @@ async def notification_recipient( .order_by(ExternalUser.id) .limit(1) ) - if recipient: - return recipient - return None + + async def initiator() -> str | None: + actor = await db.scalar( + members.join(Client, Client.id == ExternalUser.client_id) + .where(Client.matrix_user_id == origin.actor_id) + .order_by(ExternalUser.id) + .limit(1) + ) + return actor or await claimed_by(origin.actor_id) + + if prefer_owner: + return await claimed_by(agent.owner_id) or await initiator() + return await initiator() or await claimed_by(agent.owner_id) def activity_error_summary( diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 14690927f..0901d49ea 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -4,6 +4,7 @@ from collections import OrderedDict from collections.abc import Callable from datetime import UTC, datetime +from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -174,6 +175,7 @@ async def refresh_cards( origin=origin, agent=agent, thread_id=thread_id, + prefer_owner=cards.notifies_only_by_mention, ) if post is None and request.state == "open" else None @@ -502,7 +504,7 @@ async def refresh_activity( continue items = [item for item in snapshot.items if item.turn_id == turn.turn_id] revisions = tuple(item.revision for item in items) - if turn.status == "running": + if turn.status == "running" and activity.redraws_for_elapsed_time: revisions += (int(time.monotonic() // 5),) error_summary = activity_error_summary( turn, snapshot.session, online=online @@ -580,22 +582,32 @@ async def refresh_activity( ) or thread_root_id ) - metadata = { + recipient = ( + await notification_recipient( + db, + bridge_id=bridge_id, + room_id=room.id, + origin=origin, + agent=agent, + thread_id=thread_root_id, + prefer_owner=activity.notifies_only_by_mention, + ) + if error_summary + else None + ) + metadata: dict[str, Any] = { key: value for key, value in { "session_url": session_console_url( gateway_public_url, agent.id, room.id, row.id ), - "notify_external_id": await notification_recipient( - db, - bridge_id=bridge_id, - room_id=room.id, - origin=origin, - agent=agent, - thread_id=thread_root_id, - ) - if error_summary - else None, + "notify_external_id": recipient, + # Somebody has to act on this and there is nobody here to + # name. Only worth saying where a mention is the whole + # notification; elsewhere the platform reaches them anyway. + "notify_unreachable": bool(error_summary) + and recipient is None + and activity.notifies_only_by_mention, "error_summary": error_summary, }.items() if value is not None diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index 62a54d07b..e251455df 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -20,10 +20,13 @@ from typing import Any import pytest +import requests +from mattermostdriver.exceptions import NotEnoughPermissions, ResourceNotFound from switch_core.bridges.collaboration.adapter import ( RequestCard, RichContentFailed, + RichContentThrottled, TurnActivity, ) from switch_core.bridges.collaboration.mattermost.adapter import ( @@ -52,6 +55,7 @@ def __init__(self) -> None: self.thread: dict[str, dict[str, Any]] = {} self.channel: dict[str, dict[str, Any]] = {} self.create_error: Exception | None = None + self.created_id: str | None = None self.patch_error: Exception | None = None self.read_error: Exception | None = None self.thread_calls: list[str] = [] @@ -62,6 +66,8 @@ def create_post(self, post: dict[str, Any]) -> dict[str, str]: if self.create_error: raise self.create_error self.created.append(post) + if self.created_id is not None: + return {"id": self.created_id} return {"id": next(self._next)} def patch_post(self, post_id: str, body: dict[str, Any]) -> dict[str, str]: @@ -101,8 +107,12 @@ def get_user(self, user_id: str) -> dict[str, str]: class _FakeReactions: def __init__(self) -> None: self.calls: list[tuple[str, str, str, str]] = [] + self.create_error: Exception | None = None + self.delete_error: Exception | None = None def create_reaction(self, options: dict[str, str]) -> dict[str, str]: + if self.create_error: + raise self.create_error self.calls.append( ("add", options["user_id"], options["post_id"], options["emoji_name"]) ) @@ -111,15 +121,31 @@ def create_reaction(self, options: dict[str, str]) -> dict[str, str]: def delete_reaction( self, user_id: str, post_id: str, emoji_name: str ) -> dict[str, str]: + if self.delete_error: + raise self.delete_error self.calls.append(("remove", user_id, post_id, emoji_name)) return {"status": "OK"} +class _FakeClient: + """The raw HTTP surface, which is how the typing nudge is sent.""" + + def __init__(self) -> None: + self.requests: list[tuple[str, str, dict[str, str]]] = [] + + def make_request( + self, method: str, endpoint: str, body: dict[str, str] + ) -> dict[str, str]: + self.requests.append((method, endpoint, body)) + return {"status": "OK"} + + class _FakeDriver: def __init__(self, posts: _FakePosts, users: _FakeUsers) -> None: self.posts = posts self.users = users self.reactions = _FakeReactions() + self.client = _FakeClient() def _adapter(*agents: str, **users: str) -> MattermostAdapter: @@ -240,15 +266,82 @@ async def test_an_agent_with_no_bot_is_a_failure_and_not_a_quiet_skip() -> None: async def test_a_refused_post_raises_with_what_mattermost_said() -> None: adapter = _adapter() - _posts(adapter).create_error = RuntimeError("403 permission denied") + _posts(adapter).create_error = NotEnoughPermissions("403 permission denied") with pytest.raises(RichContentFailed) as excinfo: await adapter.post_rich("chan-1", "worker", _activity()) - assert isinstance(excinfo.value.__cause__, RuntimeError) + assert isinstance(excinfo.value.__cause__, NotEnoughPermissions) assert "403" in str(excinfo.value) +def _http_error(status: int, **headers: str) -> requests.HTTPError: + """What the driver raises for a status it has no exception of its own for. + + `mattermostdriver` names 400, 401, 403, 404, 405, 413 and 501 and raises + its own class for each. Everything else β€” a rate limit, a server fault β€” + comes back as the underlying `requests` error with the response attached, + which is the only place the status and any `Retry-After` can be read. + """ + response = requests.Response() + response.status_code = status + response.headers.update(headers) + return requests.HTTPError(f"{status} from Mattermost", response=response) + + +async def test_an_uncertain_send_is_not_a_refusal_and_keeps_its_reservation() -> None: + """`RichContentFailed` tells the caller to drop its reservation and start + again. A lost response has not been refused: Mattermost may well have + taken the post, and starting again would put a second one in the channel. + """ + adapter = _adapter() + _posts(adapter).create_error = TimeoutError("accepted on the server, response lost") + + with pytest.raises(TimeoutError): + await adapter.post_rich("chan-1", "worker", _activity()) + + +@pytest.mark.parametrize("status", [500, 502, 503]) +async def test_a_server_fault_is_not_a_refusal_either(status: int) -> None: + adapter = _adapter() + _posts(adapter).create_error = _http_error(status) + + with pytest.raises(requests.HTTPError): + await adapter.post_rich("chan-1", "worker", _activity()) + + +async def test_a_rate_limit_waits_for_the_deadline_mattermost_gave() -> None: + adapter = _adapter() + _posts(adapter).create_error = _http_error(429, **{"Retry-After": "17"}) + + with pytest.raises(RichContentThrottled) as excinfo: + await adapter.post_rich("chan-1", "worker", _activity()) + + assert excinfo.value.retry_after == 17 + assert excinfo.value.text + + +async def test_a_rate_limit_with_no_deadline_still_waits_before_retrying() -> None: + """Retrying at once would spend the next window being refused again.""" + adapter = _adapter() + _posts(adapter).create_error = _http_error(429) + + with pytest.raises(RichContentThrottled) as excinfo: + await adapter.post_rich("chan-1", "worker", _activity()) + + assert excinfo.value.retry_after > 0 + + +async def test_a_post_accepted_without_an_id_is_unresolved_not_refused() -> None: + """Nothing here knows whether the post exists, so the reservation stands + and recovery looks for it by its marker rather than posting again.""" + adapter = _adapter() + _posts(adapter).created_id = "" + + with pytest.raises(RuntimeError): + await adapter.post_rich("chan-1", "worker", _activity()) + + # ── Editing ────────────────────────────────────────────────────────────────── @@ -267,15 +360,38 @@ async def test_a_failed_redraw_raises_rather_than_leaving_a_stale_card() -> None showing its open form with nobody told.""" adapter = _adapter() ref = await adapter.post_rich("chan-1", "worker", await _card()) - _posts(adapter).patch_error = RuntimeError("404 post not found") + _posts(adapter).patch_error = ResourceNotFound("404 post not found") with pytest.raises(RichContentFailed) as excinfo: await adapter.update_rich("chan-1", ref, await _card()) - assert isinstance(excinfo.value.__cause__, RuntimeError) + assert isinstance(excinfo.value.__cause__, ResourceNotFound) assert excinfo.value.text +async def test_a_redraw_that_may_have_landed_is_not_reported_as_refused() -> None: + """The caller retires the message it drew on a definite refusal. A lost + response has not refused anything, and the same edit is worth trying + again against the post it was already aimed at.""" + adapter = _adapter() + ref = await adapter.post_rich("chan-1", "worker", await _card()) + _posts(adapter).patch_error = _http_error(503) + + with pytest.raises(requests.HTTPError): + await adapter.update_rich("chan-1", ref, await _card()) + + +async def test_a_rate_limited_redraw_carries_the_wait_back_to_the_caller() -> None: + adapter = _adapter() + ref = await adapter.post_rich("chan-1", "worker", await _card()) + _posts(adapter).patch_error = _http_error(429, **{"Retry-After": "8"}) + + with pytest.raises(RichContentThrottled) as excinfo: + await adapter.update_rich("chan-1", ref, await _card()) + + assert excinfo.value.retry_after == 8 + + async def test_a_redraw_does_not_mention_the_recipient_a_second_time() -> None: """An edit does not notify, so repeating the handle only adds noise to a message the person it names has already been told about.""" @@ -386,6 +502,78 @@ async def test_two_agents_on_one_message_are_two_independent_marks() -> None: assert other.reactions.calls == [("add", "bot-other", "post-1", "eyes")] +async def test_a_mark_that_did_not_happen_is_raised_rather_than_swallowed() -> None: + """The publisher writes a completion receipt on the strength of what this + tells it. A swallowed failure leaves πŸ‘€ on a finished turn for good.""" + adapter = _adapter() + driver: Any = adapter._bot_drivers["worker"] + driver.reactions.create_error = ConnectionError("temporary network failure") + + with pytest.raises(ConnectionError): + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", working=True + ) + + +async def test_an_agent_with_no_bot_cannot_mark_and_says_so() -> None: + adapter = _adapter("worker") + + with pytest.raises(RuntimeError): + await adapter.mark_activity( + "chan-1", "post-1", agent_name="ghost", working=True + ) + + +async def test_a_failed_mark_is_tried_again_rather_than_recorded_as_done() -> None: + """`self._eyes` is this process's memory of what it has already done. A + failure recorded there would talk the retry out of trying.""" + adapter = _adapter() + driver: Any = adapter._bot_drivers["worker"] + driver.reactions.create_error = ConnectionError("temporary network failure") + with pytest.raises(ConnectionError): + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", working=True + ) + + driver.reactions.create_error = None + await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=True) + + assert driver.reactions.calls == [("add", "bot-worker", "post-1", "eyes")] + + +async def test_clearing_a_mark_mattermost_says_is_gone_is_not_a_failure() -> None: + """The channel is already in the state being asked for, so there is + nothing for the caller to retry.""" + adapter = _adapter() + await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=True) + driver: Any = adapter._bot_drivers["worker"] + driver.reactions.delete_error = ResourceNotFound("404 reaction not found") + + await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=False) + + driver.reactions.delete_error = None + await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=True) + assert driver.reactions.calls == [ + ("add", "bot-worker", "post-1", "eyes"), + ("add", "bot-worker", "post-1", "eyes"), + ] + + +async def test_the_legacy_path_still_logs_a_failed_mark_rather_than_raising( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing on that path retries or records what it did, so raising there + would lose a message over a cosmetic reaction.""" + adapter = _adapter() + driver: Any = adapter._bot_drivers["worker"] + driver.reactions.create_error = ConnectionError("temporary network failure") + + with caplog.at_level(logging.WARNING): + await adapter._track_eyes("chan-1", "worker", "working", "root-1") + + assert caplog.records + + async def test_a_mark_left_over_from_before_a_restart_is_still_cleared() -> None: """After a restart the in-process record is empty, but the πŸ‘€ is still in the channel. Without `force` the removal is skipped as already done.""" @@ -538,3 +726,171 @@ async def test_a_turn_that_failed_says_so_instead_of_listing_its_tools() -> None await adapter.post_rich("chan-1", "worker", content) assert "The session host is offline." in _posts(adapter).created[0]["message"] + + +# ── Who gets told ──────────────────────────────────────────────────────────── + + +async def test_naming_somebody_is_the_only_way_this_platform_reaches_them() -> None: + """A Mattermost thread notifies the people named in it and nobody else, + which is what makes the owner worth naming ahead of whoever asked.""" + activity = SessionTurnActivity(_adapter()) + + assert activity.notifies_only_by_mention + assert not activity.redraws_for_elapsed_time + + +async def test_a_problem_with_nobody_to_name_admits_that_it_told_no_one() -> None: + """Silence here reads as "somebody has been paged". Nobody has.""" + adapter = _adapter() + content = replace( + _activity(), + error_summary="The agent host is offline.", + notify_unreachable=True, + session_url=None, + ) + + await adapter.post_rich("chan-1", "worker", content) + + message = _posts(adapter).created[0]["message"] + assert "notified no one" in message + assert "Switch Console" in message + + +async def test_the_unnotified_notice_is_not_what_pushes_a_post_over_the_limit() -> None: + adapter = _adapter() + items = [_item(kind="assistant-message", title="", text="x" * 9000)] + content = TurnActivity( + items, + _turn("running"), + error_summary="The agent host is offline.", + notify_unreachable=True, + ) + + await adapter.post_rich("chan-1", "worker", content) + + message = _posts(adapter).created[0]["message"] + assert len(message) <= adapter.rich_fallback_limit() + assert message.endswith(adapter.unnotified_notice()) + + +async def test_a_reachable_recipient_is_named_and_told_nothing_about_linking() -> None: + adapter = _adapter(**{"u-owner": "owner"}) + content = replace( + _activity(), + error_summary="The agent host is offline.", + notify_external_id="u-owner", + session_url=None, + ) + + await adapter.post_rich("chan-1", "worker", content) + + message = _posts(adapter).created[0]["message"] + assert "@owner" in message + assert "notified no one" not in message + + +# ── Saying the agent has started ───────────────────────────────────────────── + + +def _typing(adapter: MattermostAdapter, agent_name: str) -> list[dict[str, str]]: + driver: Any = adapter._bot_drivers[agent_name] + return [ + body + for _, endpoint, body in driver.client.requests + if endpoint.endswith("/typing") + ] + + +async def test_a_turn_says_the_agent_has_started_once_and_not_on_every_redraw() -> None: + """The channel shows nothing at all between the command and the first + status. Mattermost expires the indicator itself, so this is a nudge and + not something to switch off β€” and one nudge per turn, because a platform + told again on every redraw shows the agent typing for as long as it ran. + """ + adapter = _adapter() + activity = SessionTurnActivity(adapter) + + for elapsed in (1, 30): + await activity.publish( + [], _turn("running"), elapsed_seconds=elapsed, **_turn_kwargs() + ) + + assert _typing(adapter, "worker") == [{"channel_id": "chan-1"}] + + +async def test_the_nudge_goes_where_the_work_was_asked_for() -> None: + """A command typed inside a thread is watched there; the channel root is + a place the person waiting is not looking.""" + adapter = _adapter() + activity = SessionTurnActivity(adapter) + + await activity.publish( + [], + _turn("running"), + elapsed_seconds=1, + **{**_turn_kwargs(), "asked_on": "reply-9"}, + ) + + assert _typing(adapter, "worker") == [ + {"channel_id": "chan-1", "parent_id": "root-1"} + ] + + +async def test_a_turn_that_is_already_over_does_not_say_it_has_started() -> None: + adapter = _adapter() + activity = SessionTurnActivity(adapter) + + await activity.publish([], _turn("completed"), elapsed_seconds=4, **_turn_kwargs()) + + assert _typing(adapter, "worker") == [] + + +# ── What the publisher is told about a turn ────────────────────────────────── + + +async def test_a_turn_whose_mark_could_not_be_cleared_is_not_reported_complete() -> ( + None +): + """Reported complete, the turn is never published again and the πŸ‘€ stays + on a finished request for good.""" + adapter = _adapter() + activity = SessionTurnActivity(adapter) + await activity.publish([], _turn("running"), elapsed_seconds=1, **_turn_kwargs()) + driver: Any = adapter._bot_drivers["worker"] + driver.reactions.delete_error = ConnectionError("temporary network failure") + + assert not await activity.publish( + [], _turn("completed"), elapsed_seconds=2, **_turn_kwargs() + ) + + +async def test_the_clock_alone_does_not_rewrite_a_running_turns_post() -> None: + """One post carries the whole turn here, so a redraw is the reader's only + post changing under them. It is worth a change they asked about.""" + adapter = _adapter() + activity = SessionTurnActivity(adapter) + items = await _items() + + for elapsed in (1, 30, 44): + await activity.publish( + items, _turn("running"), elapsed_seconds=elapsed, **_turn_kwargs() + ) + + assert _posts(adapter).patched == [] + + +async def test_a_new_tool_does_rewrite_it() -> None: + adapter = _adapter() + activity = SessionTurnActivity(adapter) + items = await _items() + + await activity.publish(items, _turn("running"), elapsed_seconds=1, **_turn_kwargs()) + await activity.publish( + [*items, _item(**{"itemId": "item-read", "title": "Read notes.md"})], + _turn("running"), + elapsed_seconds=2, + **_turn_kwargs(), + ) + + assert len(_posts(adapter).patched) == 1 diff --git a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py new file mode 100644 index 000000000..3ad74dafd --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py @@ -0,0 +1,183 @@ +"""What a plain-text request form may and may not ask a reader to do. + +`request_summary` is the only thing standing between a host's own wording and +a channel where the answer is a number typed into a message. A number is only +an honest thing to ask for while the reader can see what each number means, so +these are the cases where it cannot: a label cut short of what distinguishes +it, a scope the label never mentioned, an option that did not fit at all. +""" + +from __future__ import annotations + +from switch_core.bridges.collaboration.session.renderers import RequestReference +from switch_core.bridges.collaboration.session.renderers.neutral import request_summary +from switch_core.sessions.contract import ( + ApprovalContent, + ApprovalOption, + Question, + QuestionOption, + QuestionsContent, + SnapshotRequest, +) + +REFERENCE = RequestReference(token="tok-1", handle="R42") + + +def _identity(text: str) -> str: + return text + + +def _approval(*options: ApprovalOption, state: str = "open") -> SnapshotRequest: + return SnapshotRequest.model_validate( + { + "requestId": "req-1", + "turnId": "turn-1", + "revision": 1, + "state": state, + "expiresAt": None, + "result": None, + "decidedBy": None, + "content": ApprovalContent( + kind="approval", + title="Run a command?", + detail=None, + options=list(options), + ).model_dump(by_alias=True), + } + ) + + +def _questions(*questions: Question, state: str = "open") -> SnapshotRequest: + return SnapshotRequest.model_validate( + { + "requestId": "req-1", + "turnId": "turn-1", + "revision": 1, + "state": state, + "expiresAt": None, + "result": None, + "decidedBy": None, + "content": QuestionsContent( + kind="questions", title="Before I start", questions=list(questions) + ).model_dump(by_alias=True), + } + ) + + +def _option(option_id: str, label: str, decision: str = "accept") -> ApprovalOption: + return ApprovalOption.model_validate( + {"optionId": option_id, "label": label, "decision": decision} + ) + + +def _render(request: SnapshotRequest, *, limit: int = 4000) -> str: + return request_summary(request, REFERENCE, escape=_identity, limit=limit) + + +def test_a_permission_label_is_shown_whole_rather_than_cut_to_a_short_ceiling(): + """A permission label is routinely a whole command line. Two of them that + differ only past a short ceiling render as the same choice twice.""" + shared = "Run `pytest core/tests/switch_core/bridges/collaboration" + "/x" * 60 + text = _render( + _approval( + _option("one", f"{shared}/test_a.py"), + _option("two", f"{shared}/test_b.py"), + ) + ) + + assert "test_a.py" in text + assert "test_b.py" in text + assert "Reply with `R42 1`." in text + + +def test_options_that_cannot_be_told_apart_are_not_answered_by_number(): + """Cut to the same prefix, 1 and 2 are the same choice on the screen. The + form stops asking for a number rather than inviting the wrong one.""" + shared = "Allow access to " + "x" * 4000 + text = _render( + _approval(_option("one", f"{shared} once"), _option("two", f"{shared} always")) + ) + + assert "Reply with" not in text + assert "Switch Console" in text + + +def test_an_option_that_reaches_beyond_this_session_says_so_on_the_form(): + """Two options can be labelled the same and mean "this once" and "from + now on". The difference is what the reader is choosing between.""" + text = _render( + _approval( + _option("once", "Run the tests"), + _option("always", "Run the tests", decision="acceptForSession"), + ) + ) + + assert text.splitlines()[-3:-1] == [ + "1. Run the tests", + "2. Run the tests (applies for the rest of this session)", + ] + + +def test_a_form_cut_short_by_the_message_limit_stops_asking_for_a_number(): + """Answering by number means answering the numbers on the screen. Some of + them are not on it.""" + text = _render( + _approval(*(_option(f"o{n}", f"Option {n}") for n in range(1, 40))), limit=200 + ) + + assert "more not shown." in text + assert "Reply with" not in text + assert len(text) <= 200 + + +def test_a_settled_form_that_was_cut_still_says_what_was_decided(): + """The notice replaces an instruction, never a record: a reader looking at + a closed request came for the outcome, not for a route to answering it.""" + text = _render( + _approval( + *(_option(f"o{n}", f"Option {n}") for n in range(1, 40)), state="closed" + ), + limit=200, + ) + + assert "Closed without being answered." in text + + +def _question(title: str, *labels: str) -> Question: + return Question.model_validate( + { + "questionId": "q-1", + "title": title, + "prompt": "", + "options": [ + QuestionOption.model_validate( + {"optionId": f"o{n}", "label": label, "description": None} + ) + for n, label in enumerate(labels, start=1) + ], + "multiSelect": False, + "allowCustomAnswer": False, + } + ) + + +def test_a_question_whose_options_were_cut_short_is_not_answerable_here(): + long = "y" * 4000 + text = _render(_questions(_question("Which branch?", f"{long} a", f"{long} b"))) + + assert "Reply with" not in text + assert "Switch Console" in text + + +def test_a_question_whose_title_did_not_fit_is_not_answerable_here(): + """The number answers a question the reader can only see part of.""" + text = _render(_questions(_question("z" * 4000, "main", "release"))) + + assert "Reply with" not in text + assert "Switch Console" in text + + +def test_a_question_that_fits_is_still_answered_by_number(): + text = _render(_questions(_question("Which branch?", "main", "release"))) + + assert "Reply with `R42 1`." in text diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py index 84f98278d..9d7a27cb7 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py @@ -100,6 +100,7 @@ async def test_activity_layout_is_an_adapter_capability_not_a_slack_type_check() post_rich=AsyncMock(side_effect=["C1:status", "C1:log"]), update_rich=AsyncMock(), mark_activity=AsyncMock(), + notify_working=AsyncMock(), ) activity = SessionTurnActivity(platform) kwargs = dict( diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index f04ea6d0b..9404232c8 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -4,7 +4,9 @@ from datetime import UTC, datetime, timedelta import pytest +from mattermostdriver.exceptions import NotEnoughPermissions +from switch_core.bridges.collaboration.adapter import RichContentThrottled from switch_core.bridges.collaboration.session.activity_journal import ActivityJournal from switch_core.bridges.collaboration.session.outbound import SessionTurnActivity from switch_core.bridges.collaboration.slack.adapter import ( @@ -14,6 +16,11 @@ from switch_core.db.models import SdkSession, require_tenant_id from switch_core.sessions.publication import SessionPublisher +from ..bridges.collaboration.test_mattermost_sdk_only import ( + _adapter as mattermost_adapter, +) +from ..bridges.collaboration.test_mattermost_sdk_only import _http_error +from ..bridges.collaboration.test_mattermost_sdk_only import _posts as mm_posts from ..bridges.collaboration.test_session_activity import _items, _turn from .test_authority import opened, setup from .test_publication import Platform @@ -586,3 +593,83 @@ async def test_publisher_reserves_activity_before_an_early_request(session_facto assert "Working" in messages[0].text assert "No tool calls yet" in messages[1].text assert any(block["type"] == "actions" for block in messages[2].blocks) + + +# ── The real Mattermost adapter, not a stand-in ────────────────────────────── +# +# Everything above replaces `post_rich` on a platform object, so it exercises +# the reservation machinery against whatever exception the test chose to +# raise. What decides the reservation's fate in production is the adapter's +# own reading of what the driver threw, and that is not covered by a +# stand-in. These run the same machinery over `MattermostAdapter`. + + +def mattermost(*, agent="Agent"): + adapter = mattermost_adapter(agent) + posts = mm_posts(adapter) + + def remember(post, created): + """Keep what was posted where recovery will look for it.""" + root = post.get("root_id") + record = { + "id": created["id"], + "user_id": f"bot-{agent}", + "props": post.get("props") or {}, + "create_at": len(posts.created), + } + (posts.thread if root else posts.channel)[created["id"]] = record + + return adapter, posts, remember + + +async def test_a_lost_mattermost_response_keeps_its_reservation(session_factory): + """A timeout is not a refusal. The reservation stands, the post is found + again by the marker that travelled in its props, and the channel is left + with one status rather than two.""" + await setup(session_factory) + adapter, posts, remember = mattermost() + accepted = posts.create_post + + def lose_the_response(post): + created = accepted(post) + remember(post, created) + raise TimeoutError("accepted on the server, response lost") + + posts.create_post = lose_the_response + with pytest.raises(TimeoutError): + await publish(activity(session_factory, adapter)) + + posts.create_post = accepted + assert await publish(activity(session_factory, adapter)) + assert len(posts.created) == 1 + + +async def test_a_mattermost_rate_limit_retries_without_a_second_post(session_factory): + await setup(session_factory) + adapter, posts, _ = mattermost() + posts.create_error = _http_error(429, **{"Retry-After": "5"}) + renderer = activity(session_factory, adapter) + + with pytest.raises(RichContentThrottled): + await publish(renderer) + + posts.create_error = None + assert await publish(renderer) + assert len(posts.created) == 1 + + +async def test_a_post_mattermost_refused_is_reserved_again_and_retried( + session_factory, +): + """The other half of the contract: a refusal really does release the + reservation, so the turn is posted rather than waiting for a post that + was never made.""" + await setup(session_factory) + adapter, posts, _ = mattermost() + posts.create_error = NotEnoughPermissions("403 permission denied") + + assert not await publish(activity(session_factory, adapter)) + + posts.create_error = None + assert await publish(activity(session_factory, adapter)) + assert len(posts.created) == 1 diff --git a/core/tests/switch_core/sessions/test_session_presentation.py b/core/tests/switch_core/sessions/test_session_presentation.py index e000e5123..470bfa68f 100644 --- a/core/tests/switch_core/sessions/test_session_presentation.py +++ b/core/tests/switch_core/sessions/test_session_presentation.py @@ -41,7 +41,10 @@ def origin(surface="slack"): ) -async def test_slack_thread_participant_needs_no_mention_or_identity_lookup(): +@pytest.mark.parametrize("prefer_owner", [False, True]) +async def test_slack_thread_participant_needs_no_mention_or_identity_lookup( + prefer_owner, +): db = AsyncMock() assert ( await notification_recipient( @@ -51,6 +54,7 @@ async def test_slack_thread_participant_needs_no_mention_or_identity_lookup(): origin=origin(), agent=SimpleNamespace(owner_id="owner"), thread_id="123.456", + prefer_owner=prefer_owner, ) is None ) @@ -68,6 +72,7 @@ async def test_other_origin_prefers_actor_and_scopes_mapping_to_bridge_and_membe origin=origin("console"), agent=SimpleNamespace(owner_id="owner"), thread_id="123.456", + prefer_owner=False, ) == "UACTOR" ) @@ -91,6 +96,7 @@ async def test_missing_actor_falls_back_to_claimed_owner_in_same_room(): origin=origin("console"), agent=SimpleNamespace(owner_id="owner"), thread_id=None, + prefer_owner=False, ) == "UOWNER" ) @@ -102,6 +108,49 @@ async def test_missing_actor_falls_back_to_claimed_owner_in_same_room(): assert "client_rooms.room_id = 'room'" in query +async def test_mention_only_platform_names_the_owner_ahead_of_the_asker(): + """On a platform where the mention is the whole notification, the person + who can act on a stalled session is named, not whoever typed the command.""" + db = AsyncMock() + db.scalar.return_value = "UOWNER" + + assert ( + await notification_recipient( + db, + bridge_id="bridge", + room_id="room", + origin=origin("mattermost"), + agent=SimpleNamespace(owner_id="owner"), + thread_id="root-1", + prefer_owner=True, + ) + == "UOWNER" + ) + query = str( + db.scalar.call_args.args[0].compile(compile_kwargs={"literal_binds": True}) + ) + assert "external_user_claims.user_id = 'owner'" in query + assert db.scalar.call_count == 1 + + +async def test_an_unclaimed_owner_still_falls_back_to_whoever_asked(): + db = AsyncMock() + db.scalar.side_effect = [None, "UACTOR"] + + assert ( + await notification_recipient( + db, + bridge_id="bridge", + room_id="room", + origin=origin("mattermost"), + agent=SimpleNamespace(owner_id="owner"), + thread_id="root-1", + prefer_owner=True, + ) + == "UACTOR" + ) + + @pytest.mark.parametrize( "turn_status,session_status,online,expected", [ diff --git a/core/tests/switch_core/sessions/test_turn_activity_publication.py b/core/tests/switch_core/sessions/test_turn_activity_publication.py index 1be593918..46bbbc94f 100644 --- a/core/tests/switch_core/sessions/test_turn_activity_publication.py +++ b/core/tests/switch_core/sessions/test_turn_activity_publication.py @@ -26,9 +26,12 @@ class ActivityPlatform: not what a renderer does with it. """ + redraws_for_elapsed_time = True + def __init__(self): self.posts = [] self.edits = [] + self.nudges = [] async def post_rich(self, channel, agent, content, thread): self.posts.append((channel, content, thread)) @@ -37,6 +40,9 @@ async def post_rich(self, channel, agent, content, thread): async def update_rich(self, channel, post, content): self.edits.append((channel, post, content)) + async def notify_working(self, channel, agent, thread_root_id): + self.nudges.append((channel, agent, thread_root_id)) + async def test_a_running_turn_is_published_for_a_real_session(session_factory): service, epoch = await setup(session_factory) @@ -1049,6 +1055,20 @@ async def capture(*args, **kwargs): assert len(platform.edits) == edits +def _elapsed_follows(monkeypatch, clock): + """Report the turn's duration from the test's clock rather than wall time. + + A turn's elapsed time is measured against `datetime.now`, which barely + moves while a test runs, so a redraw the clock earned would be + indistinguishable from no redraw at all. + """ + + async def elapsed(db, session_id, turn_id, *, running=False): + return clock[0] + + monkeypatch.setattr(publication, "_turn_elapsed_seconds", elapsed) + + async def test_running_timer_refreshes_without_new_sdk_events( session_factory, monkeypatch ): @@ -1065,6 +1085,7 @@ async def test_running_timer_refreshes_without_new_sdk_events( monkeypatch.setattr( publication, "time", SimpleNamespace(monotonic=lambda: clock[0]) ) + _elapsed_follows(monkeypatch, clock) await publisher.publish_pending() await publisher.publish_pending() assert platform.edits == [] @@ -1075,6 +1096,78 @@ async def test_running_timer_refreshes_without_new_sdk_events( assert platform.edits[0][2].elapsed_seconds >= platform.posts[0][1].elapsed_seconds +async def test_a_platform_that_does_not_tick_is_not_woken_by_the_clock( + session_factory, monkeypatch +): + """Where the turn is one post the reader is already looking at, a redraw + is their message changing under them. The clock has not earned one, so the + turn is not even offered for republication until something else changes. + """ + + class Untimed(ActivityPlatform): + redraws_for_elapsed_time = False + + service, epoch = await setup(session_factory) + await opened(service, epoch) + platform = Untimed() + publisher = SessionPublisher( + session_factory, + "bridge", + cards_for(session_factory, Platform()), + SessionTurnActivity(platform), + ) + clock = [100.0] + monkeypatch.setattr( + publication, "time", SimpleNamespace(monotonic=lambda: clock[0]) + ) + _elapsed_follows(monkeypatch, clock) + await publisher.publish_pending() + for _ in range(4): + clock[0] += 5 + await publisher.publish_pending() + + assert len(platform.posts) == 1 + assert platform.edits == [] + + +async def test_a_turn_that_ends_is_still_drawn_on_a_platform_that_does_not_tick( + session_factory, monkeypatch +): + """The suppression is of the clock, not of the turn. A reader left with + "Working…" on a finished turn is worse off than one redrawn too often.""" + + class Untimed(ActivityPlatform): + redraws_for_elapsed_time = False + + service, epoch = await setup(session_factory) + await opened(service, epoch) + platform = Untimed() + publisher = SessionPublisher( + session_factory, + "bridge", + cards_for(session_factory, Platform()), + SessionTurnActivity(platform), + ) + await publisher.publish_pending() + await service.ingest( + "agent-demo", + "host-demo", + host_event( + epoch, + 3, + { + "type": "turn.upsert", + "turnId": "turn-demo", + "status": "completed", + "commandId": "message-demo", + }, + ), + ) + await publisher.publish_pending() + + assert [edit[2].turn.status for edit in platform.edits] == ["completed"] + + async def test_rate_limited_activity_retries_at_platform_deadline( session_factory, monkeypatch ): @@ -1095,6 +1188,7 @@ async def test_rate_limited_activity_retries_at_platform_deadline( monkeypatch.setattr( publication, "time", SimpleNamespace(monotonic=lambda: clock[0]) ) + _elapsed_follows(monkeypatch, clock) await publisher.publish_pending() update = AsyncMock( side_effect=[RichContentThrottled(retry_after=17, text="Working"), None] From dc9ca5c640bda8fc1075187874b35a523d90e850 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Mon, 14 Sep 2026 20:24:58 +0100 Subject: [PATCH 003/120] Route an incompletely shown form to Console; admit an unasked card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps left by the previous pass. Clipping that the answer gate never saw. Option labels counted toward "is this form showing itself whole", but the approval title and detail, the question prompt and the option descriptions did not, so a permission detail naming a second operation past 1000 characters still invited `R42 1`, and two options distinguished only past 200 characters still read as the same choice twice. Every piece of a form a reader decides on now goes through one fitter that records whether it had to cut, so a budget cannot be added without the check that goes with it. Descriptions get the label budget for the same reason labels did. `_compose` drops a head line it cannot fit and has no count to report for it, which was the same hole from the other end; that now counts as cut too. A card asked of nobody. `notify_unreachable` reached `TurnActivity` only, so an open request that named no one rendered as if it had notified someone. `RequestCard` carries it too, set on the first post of an open card and never on a redraw β€” where the mention is dropped deliberately, having already been made. Mattermost appends the same notice it appends to a status, charged to the same budget as the form and the handle above it. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 4 + .../collaboration/mattermost/adapter.py | 9 +- .../bridges/collaboration/session/outbound.py | 2 + .../session/renderers/neutral.py | 76 +++++++++------ core/switch_core/sessions/publication.py | 36 +++++-- .../collaboration/test_mattermost_sdk_only.py | 35 +++++++ .../test_session_neutral_forms.py | 97 ++++++++++++++++++- .../sessions/test_session_presentation.py | 51 ++++++++++ 8 files changed, 266 insertions(+), 44 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 55dd970b4..a0a00e54d 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -168,6 +168,10 @@ class RequestCard: # Presentation only: never changes the SDK request or its authorization. unavailable_reason: str | None = None notify_external_id: str | None = None + # As on `TurnActivity`, and for the same reason: a card being asked of + # nobody is not the same as a redraw of one already asked, and only the + # first is worth saying out loud. + notify_unreachable: bool = False RichContent = TurnActivity | RequestCard diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 4cf5b54f3..6b946d5b1 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -671,17 +671,20 @@ def _draw( # The handle goes on its own line rather than in front of the heading: # a card is a block, and a handle wedged before "**Permission needed**" # reads as part of the heading. It is charged to the same budget, or a - # form that just fits becomes a post Mattermost refuses. + # form that just fits becomes a post Mattermost refuses. So is the + # notice below it, which is the same admission the turn status makes: + # a request nobody was named in is a request nobody was asked. lead = f"{mention}\n" if mention else "" + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" body = request_summary( content.request, content.reference, escape=escape, - limit=max(1, limit - len(lead)), + limit=max(1, limit - len(lead) - len(tail)), responder=responder, unavailable_reason=content.unavailable_reason, ) - return f"{lead}{body}" + return f"{lead}{body}{tail}" async def _render_rich(self, content: RichContent) -> str: mention = await self._mention(content.notify_external_id) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index f8301c80c..3a6326a89 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -874,6 +874,7 @@ async def post( agent_name: str, unavailable_reason: str | None = None, notify_external_id: str | None = None, + notify_unreachable: bool = False, ) -> SessionRequestPost: """Reserve the card durably, then send it to the platform. @@ -915,6 +916,7 @@ async def post( reference, unavailable_reason=unavailable_reason, notify_external_id=notify_external_id, + notify_unreachable=notify_unreachable, ), thread_root_id, ) diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 78cc95fb3..a8bee66c7 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -325,28 +325,24 @@ def _approval_form( limit: int, responder: str | None, ) -> tuple[list[str], list[str], str, bool]: + fit = _Faithful(escape) handle = escape(reference.handle) head = [f"**{_HEADINGS[request.state]}** Β· request `{handle}`"] - head.append(_fit(content.title, _share(limit, 1500, 3), escape=escape)) + head.append(fit(content.title, _share(limit, 1500, 3))) if content.detail: - head.append(_fit(content.detail, _share(limit, 1200, 4), escape=escape)) + head.append(fit(content.detail, _share(limit, 1200, 4))) body: list[str] = [] - whole = True if request.state == "open": budget = _label_budget(limit) - whole = all( - _shows_whole(option.label, budget, escape=escape) - for option in content.options - ) body = [ - f"{index}. {_fit(option.label, budget, escape=escape)}{_scope(option)}" + f"{index}. {fit(option.label, budget)}{_scope(option)}" for index, option in enumerate(content.options, start=1) ] # The one state whose footer is an instruction. `_approval_footer` says # why the others are not: nothing to choose, or already answered. invites_answer = request.state == "open" and bool(content.options) - if invites_answer and not whole: + if invites_answer and not fit.whole: return (head, body, _TOO_BIG, False) return ( head, @@ -422,29 +418,25 @@ def _questions_form( limit: int, responder: str | None, ) -> tuple[list[str], list[str], str, bool]: + fit = _Faithful(escape) handle = escape(reference.handle) head = [ f"**{_QUESTION_HEADINGS[request.state]}** Β· request `{handle}`", - _fit(content.title, _share(limit, 1500, 3), escape=escape), + fit(content.title, _share(limit, 1500, 3)), ] body: list[str] = [] - whole = True if request.state == "open": budget = _label_budget(limit) for position, question in enumerate(content.questions, start=1): - title = ( - _fit(question.title, budget, escape=escape) if question.title else "" - ) - whole = whole and _shows_whole(question.title, budget, escape=escape) + title = fit(question.title, budget) if question.title else "" body.append(f"**{position}. {title}**" if title else f"**{position}.**") if question.prompt: - body.append(_fit(question.prompt, _share(limit, 800, 4), escape=escape)) + body.append(fit(question.prompt, _share(limit, 800, 4))) for index, option in enumerate(question.options, start=1): - body.append(_option_line(index, option, escape=escape, limit=limit)) - whole = whole and _shows_whole(option.label, budget, escape=escape) + body.append(_option_line(index, option, fit=fit, limit=limit)) invites_answer = request.state == "open" and unanswerable(content.questions) is None - if invites_answer and not whole: + if invites_answer and not fit.whole: return (head, body, _TOO_BIG, False) return ( head, @@ -460,12 +452,19 @@ def _option_line( index: int, option: QuestionOption, *, - escape: Callable[[str], str], + fit: _Faithful, limit: int, ) -> str: - line = f"{index}. {_fit(option.label, _label_budget(limit), escape=escape)}" + """One numbered choice, label and the description that distinguishes it. + + The description gets the same budget as the label, because it is doing the + same job: two options can share a label and differ only in the description + under it, and clipping that is clipping the difference the reader is being + asked to choose on. + """ + line = f"{index}. {fit(option.label, _label_budget(limit))}" if option.description: - line += f" β€” {_fit(option.description, _share(limit, 200, 8), escape=escape)}" + line += f" β€” {fit(option.description, _label_budget(limit))}" return line @@ -665,8 +664,11 @@ def _compose( break shown.append(line) spent += len(line) + 1 - cut = len(shown) < len(body) - if cut: + # A head line that did not fit is dropped silently β€” there is no count to + # report for a title β€” but it is still the form failing to show itself, + # and `if_cut` is as much the answer for that as for a dropped option. + cut = len(shown) < len(body) or len(lines) < len(head) + if len(shown) < len(body): notice = _CUT.format(left=len(body) - len(shown)) while shown and spent + len(notice) + 1 > limit: spent -= len(shown.pop()) + 1 @@ -707,9 +709,29 @@ def _label_budget(limit: int) -> int: return _share(limit, 1500, 3) -def _shows_whole(text: str, limit: int, *, escape: Callable[[str], str]) -> bool: - """Whether `_fit` will show all of `text`, or have to cut it.""" - return len(escape(text)) <= limit +class _Faithful: + """`_fit`, remembering whether it ever had to cut. + + Every piece of a form a reader decides on goes through one of these, so + "did this show itself whole" is answered by the fitting itself rather than + by a second set of checks kept in step with it by hand. That pairing is + the point: a budget added later without a matching check is how a form + comes to clip the sentence naming a second operation and still ask for a + number. + + Whole means every one of them fitted. A title cut short is as good a + reason not to invite an answer as an option cut short β€” the reader is + choosing on what is in front of them, and a question they can only see + part of is not one a number answers. + """ + + def __init__(self, escape: Callable[[str], str]) -> None: + self._escape = escape + self.whole = True + + def __call__(self, text: str, limit: int) -> str: + self.whole = self.whole and len(self._escape(text)) <= limit + return _fit(text, limit, escape=self._escape) def _scope(option: ApprovalOption) -> str: diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 0901d49ea..784c4cdbc 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -167,6 +167,11 @@ async def refresh_cards( origin.thread_id or origin.message_id, ) ) + # Only the first post of an open card asks anyone. A redraw leaves + # the recipient unset on purpose β€” the mention has been made and + # repeating it is a second notification β€” so "nobody to name" is + # only meaningful here, where naming someone was the intent. + asking = post is None and request.state == "open" recipient = ( await notification_recipient( db, @@ -177,18 +182,34 @@ async def refresh_cards( thread_id=thread_id, prefer_owner=cards.notifies_only_by_mention, ) - if post is None and request.state == "open" + if asking else None ) publications.append( - (request, post, room.id, room.external_channel_id, thread_id, recipient) + ( + request, + post, + room.id, + room.external_channel_id, + thread_id, + recipient, + asking and recipient is None and cards.notifies_only_by_mention, + ) ) epoch = row.epoch agent_name = agent.name db.expunge_all() errors: list[BaseException] = [] backed_off = 0 - for request, post, room_id, channel_id, thread_id, recipient in publications: + for ( + request, + post, + room_id, + channel_id, + thread_id, + recipient, + unreachable, + ) in publications: state = ( request.revision, request.state @@ -210,12 +231,9 @@ async def refresh_cards( session_id=session_id, epoch=epoch, agent_name=agent_name, - **({"notify_external_id": recipient} if recipient else {}), - **( - {"unavailable_reason": unavailable_reason} - if unavailable_reason - else {} - ), + notify_external_id=recipient, + notify_unreachable=unreachable, + unavailable_reason=unavailable_reason, ) refreshed(new_post.token, state) elif post.external_post_id == post.token: diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index e251455df..58e22c102 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -774,6 +774,41 @@ async def test_the_unnotified_notice_is_not_what_pushes_a_post_over_the_limit() assert message.endswith(adapter.unnotified_notice()) +async def test_a_request_asked_of_nobody_admits_it_too() -> None: + """A card is the one post that exists to be answered. Unanswered because + nobody saw it looks exactly like unanswered because nobody has decided.""" + adapter = _adapter() + + await adapter.post_rich("chan-1", "worker", await _card(notify_unreachable=True)) + + message = _posts(adapter).created[0]["message"] + assert "notified no one" in message + assert "Reply with" in message + + +async def test_a_card_redrawn_without_a_mention_does_not_claim_it_reached_nobody() -> ( + None +): + """Every redraw drops the mention on purpose β€” it has already notified. + That is not the same as there having been nobody to name.""" + adapter = _adapter() + + await adapter.post_rich("chan-1", "worker", await _card()) + + assert "notified no one" not in _posts(adapter).created[0]["message"] + + +async def test_the_card_notice_is_not_what_pushes_a_post_over_the_limit() -> None: + adapter = _adapter() + card = await _card(notify_unreachable=True, notify_external_id="u-owner") + + await adapter.post_rich("chan-1", "worker", card) + + message = _posts(adapter).created[0]["message"] + assert len(message) <= adapter.rich_fallback_limit() + assert message.endswith(adapter.unnotified_notice()) + + async def test_a_reachable_recipient_is_named_and_told_nothing_about_linking() -> None: adapter = _adapter(**{"u-owner": "owner"}) content = replace( diff --git a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py index 3ad74dafd..a255cac36 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py @@ -27,7 +27,12 @@ def _identity(text: str) -> str: return text -def _approval(*options: ApprovalOption, state: str = "open") -> SnapshotRequest: +def _approval( + *options: ApprovalOption, + state: str = "open", + title: str = "Run a command?", + detail: str | None = None, +) -> SnapshotRequest: return SnapshotRequest.model_validate( { "requestId": "req-1", @@ -39,8 +44,8 @@ def _approval(*options: ApprovalOption, state: str = "open") -> SnapshotRequest: "decidedBy": None, "content": ApprovalContent( kind="approval", - title="Run a command?", - detail=None, + title=title, + detail=detail, options=list(options), ).model_dump(by_alias=True), } @@ -143,7 +148,37 @@ def test_a_settled_form_that_was_cut_still_says_what_was_decided(): assert "Closed without being answered." in text -def _question(title: str, *labels: str) -> Question: +def test_a_detail_that_names_a_second_operation_is_not_cut_off_mid_form(): + """The options say "Allow" and "Deny"; what is being allowed is in the + detail. Cutting it there is cutting the whole of the decision.""" + text = _render( + _approval( + _option("yes", "Allow"), + _option("no", "Deny", decision="decline"), + detail="Delete the build cache. " + "x" * 4000 + " Also drop the database.", + ) + ) + + assert "Reply with" not in text + assert "Switch Console" in text + + +def test_a_title_the_message_had_no_room_for_stops_the_form_asking(): + """A head line `_compose` cannot fit is dropped with no count to report β€” + there is no "1 more" for a title. Silence is still the reader being asked + to decide on something they cannot see.""" + title = "Should I " + "z" * 50 + "?" + text = _render(_approval(_option("yes", "Allow"), title=title), limit=200) + + assert title not in text + assert "Reply with" not in text + assert "Switch Console" in text + assert len(text) <= 200 + + +def _question( + title: str, *labels: str, descriptions: list[str] | None = None +) -> Question: return Question.model_validate( { "questionId": "q-1", @@ -151,7 +186,13 @@ def _question(title: str, *labels: str) -> Question: "prompt": "", "options": [ QuestionOption.model_validate( - {"optionId": f"o{n}", "label": label, "description": None} + { + "optionId": f"o{n}", + "label": label, + "description": ( + descriptions[n - 1] if descriptions is not None else None + ), + } ) for n, label in enumerate(labels, start=1) ], @@ -181,3 +222,49 @@ def test_a_question_that_fits_is_still_answered_by_number(): text = _render(_questions(_question("Which branch?", "main", "release"))) assert "Reply with `R42 1`." in text + + +def test_options_told_apart_only_by_their_descriptions_are_not_cut_there(): + """The labels are the same on purpose; the description is the difference. + A ceiling that clips it clips the choice.""" + shared = "Deploy the service. " + "d" * 400 + text = _render( + _questions( + _question( + "Which one?", + "Deploy", + "Deploy", + descriptions=[f"{shared} to staging", f"{shared} to production"], + ) + ) + ) + + assert "to staging" in text + assert "to production" in text + assert "Reply with `R42 1`." in text + + +def test_a_description_too_long_for_even_that_stops_the_form_asking(): + text = _render( + _questions( + _question( + "Which one?", + "Deploy", + "Deploy", + descriptions=["y" * 4000 + " to staging", "y" * 4000 + " to live"], + ) + ) + ) + + assert "Reply with" not in text + assert "Switch Console" in text + + +def test_a_prompt_cut_short_is_not_answered_by_number_either(): + """The prompt is where a question says what it actually means.""" + question = _question("Which one?", "main", "release") + question = question.model_copy(update={"prompt": "Note that " + "p" * 4000}) + text = _render(_questions(question)) + + assert "Reply with" not in text + assert "Switch Console" in text diff --git a/core/tests/switch_core/sessions/test_session_presentation.py b/core/tests/switch_core/sessions/test_session_presentation.py index 470bfa68f..bef59b63f 100644 --- a/core/tests/switch_core/sessions/test_session_presentation.py +++ b/core/tests/switch_core/sessions/test_session_presentation.py @@ -231,6 +231,57 @@ async def update_rich(self, channel, post, content): assert len(platform.contents) == 2 assert platform.contents[0].notify_external_id == recipient assert platform.contents[1].notify_external_id is None + # `Capture` does not claim to notify only by mention, so there is nothing + # for an unnamed card to admit to β€” see the mention-only case below. + assert not any(content.notify_unreachable for content in platform.contents) + + +async def test_a_card_nobody_could_be_named_in_says_so_once_not_on_redraws( + session_factory, +): + """The mention is made on the first post and deliberately left off every + redraw, so "nobody to name" is only ever a question about the first.""" + from switch_core.db.models import ExternalUserClaim, SdkSessionCommand + from switch_core.sessions.publication import refresh_cards + + from .test_authority import opened, setup + from .test_publication_retries import cards_for + + service, epoch = await setup(session_factory) + await opened(service, epoch) + async with session_factory() as db, db.begin(): + from sqlalchemy import delete, select + + stored = await db.scalar(select(SdkSessionCommand)) + command = dict(stored.command) + command["origin"] = {**command["origin"], "actorId": "nobody-linked-here"} + stored.command = command + # Nobody in this room has linked the owner's account, and the asker is + # not a platform user either: there is genuinely no one to name. + await db.execute(delete(ExternalUserClaim)) + + class MentionOnly: + notifies_only_by_mention = True + + def __init__(self): + self.contents = [] + + async def post_rich(self, channel, agent, content, thread): + self.contents.append(content) + return "channel-demo:111.0" + + async def update_rich(self, channel, post, content): + self.contents.append(content) + + platform = MentionOnly() + cards = cards_for(session_factory, platform) + for _ in range(2): + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert [content.notify_unreachable for content in platform.contents] == [ + True, + False, + ] async def test_live_session_fault_redraws_activity_with_safe_summary(session_factory): From d8e890aa2de79cf7e16a0649b5b41fbb236fef14 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Mon, 14 Sep 2026 20:59:29 +0100 Subject: [PATCH 004/120] Name the asker, not the owner, when a card needs a mention The owner-first preference went in as a guess at who a stalled session needs. The person waiting on the answer is the better guess: on a platform where the mention is the whole notification, naming somebody else leaves the asker watching a channel that never says their name. The owner stays as the fallback, so a turn started by someone with no account here still reaches somebody. That is what the function did before this branch, so the platform-dependent preference is gone rather than inverted, along with the flag that selected it. Co-Authored-By: Claude Opus 5 --- core/switch_core/sessions/presentation.py | 18 ++++------- core/switch_core/sessions/publication.py | 2 -- .../sessions/test_session_presentation.py | 32 ++++++++----------- 3 files changed, 20 insertions(+), 32 deletions(-) diff --git a/core/switch_core/sessions/presentation.py b/core/switch_core/sessions/presentation.py index 077846f6a..fffcfe4ec 100644 --- a/core/switch_core/sessions/presentation.py +++ b/core/switch_core/sessions/presentation.py @@ -43,18 +43,13 @@ async def notification_recipient( origin: Origin, agent: Agent, thread_id: str | None, - prefer_owner: bool, ) -> str | None: """Slack participants follow replies by default; mention only other origins. - `prefer_owner` names the agent's owner ahead of whoever started the turn. - It is for the platforms where a mention is the whole notification: the - owner is the person who can open Console and act on a stalled session, - and whoever typed the command may not be able to do anything about it. - Where the platform's own following reaches the participants anyway, the - person who asked leads instead β€” they are the one waiting on an answer. - Either way the other is the fallback, so an agent with no owner, or an - owner who has claimed no account here, still reaches somebody. + Whoever asked is named first. They are the one waiting on the answer, and + on a platform where a mention is the whole notification they are also the + one most likely to be looking. The agent's owner is the fallback, so a turn + started by someone who has claimed no account here still reaches somebody. Membership and bridge checks prevent mentioning identities from another room or workspace. No follower API is needed for the usual threaded case. @@ -71,7 +66,7 @@ async def claimed_by(user_id: str | None) -> str | None: # Console commands identify their user directly rather than a puppet. if not user_id: return None - return await db.scalar( + claimant: str | None = await db.scalar( members.join( ExternalUserClaim, ExternalUserClaim.external_user_id == ExternalUser.id ) @@ -79,6 +74,7 @@ async def claimed_by(user_id: str | None) -> str | None: .order_by(ExternalUser.id) .limit(1) ) + return claimant async def initiator() -> str | None: actor = await db.scalar( @@ -89,8 +85,6 @@ async def initiator() -> str | None: ) return actor or await claimed_by(origin.actor_id) - if prefer_owner: - return await claimed_by(agent.owner_id) or await initiator() return await initiator() or await claimed_by(agent.owner_id) diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 784c4cdbc..181c18bc0 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -180,7 +180,6 @@ async def refresh_cards( origin=origin, agent=agent, thread_id=thread_id, - prefer_owner=cards.notifies_only_by_mention, ) if asking else None @@ -608,7 +607,6 @@ async def refresh_activity( origin=origin, agent=agent, thread_id=thread_root_id, - prefer_owner=activity.notifies_only_by_mention, ) if error_summary else None diff --git a/core/tests/switch_core/sessions/test_session_presentation.py b/core/tests/switch_core/sessions/test_session_presentation.py index bef59b63f..87e96cd2d 100644 --- a/core/tests/switch_core/sessions/test_session_presentation.py +++ b/core/tests/switch_core/sessions/test_session_presentation.py @@ -41,10 +41,7 @@ def origin(surface="slack"): ) -@pytest.mark.parametrize("prefer_owner", [False, True]) -async def test_slack_thread_participant_needs_no_mention_or_identity_lookup( - prefer_owner, -): +async def test_slack_thread_participant_needs_no_mention_or_identity_lookup(): db = AsyncMock() assert ( await notification_recipient( @@ -54,7 +51,6 @@ async def test_slack_thread_participant_needs_no_mention_or_identity_lookup( origin=origin(), agent=SimpleNamespace(owner_id="owner"), thread_id="123.456", - prefer_owner=prefer_owner, ) is None ) @@ -72,7 +68,6 @@ async def test_other_origin_prefers_actor_and_scopes_mapping_to_bridge_and_membe origin=origin("console"), agent=SimpleNamespace(owner_id="owner"), thread_id="123.456", - prefer_owner=False, ) == "UACTOR" ) @@ -96,7 +91,6 @@ async def test_missing_actor_falls_back_to_claimed_owner_in_same_room(): origin=origin("console"), agent=SimpleNamespace(owner_id="owner"), thread_id=None, - prefer_owner=False, ) == "UOWNER" ) @@ -108,11 +102,15 @@ async def test_missing_actor_falls_back_to_claimed_owner_in_same_room(): assert "client_rooms.room_id = 'room'" in query -async def test_mention_only_platform_names_the_owner_ahead_of_the_asker(): - """On a platform where the mention is the whole notification, the person - who can act on a stalled session is named, not whoever typed the command.""" +async def test_the_asker_leads_even_where_a_mention_is_the_whole_notification(): + """The person waiting on the answer is named, not the agent's owner. + + A platform that only notifies by mention is the tempting place to name the + owner instead β€” they are the one who can open Console β€” but the mention is + also how the asker learns their own turn needs them, and naming somebody + else leaves them watching a channel that never says their name.""" db = AsyncMock() - db.scalar.return_value = "UOWNER" + db.scalar.return_value = "UACTOR" assert ( await notification_recipient( @@ -122,20 +120,19 @@ async def test_mention_only_platform_names_the_owner_ahead_of_the_asker(): origin=origin("mattermost"), agent=SimpleNamespace(owner_id="owner"), thread_id="root-1", - prefer_owner=True, ) - == "UOWNER" + == "UACTOR" ) query = str( db.scalar.call_args.args[0].compile(compile_kwargs={"literal_binds": True}) ) - assert "external_user_claims.user_id = 'owner'" in query + assert "clients.matrix_user_id = '@actor:switch'" in query assert db.scalar.call_count == 1 -async def test_an_unclaimed_owner_still_falls_back_to_whoever_asked(): +async def test_an_asker_with_no_account_here_still_reaches_the_owner(): db = AsyncMock() - db.scalar.side_effect = [None, "UACTOR"] + db.scalar.side_effect = [None, None, "UOWNER"] assert ( await notification_recipient( @@ -145,9 +142,8 @@ async def test_an_unclaimed_owner_still_falls_back_to_whoever_asked(): origin=origin("mattermost"), agent=SimpleNamespace(owner_id="owner"), thread_id="root-1", - prefer_owner=True, ) - == "UACTOR" + == "UOWNER" ) From 433a46c2e4a3c0a4534a46fce24fc44559890853 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Mon, 14 Sep 2026 21:50:12 +0100 Subject: [PATCH 005/120] Publish SDK sessions through the Discord adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord now carries a turn the way Slack and Mattermost do: one status message per turn, posted into the turn's thread under the agent's webhook identity, edited in place as the turn moves, and a separate reply when something needs a person. A question names the asker, because Discord subscribes you to a thread you started, were mentioned in, or have spoken in and to nothing else β€” a reply that names nobody reaches nobody. Two behaviours are Discord's own. A finished turn's status is deleted when it sits at a channel root and kept when it sits in a thread, so a busy channel is not left with a row of completed statuses while a thread keeps its record. And a webhook message carries no metadata, so recovery after an uncertain send matches the request's visible handle rather than a hidden marker; `find_request_card` takes that handle, and an activity publication, which has none, cannot be recovered that way. The failure contract is what decides whether a reservation survives. A 4xx is Discord refusing and becomes `RichContentFailed`; a 5xx, a timeout or a dropped connection is not an answer and propagates as itself, so the caller keeps its reservation and settles it by searching instead of asking the same question twice. The legacy runtime-state renderer is still present but no longer reachable, so its tests drive `_apply_runtime_state` directly. Removing it is its own task. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 27 +- .../bridges/collaboration/discord/adapter.py | 727 ++++++++++++++++- .../collaboration/mattermost/adapter.py | 4 +- .../bridges/collaboration/session/outbound.py | 19 +- .../bridges/collaboration/slack/adapter.py | 8 +- core/switch_core/sessions/publication.py | 12 +- .../collaboration/test_discord_adapter.py | 26 +- .../collaboration/test_discord_sdk_only.py | 763 ++++++++++++++++++ .../collaboration/test_mattermost_sdk_only.py | 12 +- .../test_slack_block_fallback.py | 2 +- .../sessions/test_activity_durability.py | 2 +- .../sessions/test_publication_retries.py | 4 +- 12 files changed, 1565 insertions(+), 41 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index a0a00e54d..54cf936b0 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -228,12 +228,11 @@ class CollaborationAdapter(ABC): #: Whether a mention is the only way an attention post reaches anyone. #: #: True where nobody follows a thread they are not already in, so a post - #: that names no one is read by no one. Two things follow from that. The - #: agent's owner leads the naming β€” they are the person who can open - #: Console and act on a stalled session, where whoever happened to type - #: the command may be able to do nothing about it. And an attention post - #: with nobody to name says so, because one that notified no one otherwise - #: looks exactly like one that notified the right person. + #: that names no one is read by no one. What follows from that is the + #: admission: an attention post with nobody to name says so, because one + #: that notified no one otherwise looks exactly like one that notified the + #: right person. Who gets named is not decided here β€” the asker leads + #: everywhere, with the agent's owner as the fallback. #: #: False where the platform's own following does that work: a Slack #: participant gets the threaded reply without being named, and naming @@ -713,14 +712,24 @@ async def find_request_card( thread_root_id: str | None, token: str, created_at: datetime, + handle: str | None, ) -> str | None: - """Search for a request card already on the platform, by its token. + """Search for a publication already on the platform, by its marker. `recover` calls this when a post's outcome is uncertain, so it can bind the reservation to what is actually there instead of risking a duplicate. `None` means either nothing was found or, as here, that - this platform has no way to look β€” a card recovers only where an - adapter can search for one, which today is only `SlackAdapter`. + this platform has no way to look β€” a publication recovers only where + an adapter can search for one. + + `token` is the marker the adapter was given to carry, and is what a + platform with somewhere to hide one matches on. `handle` is the + request's own name, the one printed in the card for people to type + back, and it is here for the platform that has nowhere to hide a + marker at all: on Discord a webhook message carries no metadata, so + the visible handle is the only durable thing that distinguishes one + card from another. It is `None` for an activity publication, which + has no handle and so cannot be recovered that way. """ return None diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index ee022fbcd..88e7183c6 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -9,6 +9,7 @@ from collections import OrderedDict from collections.abc import Awaitable, Callable, Coroutine from dataclasses import replace +from datetime import UTC, datetime, timedelta from typing import Any, ClassVar import discord @@ -20,8 +21,16 @@ from switch_core.bridges.collaboration.adapter import ( CollaborationAdapter, LiveRuntimeIndicator, + RequestCard, + RichContent, + RichContentFailed, + RichContentThrottled, + TurnActivity, +) +from switch_core.bridges.collaboration.discord.chunking import ( + MAX_MESSAGE, + chunk_message, ) -from switch_core.bridges.collaboration.discord.chunking import chunk_message from switch_core.bridges.collaboration.discord.slash import ( SlashArgError, build_app_commands, @@ -39,6 +48,11 @@ InboundMessage, InboundUserJoin, ) +from switch_core.bridges.collaboration.session.renderers.neutral import ( + request_summary, + turn_status, +) +from switch_core.sessions.contract import TURN_ENDED logger = logging.getLogger(__name__) @@ -67,6 +81,47 @@ # technique discord.py uses on `@`. _ZERO_WIDTH_SPACE = "\u200b" +# How far before a reservation's own timestamp a recovery search starts, and +# how far back it is willing to read. The allowance covers the gap between +# Switch writing the reservation and Discord stamping the message it is looking +# for; the limit stops a busy channel turning one lookup into a history crawl. +_RECOVERY_SKEW = timedelta(seconds=30) +_RECOVERY_LIMIT = 100 + + +def _as_rich_failure( + error: Exception, *, description: str, text: str +) -> RichContentFailed | None: + """Discord's answer, or no answer at all. + + `None` means the send may or may not have happened, and the caller must + keep its reservation: `RichContentFailed` is a licence to discard one and + try again, which on a request card is a licence to ask the same question + twice. + + A 4xx is Discord refusing, and Discord refusing is an answer. A 5xx is not: + discord.py has already retried it several times by then, and each of those + attempts may have been the one that landed before the response was lost. + Neither is a timeout or a dropped connection, which arrive as + `aiohttp` and `asyncio` errors rather than as anything Discord said. + """ + if isinstance(error, discord.RateLimited): + return RichContentThrottled(retry_after=error.retry_after, text=text) + if isinstance(error, discord.DiscordServerError): + return None + if isinstance(error, discord.HTTPException | ValueError): + return RichContentFailed(f"{description}: {error}", text=text) + return None + + +def _turn_has_ended(content: RichContent) -> bool: + """Whether this publication is a turn with nothing left to happen in it. + + A request card is never one, whatever state its turn is in: the card is the + record of a decision and outlives the turn that asked for it. + """ + return isinstance(content, TurnActivity) and content.turn.status in TURN_ENDED + class _WebhookIdentity: """Keep one accepted identity across chunks and attachment retries.""" @@ -182,6 +237,38 @@ class DiscordAdapter(CollaborationAdapter): # https redirect (`GATEWAY_PUBLIC_URL`) to be clickable here. renders_custom_url_schemes: ClassVar[bool] = False + publishes_sdk_sessions: ClassVar[bool] = True + + # One status per turn, holding its own tool counts. A second message would + # be a second notification for everyone in the thread, and the thread is + # already where the detail is allowed to live. + separate_activity_log: ClassVar[bool] = False + + # A problem somebody has to act on gets its own reply, because the status + # it would otherwise be an edit to is a message they have already read. + separate_attention_slot: ClassVar[bool] = True + + # Discord subscribes you to a thread you started, were mentioned in, or + # have spoken in β€” and to nothing else. The person who asked from the + # channel root is in none of those, so a reply that names nobody reaches + # nobody. + notifies_only_by_mention: ClassVar[bool] = True + + # The status is the turn's one post, so the clock rides along with the next + # real change rather than rewriting a message somebody is reading. See the + # matching choice on Mattermost. + redraws_for_elapsed_time: ClassVar[bool] = False + + supports_activity_reactions: ClassVar[bool] = True + + # Every agent posts through one bot application, so there is one πŸ‘€ between + # them: the first turn to want it adds it and the last to finish removes it. + activity_reactions_per_agent: ClassVar[bool] = False + + # Both paths would draw the same turn. The legacy renderer below is + # retained, not reachable β€” removing it is its own task. + renders_legacy_runtime_state: ClassVar[bool] = False + def __init__(self, *, config: DiscordConnectionConfig) -> None: super().__init__() self._config = config @@ -207,6 +294,16 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: # marked several messages. self._eyes: set[str] = set() self._agent_eyes: dict[tuple[str, str], set[str]] = {} + # Which agent a published status or card was posted as. Needed only in + # a DM, where the name is inlined in the body and an edit has to write + # it again; a webhook post keeps its identity through an edit by itself. + self._rich_agents: OrderedDict[str, str] = OrderedDict() + self._rich_agents_max = 1000 + # Publications this adapter has taken down at the end of a turn, so a + # later redraw of one is recognised as finished rather than reported as + # a message Discord has lost. + self._rich_retired: OrderedDict[str, None] = OrderedDict() + self._rich_retired_max = 1000 # Set once Discord has told us it will not host agent roles, so the # bridge stops asking and says so only once. self._agent_roles_off_reason: str | None = None @@ -703,6 +800,604 @@ async def send_typing( "Failed to trigger typing in Discord channel %s", channel_id ) + # ── SDK session publication ────────────────────────────────────────────── + + def rich_fallback_limit(self) -> int: + return MAX_MESSAGE + + def rich_fallback_text(self, content: RichContent) -> str: + """What a publication says with nothing resolved against the guild. + + The same renderers `post_rich` uses, without the mention, the + responder's handle or the DM name prefix β€” each of which needs + something looked up. This is the string that travels in a + `RichContentFailed`, where the lookups would be decorating a message + nobody is going to see. + """ + return self._draw(content, mention=None, responder=None, prefix="") + + def _draw( + self, + content: RichContent, + *, + mention: str | None, + responder: str | None, + prefix: str, + ) -> str: + escape = self._rich_escape + limit = max(1, self.rich_fallback_limit() - len(prefix)) + if isinstance(content, TurnActivity): + # Charged to the same budget as the status it follows: a message + # that just fits, plus a line saying it reached nobody, is a + # message Discord refuses. + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + body = ( + turn_status( + content.items, + content.turn, + escape=escape, + limit=max(1, limit - len(tail)), + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + mention=mention, + error_summary=content.error_summary, + ) + + tail + ) + return f"{prefix}{body}" + # The mention goes on its own line rather than in front of the heading: + # a card is a block, and a handle wedged before "**Permission needed**" + # reads as part of the heading. + lead = f"{mention}\n" if mention else "" + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + body = request_summary( + content.request, + content.reference, + escape=escape, + limit=max(1, limit - len(lead) - len(tail)), + responder=responder, + unavailable_reason=content.unavailable_reason, + ) + return f"{prefix}{lead}{body}{tail}" + + async def _render_rich( + self, content: RichContent, *, agent_name: str | None, lobby: bool + ) -> str: + """Draw `content` for one place on Discord. + + `lobby` is a real DM, which has no webhook and so no per-message + identity: the agent's name is inlined the way `send_message` inlines + it, and charged to the same 2,000 characters as everything else. + """ + prefix = "" + if lobby: + if agent_name is None: + logger.warning( + "Redrawing a Discord DM publication without knowing which " + "agent posted it, so it loses its name prefix." + ) + else: + prefix = f"**{await self.agent_label_for_body(agent_name)}**: " + responder = ( + self._mention(content.responder_external_id) + if isinstance(content, RequestCard) + else None + ) + return self._draw( + content, + mention=self._mention(content.notify_external_id), + responder=responder, + prefix=prefix, + ) + + def _mention(self, external_user_id: str | None) -> str | None: + """`<@id>` for a Discord user id, or None where there is nothing to name. + + No lookup, unlike the platforms whose mention syntax needs a handle: + Discord resolves the id itself at render time, so this costs no call + and cannot fail for a user this process has never seen. + """ + if not external_user_id: + return None + try: + return f"<@{int(external_user_id)}>" + except ValueError: + logger.warning( + "Cannot mention %r on Discord: it is not a user id.", + external_user_id[:64], + ) + return None + + async def post_rich( + self, + channel_id: str, + agent_name: str, + content: RichContent, + thread_root_id: str | None = None, + ) -> str: + """Post a turn's status or a request's card, as the agent itself. + + Raises on every failure, unlike `send_message`, which reports one by + returning `None`: a publication that silently did not happen is a + reservation nothing retries and a turn the channel never sees. What it + raises is the point β€” `RichContentFailed` is the caller's licence to + discard the reservation and try again, so it is reserved for a refusal + Discord actually gave. A send whose outcome nobody knows raises the + transport's own error and keeps the reservation. + + A thread that cannot be resolved is where the two kinds of content part + company. Progress is suppressed rather than spilled into the main + channel: the reply the agent is working on will arrive there anyway, + and a channel narrating every turn is the noise this presentation + exists to avoid. A card is posted at the channel root instead, because + a question in the wrong place still gets answered and one nobody can + see does not. + """ + fallback = self.rich_fallback_text(content) + try: + target = await self._get_channel(int(channel_id)) + except Exception as error: + raise self._rich_failure( + error, + f"Discord could not resolve channel {channel_id}", + fallback, + ) from error + + lobby = self._channel_type_of(target) == "lobby" + text = await self._render_rich(content, agent_name=agent_name, lobby=lobby) + if lobby: + try: + sent = await target.send( + text, + suppress_embeds=True, + allowed_mentions=_NO_MASS_MENTIONS, + ) + except Exception as error: + raise self._rich_failure( + error, f"Discord refused the post in DM {channel_id}", text + ) from error + return self._remember_rich(f"{sent.channel.id}:{sent.id}", agent_name) + + thread: Any = None + if thread_root_id: + try: + thread = await self._ensure_thread(int(channel_id), thread_root_id) + except Exception as error: + if isinstance(content, TurnActivity): + raise RichContentFailed( + f"Discord has no thread under {thread_root_id} in channel " + f"{channel_id} to show this turn's progress in, and the " + f"channel root is not a substitute for one: {error}", + text=text, + ) from error + logger.warning( + "Could not resolve the Discord thread under %s in channel %s " + "(%s); posting the request at the channel root instead, where " + "it can at least be answered.", + thread_root_id, + channel_id, + error, + ) + + try: + webhook = await self._get_webhook(int(channel_id)) + agent = await self.agent_rendering(agent_name) + payload: dict[str, Any] = { + "content": text, + "avatar_url": agent.icon_url, + "suppress_embeds": True, + "allowed_mentions": _NO_MASS_MENTIONS, + "wait": True, + } + if thread is not None: + payload["thread"] = thread + sent = await _WebhookIdentity(agent.field_label, agent_name).send( + webhook, payload + ) + except Exception as error: + raise self._rich_failure( + error, f"Discord refused the post in channel {channel_id}", text + ) from error + return self._remember_rich(f"{sent.channel.id}:{sent.id}", agent_name) + + async def update_rich( + self, channel_id: str, message_ref: str, content: RichContent + ) -> None: + """Redraw a publication in place β€” or take it down, where it has served + its purpose and staying would just be clutter. + + A turn that has ended leaves nothing behind outside a thread. At the + channel root and in a DM the status was only ever the thing saying work + was happening, and Discord deletes it cleanly, so it goes the way the + legacy indicator went. Inside a thread it stays: a thread is the record + of one exchange, and the outcome, the time it took and the link to the + session belong in it. A request card is never taken down anywhere β€” it + is the record of a decision, and it says on its face what became of it. + + Not `update_message`, which logs and returns. That is right for a + status line nobody is waiting on and wrong here: a card that failed to + redraw is still showing a settled request as open, and the caller has a + reply to post about that β€” but only if it is told. + """ + _, message_id = self._parse_message_ref(message_ref) + if not message_id: + raise RichContentFailed( + f"Cannot redraw Discord publication {message_ref!r}: it is not a " + "location:message reference.", + text=self.rich_fallback_text(content), + ) + if message_ref in self._rich_retired: + return + + try: + target = await self._get_channel(int(channel_id)) + except Exception as error: + raise self._rich_failure( + error, + f"Discord could not resolve channel {channel_id}", + self.rich_fallback_text(content), + ) from error + + lobby = self._channel_type_of(target) == "lobby" + # A post notifies; an edit does not. Repeating the mention on every + # redraw would be a handle in the channel that never reaches anybody + # it has not already reached. + text = await self._render_rich( + replace(content, notify_external_id=None), + agent_name=self._rich_agents.get(message_ref), + lobby=lobby, + ) + if self._is_flat(channel_id, message_ref) and _turn_has_ended(content): + await self._retire_rich(channel_id, message_ref, text, lobby=lobby) + return + await self._edit_rich(channel_id, message_ref, text, lobby=lobby) + + def _is_flat(self, channel_id: str, message_ref: str) -> bool: + """Whether a publication is sitting in the channel rather than a thread. + + A Discord thread is a channel of its own, so the location half of the + ref differs from the channel the turn belongs to exactly when the post + went into a thread. + """ + location_id, _ = self._parse_message_ref(message_ref) + return location_id == channel_id + + async def _retire_rich( + self, channel_id: str, message_ref: str, text: str, *, lobby: bool + ) -> None: + location_id, message_id = self._parse_message_ref(message_ref) + try: + if lobby: + target = await self._get_channel(int(location_id or channel_id)) + await target.get_partial_message(int(message_id)).delete() + else: + webhook = await self._get_webhook(int(channel_id)) + await webhook.delete_message(int(message_id)) + except discord.NotFound: + pass + except Exception as error: + failure = _as_rich_failure( + error, + description=( + f"Discord refused to remove the finished status {message_ref} " + f"in channel {channel_id}" + ), + text=text, + ) + if failure is None: + raise + # Visibly degraded rather than quietly wrong: the status cannot be + # taken down, so it is left saying what actually happened instead + # of saying the turn is still running. + logger.warning( + "Could not remove the finished Discord status %s in channel %s " + "(%s); leaving its final state in the channel instead.", + message_ref, + channel_id, + error, + ) + await self._edit_rich(channel_id, message_ref, text, lobby=lobby) + return + self._retire_ref(message_ref) + + async def _edit_rich( + self, channel_id: str, message_ref: str, text: str, *, lobby: bool + ) -> None: + location_id, message_id = self._parse_message_ref(message_ref) + try: + if lobby: + target = await self._get_channel(int(location_id or channel_id)) + message = await target.fetch_message(int(message_id)) + await message.edit(content=text, allowed_mentions=_NO_MASS_MENTIONS) + return + kwargs: dict[str, Any] = {} + if location_id and location_id != channel_id: + kwargs["thread"] = discord.Object(id=int(location_id)) + webhook = await self._get_webhook(int(channel_id)) + await webhook.edit_message( + int(message_id), + content=text, + allowed_mentions=_NO_MASS_MENTIONS, + **kwargs, + ) + except Exception as error: + raise self._rich_failure( + error, + f"Discord refused the edit to {message_ref} in channel {channel_id}", + text, + ) from error + + def _rich_failure(self, error: Exception, description: str, text: str) -> Exception: + """The exception to raise for `error`: Discord's refusal, or its own. + + Raising the original back is what keeps an uncertain send's reservation + alive, so this returns rather than raises β€” the caller writes + `raise ... from error` and the chain stays intact either way. + """ + return _as_rich_failure(error, description=description, text=text) or error + + async def find_request_card( + self, + channel_id: str, + thread_root_id: str | None, + token: str, + created_at: datetime, + handle: str | None, + ) -> str | None: + """Look for a card this bridge may already have posted. + + Asked when a post's outcome is unknown β€” the request timed out, or the + process died between sending and recording the id. The answer decides + between binding the reservation to what is there and asking the same + question twice, so a lookup that cannot be trusted comes back as + `None`: the reservation survives and the question is asked again later. + + Matched on the handle, because on Discord there is nothing else to + match on. A webhook message carries no metadata and no props, so the + marker that serves Slack and Mattermost has nowhere to live β€” but the + card prints its own handle, in a phrase (``request `R7` ``) that no + answer typed back at it reproduces. Narrowed further to messages this + bridge posted, so that a person quoting the card is not mistaken for + it. + + `handle` is `None` for a turn's activity, which prints no handle and so + cannot be found this way. That publication stays unconfirmed rather + than being posted twice β€” see the warning below. + """ + if handle is None: + logger.warning( + "Cannot look for the Discord publication marked %s in channel %s: " + "a webhook message carries no metadata here, so only a request " + "card, which prints its own handle, can be recognised again. This " + "turn's status stays unconfirmed rather than being posted twice.", + token, + channel_id, + ) + return None + client = self._client + if client is None: + logger.warning( + "Cannot look for card %s in Discord channel %s: not connected.", + handle, + channel_id, + ) + return None + + stamped = created_at if created_at.tzinfo else created_at.replace(tzinfo=UTC) + after = stamped - _RECOVERY_SKEW + wanted = f"request `{handle}`" + for place in await self._recovery_places(channel_id, thread_root_id): + authors = await self._bridge_author_ids(place) + try: + async for message in place.history( + after=after, limit=_RECOVERY_LIMIT, oldest_first=True + ): + if message.webhook_id not in authors: + continue + if wanted in (message.content or ""): + return f"{message.channel.id}:{message.id}" + except Exception as e: + logger.warning( + "Could not read Discord channel %s looking for card %s: %s.", + place.id, + handle, + e, + ) + return None + + async def _recovery_places( + self, channel_id: str, thread_root_id: str | None + ) -> list[Any]: + """Where a card posted for this channel and thread could have landed. + + Both, in order, because `post_rich` falls back to the channel root when + a thread cannot be resolved β€” so a card whose outcome is unknown may be + in either. The thread is only looked up, never created: creating one + here would be answering "where is it?" by making a new empty place it + certainly is not in. + """ + places: list[Any] = [] + if thread_root_id: + thread_id = self._thread_channel_id(thread_root_id) + if thread_id is not None: + try: + places.append(await self._get_channel(thread_id)) + except Exception as e: + logger.warning( + "Could not open the Discord thread under %s: %s.", + thread_root_id, + e, + ) + try: + places.append(await self._get_channel(int(channel_id))) + except Exception as e: + logger.warning("Could not open Discord channel %s: %s.", channel_id, e) + return places + + async def _bridge_author_ids(self, place: Any) -> set[int]: + """The webhook ids a publication in `place` could have been posted by. + + Resolved rather than read off the cache: after a restart nothing has + posted to this channel yet, so the cache is empty and every message in + it would look like somebody else's. + """ + parent = getattr(place, "parent", None) or place + if self._channel_type_of(parent) == "lobby": + return set() + try: + self._webhook_ids.add((await self._get_webhook(parent.id)).id) + except Exception as e: + logger.warning( + "Could not resolve the Discord webhook for channel %s, so a " + "publication there cannot be told from anybody else's message: %s.", + parent.id, + e, + ) + return set(self._webhook_ids) + + async def is_first_reply( + self, channel_id: str, root_ref: str, message_ref: str + ) -> bool: + """Whether this message is the first thing said inside a thread. + + A Discord thread is a channel whose id is the id of the message it was + created from, and the root message itself lives in the parent channel β€” + so the thread's first message is the first reply, with nothing to skip + over. Read from Discord each time rather than counted here: two replies + arriving at once would both look like the first to anything counting + locally, and each would decide the request. + + Never raises. This is on the inbound path of every message, ahead of + the relay, so an exception out of it is not a refused answer but a + message the room never sees. + """ + thread_id = self._thread_channel_id(root_ref) + if thread_id is None or self._client is None: + logger.warning( + "Cannot read the Discord thread under %s in %s, so %s does not " + "answer the card there.", + root_ref, + channel_id, + message_ref, + ) + return False + try: + thread = await self._get_channel(thread_id) + async for message in thread.history(limit=1, oldest_first=True): + return f"{message.channel.id}:{message.id}" == message_ref + except Exception as e: + logger.warning( + "Could not read the Discord thread under %s in %s: %s. Treating " + "%s as not the first reply.", + root_ref, + channel_id, + e, + message_ref, + ) + return False + + async def mark_activity( + self, + channel_id: str, + message_ref: str, + *, + agent_name: str, + working: bool, + force: bool = False, + ) -> None: + """Put πŸ‘€ on the message being worked on, or take it off. + + One mark between every agent, because every agent posts through one + bot application here and a reaction belongs to whoever added it. The + publisher already counts the turns holding it, so the first to want it + adds it and the last to finish removes it. + + `force` is the durable publisher reconciling after a restart, when this + process's record of what is already on the message is empty and wrong + rather than empty and right. + + Raises where another attempt might work, so the publisher retries and + records the turn as drawn only once the channel shows what it says it + shows. A missing permission is not that: it would be retried for the + life of the turn and refused every time, so it is reported once and + the turn goes on without the mark. + """ + _, message_id = self._parse_message_ref(message_ref) + if not message_id: + logger.warning( + "Cannot mark %s as being worked on: not a Discord message reference.", + message_ref, + ) + return + if not force and working == (message_ref in self._eyes): + return + await self._react(message_ref, working=working) + + async def notify_working( + self, channel_id: str, agent_name: str, thread_root_id: str | None + ) -> None: + """The one-shot typing nudge, where the agent was asked. + + Discord expires it after about ten seconds, so it costs the channel + nothing and it is the only signal that arrives before the first post. + Sent into a thread only if that thread already exists: a typing + indicator is not worth creating a thread for, and one created here + would be an empty thread on a message somebody may never get a reply in. + """ + target: Any = None + if thread_root_id: + thread_id = self._thread_channel_id(thread_root_id) + if thread_id is not None and self._client is not None: + target = self._client.get_channel(thread_id) + if target is None: + try: + target = await self._get_channel(int(channel_id)) + except Exception as e: + logger.warning( + "Could not open Discord channel %s to signal that %s has " + "started: %s.", + channel_id, + agent_name, + e, + ) + return + try: + await target.typing() + except Exception as e: + logger.warning( + "Could not signal in Discord channel %s that %s has started: %s.", + channel_id, + agent_name, + e, + ) + + def _remember_rich(self, message_ref: str, agent_name: str) -> str: + self._rich_agents[message_ref] = agent_name + self._rich_agents.move_to_end(message_ref) + while len(self._rich_agents) > self._rich_agents_max: + self._rich_agents.popitem(last=False) + return message_ref + + def _retire_ref(self, message_ref: str) -> None: + self._rich_agents.pop(message_ref, None) + self._rich_retired[message_ref] = None + self._rich_retired.move_to_end(message_ref) + while len(self._rich_retired) > self._rich_retired_max: + self._rich_retired.popitem(last=False) + + @staticmethod + def _thread_channel_id(thread_root_ref: str) -> int | None: + """The id of the thread rooted at this message ref. + + A Discord thread is a channel whose id equals the id of the message it + was created from, so the ref's message half is the thread's id β€” + whether or not the thread has been created yet. + """ + try: + return int(thread_root_ref.split(":", 1)[-1]) + except ValueError: + return None + # ── Runtime state ──────────────────────────────────────────────────────── async def _apply_runtime_state( @@ -806,12 +1501,31 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: available. A guild that has not granted the permission gets one warning and no reaction, rather than a mark that is not there. """ - location_id, message_id = self._parse_message_ref(message_ref) + _, message_id = self._parse_message_ref(message_ref) if not message_id or self._client is None: return if working == (message_ref in self._eyes): return + try: + await self._react(message_ref, working=working) + except (discord.HTTPException, ValueError) as e: + logger.warning( + "Could not %s the working reaction on Discord message %s: %s", + "add" if working else "remove", + message_ref, + e, + ) + + async def _react(self, message_ref: str, *, working: bool) -> None: + """Add or remove πŸ‘€, letting through whatever another attempt might fix. + + Two endings are final rather than worth retrying: the message is gone, + or this guild will never allow the reaction. Everything else is left to + raise, so a caller that can try again knows it should. + """ + location_id, message_id = self._parse_message_ref(message_ref) + client = self._require_client() try: channel = await self._get_channel(int(location_id)) message = channel.get_partial_message(int(message_id)) @@ -819,7 +1533,7 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: await message.add_reaction(_WORKING_REACTION) self._eyes.add(message_ref) else: - await message.remove_reaction(_WORKING_REACTION, self._client.user) + await message.remove_reaction(_WORKING_REACTION, client.user) self._eyes.discard(message_ref) except discord.NotFound: # The message (or the reaction) is gone; the end state is what was @@ -833,13 +1547,6 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: "Re-invite the bot with the permissions in DISCORD_SETUP.md.", message_ref, ) - except (discord.HTTPException, ValueError) as e: - logger.warning( - "Could not %s the working reaction on Discord message %s: %s", - "add" if working else "remove", - message_ref, - e, - ) async def _clear_working(self, channel_id: str, agent_name: str) -> None: live = self._working_msg.pop((channel_id, agent_name), None) diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 6b946d5b1..7a1f8fb64 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -806,6 +806,7 @@ async def find_request_card( thread_root_id: str | None, token: str, created_at: datetime, + handle: str | None, ) -> str | None: """Look for a publication this bridge may already have posted. @@ -818,7 +819,8 @@ async def find_request_card( Matched on the props marker and on the post's author, because a token quoted back in somebody's message must not be mistaken for the post - that carries it. + that carries it. `handle` is unused for the same reason it is on + Slack: the props marker is exact, and invisible to a reader. """ driver = self._admin_driver loop = self._main_loop diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 3a6326a89..342c7f7fc 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -181,6 +181,18 @@ def notifies_only_by_mention(self) -> bool: """ return self._only_mentions_notify + @property + def renders_custom_url_schemes(self) -> bool: + """Whether a `switchdash://` link is a link here at all. + + Read by the caller that builds the Console link, which is the only + place that holds both the deeplink and the gateway's public URL to + rewrite it against. False sends the browser redirect instead, because + a platform that linkifies only http(s) shows the raw deeplink as text + somebody would have to copy. + """ + return bool(getattr(self._adapter, "renders_custom_url_schemes", True)) + @property def redraws_for_elapsed_time(self) -> bool: """Whether a running turn is worth redrawing for the clock alone. @@ -400,6 +412,7 @@ async def _post_activity( delivery["thread"], delivery["token"], datetime.fromisoformat(delivery["created_at"]), + None, ) if ref is None: raise CardNotPosted( @@ -942,7 +955,11 @@ async def post( async def recover(self, post: SessionRequestPost) -> SessionRequestPost: """Bind an uncertain delivery to its existing platform message; never repost.""" ref = await self._adapter.find_request_card( - post.external_channel_id, post.thread_id, post.token, post.created_at + post.external_channel_id, + post.thread_id, + post.token, + post.created_at, + post.handle, ) if ref is None: raise CardNotPosted( diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 128df4ad3..465c5121e 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -400,8 +400,14 @@ async def find_request_card( thread_root_id: str | None, token: str, created_at: datetime, + handle: str | None, ) -> str | None: - """Find a reserved request or activity post by its shared recovery marker.""" + """Find a reserved request or activity post by its shared recovery marker. + + `handle` is unused: Slack carries the marker in the message's own + `block_id`, which is exact and invisible, so there is nothing the + printed handle would add. + """ if self._web_client is None: raise RuntimeError( "Cannot recover a request card: Slack client not connected." diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 181c18bc0..b38ac646c 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -25,6 +25,7 @@ require_tenant_id, ) from switch_core.db.stores.session_request_post_store import SessionRequestPostStore +from switch_core.deeplinks import deeplink_for_platform from switch_core.sessions.contract import ( TURN_ENDED, Command, @@ -614,8 +615,15 @@ async def refresh_activity( metadata: dict[str, Any] = { key: value for key, value in { - "session_url": session_console_url( - gateway_public_url, agent.id, room.id, row.id + # Rewritten here rather than in the renderer: this is the + # only place holding both the deeplink and the gateway URL + # the redirect has to come from. + "session_url": deeplink_for_platform( + session_console_url( + gateway_public_url, agent.id, room.id, row.id + ), + gateway_public_url, + activity.renders_custom_url_schemes, ), "notify_external_id": recipient, # Somebody has to act on this and there is nobody here to diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py index 2ff6c4d82..c1d012187 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py @@ -911,6 +911,14 @@ def test_send_typing_triggers_once_and_off_is_noop() -> None: # ── Runtime state (working-on-it activity) ────────────────────────────────── +# +# These drive `_apply_runtime_state` rather than the public entry point because +# the public one no longer reaches it: Discord now publishes SDK sessions and +# declares `renders_legacy_runtime_state = False`, so the base class stops the +# legacy path before the adapter sees it. The implementation is still here and +# still correct; what it no longer has is a caller. Removing it is its own task +# β€” until then these keep it honest, and `test_discord_sdk_only.py` covers the +# disabled ingress itself. def _runtime_setup() -> tuple[DiscordAdapter, _FakeChannel, _FakeWebhook]: @@ -926,7 +934,7 @@ def test_runtime_state_working_posts_persistent_indicator() -> None: adapter, _, webhook = _runtime_setup() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "working", @@ -948,7 +956,7 @@ def test_runtime_state_detail_edits_message_in_place() -> None: adapter, _, webhook = _runtime_setup() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "working", @@ -957,7 +965,7 @@ def test_runtime_state_detail_edits_message_in_place() -> None: ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "working", @@ -981,7 +989,7 @@ def test_runtime_state_idle_clears_working_message() -> None: adapter, channel, webhook = _runtime_setup() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "working", @@ -990,7 +998,7 @@ def test_runtime_state_idle_clears_working_message() -> None: ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "idle", @@ -1008,7 +1016,7 @@ def test_runtime_state_awaiting_input_pings_and_resume_clears_pings() -> None: adapter, channel, webhook = _runtime_setup() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "working", @@ -1017,7 +1025,7 @@ def test_runtime_state_awaiting_input_pings_and_resume_clears_pings() -> None: ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "awaiting-input", @@ -1035,7 +1043,7 @@ def test_runtime_state_awaiting_input_pings_and_resume_clears_pings() -> None: # Resuming work means the input was provided β€” the ping is deleted, the # working indicator is refreshed in place. _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "working", @@ -1269,7 +1277,7 @@ def test_awaiting_input_with_nobody_linked_says_so() -> None: adapter, _channel, webhook = _runtime_setup() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHANNEL_ID), "my-agent", "awaiting-input", diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py new file mode 100644 index 000000000..95428b59b --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -0,0 +1,763 @@ +"""Discord publishes SDK sessions, and the legacy renderer no longer runs. + +What is under test here is the rich-content seam: the compact status and the +plain-text request card, posted under the agent's own webhook identity, edited +in place, taken down at the end of a turn where a thread is not holding it, +found again after an uncertain delivery, and loud when any of that fails. + +The old runtime-state renderer is still in the file (removing it is its own +task) but nothing routes to it any more. The first test holds that line: the +two renderers must not both draw, or every turn appears twice. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import discord +import pytest + +from switch_core.bridges.collaboration.adapter import ( + RequestCard, + RichContentFailed, + RichContentThrottled, + TurnActivity, +) +from switch_core.bridges.collaboration.discord.adapter import ( + DiscordAdapter, + DiscordConnectionConfig, +) +from switch_core.bridges.collaboration.session.renderers import RequestReference +from switch_core.bridges.collaboration.session.transport import ( + FixtureEventSource, + project, +) + +from .test_session_activity import _item, _turn + +REPO_ROOT = Path(__file__).resolve().parents[5] +EXAMPLES_PATH = REPO_ROOT / "console/packages/shared/src/session-v1/examples.json" + +GUILD_ID = 900 +CHANNEL_ID = 100 +DM_CHANNEL_ID = 555 +BOT_USER_ID = 42 +WEBHOOK_ID = 77 +ROOT_MESSAGE_ID = 321 +ASKER_ID = "60606" + + +# ── Fakes ──────────────────────────────────────────────────────────────────── + + +class _Response: + status = 400 + reason = "Bad Request" + + +def _http_error(status: int) -> discord.HTTPException: + response = _Response() + response.status = status # type: ignore[misc] + if status >= 500: + return discord.DiscordServerError(response, "upstream") # type: ignore[arg-type] + return discord.HTTPException(response, "refused") # type: ignore[arg-type] + + +class _Role: + pass + + +class _Guild: + def __init__(self) -> None: + self.id = GUILD_ID + self.default_role = _Role() + + +class _Overwrite: + view_channel = True + + +class _Message: + def __init__(self, channel: Any, message_id: int, content: str = "") -> None: + self.id = message_id + self.channel = channel + self.content = content + self.webhook_id: int | None = None + self.edited: str | None = None + self.deleted = False + + async def edit(self, *, content: str, **kwargs: Any) -> None: + self.edited = content + + async def delete(self) -> None: + self.deleted = True + self.channel.deleted_ids.append(self.id) + + async def create_thread(self, *, name: str) -> Any: + raise AssertionError("nothing in this seam may create a thread") + + +class _PartialMessage: + def __init__(self, channel: Any, message_id: int) -> None: + self.id = message_id + self._channel = channel + + async def delete(self) -> None: + if self._channel.delete_error is not None: + raise self._channel.delete_error + self._channel.deleted_ids.append(self.id) + + async def add_reaction(self, emoji: str) -> None: + if self._channel.reaction_error is not None: + raise self._channel.reaction_error + self._channel.reactions.append((emoji, True)) + + async def remove_reaction(self, emoji: str, user: Any) -> None: + if self._channel.reaction_error is not None: + raise self._channel.reaction_error + self._channel.reactions.append((emoji, False)) + + +class _Channel: + def __init__(self, channel_id: int = CHANNEL_ID, *, guild: Any | None = None): + self.id = channel_id + self.guild = guild if guild is not None else _Guild() + self.name = "general" + self.sent: list[dict[str, Any]] = [] + self.deleted_ids: list[int] = [] + self.reactions: list[tuple[str, bool]] = [] + self.reaction_error: Exception | None = None + self.typing_count = 0 + self.messages: dict[int, _Message] = {} + self.history_messages: list[_Message] = [] + self.history_error: Exception | None = None + self.send_error: Exception | None = None + self.delete_error: Exception | None = None + self.existing_webhooks: list[Any] = [] + self.webhook_error: Exception | None = None + + def overwrites_for(self, role: Any) -> _Overwrite: + return _Overwrite() + + async def send(self, content: str, **kwargs: Any) -> Any: + if self.send_error is not None: + raise self.send_error + self.sent.append({"content": content, **kwargs}) + message = _Message(self, 500 + len(self.sent), content) + self.messages[message.id] = message + return message + + async def typing(self) -> None: + self.typing_count += 1 + + async def fetch_message(self, message_id: int) -> _Message: + message = self.messages.get(message_id) + if message is None: + raise discord.NotFound(_Response(), "message not found") # type: ignore[arg-type] + return message + + def get_partial_message(self, message_id: int) -> _PartialMessage: + return _PartialMessage(self, message_id) + + async def webhooks(self) -> list[Any]: + if self.webhook_error is not None: + raise self.webhook_error + return self.existing_webhooks + + async def create_webhook(self, *, name: str) -> Any: + webhook = _Webhook() + self.existing_webhooks.append(webhook) + return webhook + + def history(self, **kwargs: Any) -> Any: + messages = list(self.history_messages) + error = self.history_error + + class _History: + def __aiter__(self) -> Any: + return self + + async def __anext__(self) -> _Message: + if error is not None: + raise error + if not messages: + raise StopAsyncIteration + return messages.pop(0) + + return _History() + + +class _DMChannel(_Channel): + def __init__(self) -> None: + super().__init__(DM_CHANNEL_ID, guild=None) + self.guild = None + + async def webhooks(self) -> list[Any]: + raise AssertionError("a DM channel has no webhooks") + + +class _Thread(_Channel): + def __init__(self, parent: _Channel, thread_id: int = ROOT_MESSAGE_ID) -> None: + super().__init__(thread_id, guild=parent.guild) + self.parent = parent + self.parent_id = parent.id + + +class _Webhook: + def __init__(self) -> None: + self.id = WEBHOOK_ID + self.name = "Switch Bridge" + self.token = "tok" + self.sent: list[dict[str, Any]] = [] + self.edits: list[dict[str, Any]] = [] + self.deletes: list[dict[str, Any]] = [] + self.send_error: Exception | None = None + self.edit_error: Exception | None = None + self.delete_error: Exception | None = None + + async def send(self, **kwargs: Any) -> Any: + if self.send_error is not None: + raise self.send_error + self.sent.append(kwargs) + thread = kwargs.get("thread") + channel = thread if thread is not None else _Channel() + return _Message(channel, 900 + len(self.sent), kwargs.get("content", "")) + + async def edit_message(self, message_id: int, **kwargs: Any) -> None: + if self.edit_error is not None: + raise self.edit_error + self.edits.append({"message_id": message_id, **kwargs}) + + async def delete_message(self, message_id: int, **kwargs: Any) -> None: + if self.delete_error is not None: + raise self.delete_error + self.deletes.append({"message_id": message_id, **kwargs}) + + +class _Client: + def __init__(self, channels: dict[int, Any]) -> None: + self._channels = channels + self.user = object() + + def get_channel(self, channel_id: int) -> Any | None: + return self._channels.get(channel_id) + + async def fetch_channel(self, channel_id: int) -> Any: + channel = self._channels.get(channel_id) + if channel is None: + raise discord.NotFound(_Response(), "unknown channel") # type: ignore[arg-type] + return channel + + +def _adapter(channels: dict[int, Any]) -> DiscordAdapter: + adapter = DiscordAdapter( + config=DiscordConnectionConfig(bot_token="token", guild_id=str(GUILD_ID)) + ) + adapter._bot_user_id = BOT_USER_ID + adapter._client = _Client(channels) # type: ignore[assignment] + return adapter + + +def _guild_setup() -> tuple[DiscordAdapter, _Channel, _Thread, _Webhook]: + channel = _Channel() + thread = _Thread(channel) + adapter = _adapter({CHANNEL_ID: channel, ROOT_MESSAGE_ID: thread}) + webhook = _Webhook() + adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhook_ids.add(webhook.id) + return adapter, channel, thread, webhook + + +def _activity(**kwargs: Any) -> TurnActivity: + items = [_item(kind="assistant-message", title="", text="Looking now.")] + return TurnActivity(items, _turn("running"), **kwargs) + + +def _ended(**kwargs: Any) -> TurnActivity: + items = [_item(kind="assistant-message", title="", text="Done.")] + return TurnActivity(items, _turn("completed"), **kwargs) + + +async def _card(**kwargs: Any) -> RequestCard: + source = FixtureEventSource.from_examples(EXAMPLES_PATH, events=[]) + projection = await project(source, "session-demo") + request = projection.open_requests()[0] + return RequestCard(request, RequestReference(token="tok-1", handle="R7"), **kwargs) + + +# ── The legacy renderer is off ─────────────────────────────────────────────── + + +async def test_the_legacy_renderer_no_longer_draws_alongside_the_sdk_one() -> None: + """Both would draw the same turn, and the channel would show it twice.""" + adapter, channel, _thread, webhook = _guild_setup() + + for state in ("working", "awaiting-input", "idle"): + await adapter.apply_runtime_state( + str(CHANNEL_ID), + "my-agent", + state, + mention_handle="someone", + thread_root_id=None, + ) + await adapter.reposition_runtime_state(str(CHANNEL_ID), "my-agent", None) + + assert webhook.sent == [] + assert channel.sent == [] + assert adapter.renders_legacy_runtime_state is False + assert adapter.publishes_sdk_sessions is True + + +def test_discord_names_the_asker_because_nothing_else_reaches_them() -> None: + """A thread reply notifies only its members, and the asker is not one.""" + adapter, _channel, _thread, _webhook = _guild_setup() + + assert adapter.notifies_only_by_mention is True + assert adapter.separate_attention_slot is True + assert adapter.separate_activity_log is False + assert adapter.supports_activity_reactions is True + assert adapter.activity_reactions_per_agent is False + + +# ── Posting ────────────────────────────────────────────────────────────────── + + +async def test_a_turn_posts_into_its_thread_under_the_agents_own_identity() -> None: + adapter, channel, thread, webhook = _guild_setup() + + ref = await adapter.post_rich( + str(CHANNEL_ID), "my-agent", _activity(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert channel.sent == [] + assert webhook.sent[0]["username"] == "my-agent" + assert webhook.sent[0]["thread"] is thread + assert webhook.sent[0]["wait"] is True + assert ref == f"{ROOT_MESSAGE_ID}:901" + + +async def test_a_card_names_the_asker_and_prints_the_handle_it_answers_to() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + + await adapter.post_rich( + str(CHANNEL_ID), + "my-agent", + await _card(notify_external_id=ASKER_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + ) + + content = webhook.sent[0]["content"] + assert content.startswith(f"<@{ASKER_ID}>\n") + assert "request `R7`" in content + + +async def test_a_dm_inlines_the_agent_name_because_there_is_no_webhook() -> None: + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + + ref = await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", _activity(), None) + + assert dm.sent[0]["content"].startswith("**my-agent**: ") + assert ref == f"{DM_CHANNEL_ID}:501" + + +async def test_progress_is_suppressed_rather_than_spilled_into_the_channel() -> None: + """The channel shows what the agent was asked and what it answers; a turn + whose thread cannot be made does not get to narrate itself there instead.""" + channel = _Channel() + adapter = _adapter({CHANNEL_ID: channel}) + adapter._webhooks[CHANNEL_ID] = _Webhook() + + with pytest.raises(RichContentFailed): + await adapter.post_rich( + str(CHANNEL_ID), "my-agent", _activity(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert channel.sent == [] + + +async def test_a_card_falls_back_to_the_channel_root_when_its_thread_will_not_open( + caplog: pytest.LogCaptureFixture, +) -> None: + """A question in the wrong place is answerable; one nobody can see is not.""" + channel = _Channel() + adapter = _adapter({CHANNEL_ID: channel}) + webhook = _Webhook() + adapter._webhooks[CHANNEL_ID] = webhook + + with caplog.at_level(logging.WARNING): + ref = await adapter.post_rich( + str(CHANNEL_ID), + "my-agent", + await _card(), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + ) + + assert "thread" in caplog.text + assert "thread" not in webhook.sent[0] + assert ref.endswith(":901") + + +# ── Failure semantics ──────────────────────────────────────────────────────── + + +async def test_a_refusal_is_reported_as_one_so_the_reservation_is_dropped() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + webhook.send_error = _http_error(400) + + with pytest.raises(RichContentFailed) as raised: + await adapter.post_rich(str(CHANNEL_ID), "my-agent", _activity(), None) + + # What the channel would have shown, so a caller can say what it now cannot. + assert raised.value.text == "**Working…**" + + +async def test_an_unknown_outcome_keeps_its_reservation_by_raising_itself() -> None: + """A 5xx may still have landed, and RichContentFailed would license a + second copy of a card somebody is meant to answer exactly once.""" + adapter, _channel, _thread, webhook = _guild_setup() + webhook.send_error = _http_error(503) + + with pytest.raises(discord.DiscordServerError): + await adapter.post_rich(str(CHANNEL_ID), "my-agent", await _card(), None) + + +async def test_a_timeout_is_not_a_refusal_either() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + webhook.send_error = TimeoutError() + + with pytest.raises(TimeoutError): + await adapter.post_rich(str(CHANNEL_ID), "my-agent", await _card(), None) + + +async def test_being_rate_limited_says_how_long_to_wait() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + webhook.send_error = discord.RateLimited(2.5) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.post_rich(str(CHANNEL_ID), "my-agent", _activity(), None) + + assert raised.value.retry_after == 2.5 + + +async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + webhook.edit_error = _http_error(403) + + with pytest.raises(RichContentFailed): + await adapter.update_rich( + str(CHANNEL_ID), f"{ROOT_MESSAGE_ID}:901", await _card() + ) + + +# ── Redrawing and retirement ───────────────────────────────────────────────── + + +async def test_a_running_turn_is_redrawn_in_place_inside_its_thread() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + + await adapter.update_rich(str(CHANNEL_ID), f"{ROOT_MESSAGE_ID}:901", _activity()) + + assert webhook.deletes == [] + assert webhook.edits[0]["message_id"] == 901 + assert webhook.edits[0]["thread"].id == ROOT_MESSAGE_ID + + +async def test_a_thread_keeps_the_finished_turn_as_its_record() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + + await adapter.update_rich(str(CHANNEL_ID), f"{ROOT_MESSAGE_ID}:901", _ended()) + + assert webhook.deletes == [] + assert webhook.edits[0]["message_id"] == 901 + + +async def test_a_flat_channel_loses_the_status_when_the_turn_ends() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + + await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", _ended()) + + assert webhook.edits == [] + assert webhook.deletes[0]["message_id"] == 901 + + +async def test_a_dm_loses_it_too_and_never_asks_for_a_webhook() -> None: + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + dm.messages[501] = _Message(dm, 501) + + await adapter.update_rich(str(DM_CHANNEL_ID), f"{DM_CHANNEL_ID}:501", _ended()) + + assert dm.deleted_ids == [501] + + +async def test_a_dm_redraw_writes_the_agent_name_back_into_the_body() -> None: + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + + ref = await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", _activity(), None) + await adapter.update_rich(str(DM_CHANNEL_ID), ref, _activity()) + + assert dm.messages[501].edited is not None + assert dm.messages[501].edited.startswith("**my-agent**: ") + + +async def test_a_settled_card_is_never_taken_down() -> None: + """It is the record of a decision, and it says what became of it.""" + adapter, _channel, _thread, webhook = _guild_setup() + card = await _card() + + await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", card) + + assert webhook.deletes == [] + assert webhook.edits[0]["message_id"] == 901 + + +async def test_a_status_that_cannot_be_removed_is_left_saying_what_happened( + caplog: pytest.LogCaptureFixture, +) -> None: + adapter, _channel, _thread, webhook = _guild_setup() + webhook.delete_error = _http_error(403) + + with caplog.at_level(logging.WARNING): + await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", _ended()) + + assert "leaving its final state" in caplog.text + assert webhook.edits[0]["message_id"] == 901 + + +async def test_redrawing_a_retired_status_is_not_reported_as_a_lost_message() -> None: + adapter, _channel, _thread, webhook = _guild_setup() + + await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", _ended()) + await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", _ended()) + + assert len(webhook.deletes) == 1 + assert webhook.edits == [] + + +# ── Recovery ───────────────────────────────────────────────────────────────── + + +def _posted_card(channel: Any, message_id: int, content: str, webhook_id: int | None): + message = _Message(channel, message_id, content) + message.webhook_id = webhook_id + return message + + +async def test_an_uncertainly_delivered_card_is_found_by_its_own_handle() -> None: + adapter, channel, thread, webhook = _guild_setup() + thread.history_messages = [ + _posted_card(thread, 901, "**Permission needed** Β· request `R7`", WEBHOOK_ID) + ] + + found = await adapter.find_request_card( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + "tok-1", + datetime.now(UTC) - timedelta(seconds=5), + "R7", + ) + + assert found == f"{ROOT_MESSAGE_ID}:901" + + +async def test_somebody_quoting_the_handle_is_not_mistaken_for_the_card() -> None: + adapter, channel, thread, webhook = _guild_setup() + thread.history_messages = [ + _posted_card(thread, 902, "is that the request `R7` one?", None) + ] + + found = await adapter.find_request_card( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + "tok-1", + datetime.now(UTC), + "R7", + ) + + assert found is None + + +async def test_a_card_that_fell_back_to_the_channel_root_is_still_found() -> None: + adapter, channel, thread, webhook = _guild_setup() + channel.history_messages = [ + _posted_card(channel, 903, "**Permission needed** Β· request `R7`", WEBHOOK_ID) + ] + + found = await adapter.find_request_card( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + "tok-1", + datetime.now(UTC), + "R7", + ) + + assert found == f"{CHANNEL_ID}:903" + + +async def test_a_naive_timestamp_is_read_as_utc_rather_than_as_local_time() -> None: + adapter, _channel, thread, _webhook = _guild_setup() + thread.history_messages = [ + _posted_card(thread, 901, "**Permission needed** Β· request `R7`", WEBHOOK_ID) + ] + + found = await adapter.find_request_card( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + "tok-1", + datetime.now(UTC).replace(tzinfo=None), + "R7", + ) + + assert found == f"{ROOT_MESSAGE_ID}:901" + + +async def test_a_turns_activity_cannot_be_recovered_and_says_so( + caplog: pytest.LogCaptureFixture, +) -> None: + """A webhook message carries no metadata, so there is nothing but the + handle to match on β€” and a status prints none.""" + adapter, _channel, _thread, _webhook = _guild_setup() + + with caplog.at_level(logging.WARNING): + found = await adapter.find_request_card( + str(CHANNEL_ID), None, "tok-1", datetime.now(UTC), None + ) + + assert found is None + assert "stays unconfirmed rather than being posted twice" in caplog.text + + +async def test_an_unreadable_history_is_not_a_missing_card( + caplog: pytest.LogCaptureFixture, +) -> None: + adapter, channel, _thread, _webhook = _guild_setup() + channel.history_error = _http_error(500) + + with caplog.at_level(logging.WARNING): + found = await adapter.find_request_card( + str(CHANNEL_ID), None, "tok-1", datetime.now(UTC), "R7" + ) + + assert found is None + assert "looking for card R7" in caplog.text + + +# ── Reactions, typing and thread reads ─────────────────────────────────────── + + +async def test_one_mark_is_shared_between_agents_and_not_added_twice() -> None: + """Every agent posts through one bot application here, so there is one + reaction between them; the publisher counts who is still holding it.""" + adapter, channel, _thread, _webhook = _guild_setup() + + ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", working=True + ) + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="other-agent", working=True + ) + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", working=False + ) + + assert channel.reactions == [("πŸ‘€", True), ("πŸ‘€", False)] + + +async def test_force_marks_again_because_the_record_may_be_empty_and_wrong() -> None: + adapter, channel, _thread, _webhook = _guild_setup() + + ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", working=True + ) + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", working=True, force=True + ) + + assert channel.reactions == [("πŸ‘€", True), ("πŸ‘€", True)] + + +async def test_a_missing_permission_is_not_retried_for_the_life_of_the_turn( + caplog: pytest.LogCaptureFixture, +) -> None: + adapter, channel, _thread, _webhook = _guild_setup() + channel.reaction_error = discord.Forbidden(_Response(), "no Add Reactions") # type: ignore[arg-type] + + with caplog.at_level(logging.WARNING): + await adapter.mark_activity( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + agent_name="my-agent", + working=True, + ) + + assert "Add Reactions" in caplog.text + + +async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> None: + adapter, channel, _thread, _webhook = _guild_setup() + channel.reaction_error = _http_error(500) + + with pytest.raises(discord.DiscordServerError): + await adapter.mark_activity( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + agent_name="my-agent", + working=True, + ) + + +async def test_the_typing_nudge_never_creates_a_thread_to_go_in() -> None: + channel = _Channel() + adapter = _adapter({CHANNEL_ID: channel}) + + await adapter.notify_working( + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert channel.typing_count == 1 + + +async def test_the_typing_nudge_uses_a_thread_that_already_exists() -> None: + adapter, channel, thread, _webhook = _guild_setup() + + await adapter.notify_working( + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert thread.typing_count == 1 + assert channel.typing_count == 0 + + +async def test_the_first_message_in_a_thread_is_the_first_reply() -> None: + adapter, _channel, thread, _webhook = _guild_setup() + thread.history_messages = [_Message(thread, 901, "yes")] + + root = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + assert await adapter.is_first_reply(str(CHANNEL_ID), root, f"{ROOT_MESSAGE_ID}:901") + assert not await adapter.is_first_reply( + str(CHANNEL_ID), root, f"{ROOT_MESSAGE_ID}:902" + ) + + +async def test_a_thread_that_cannot_be_read_refuses_rather_than_raises( + caplog: pytest.LogCaptureFixture, +) -> None: + adapter, _channel, thread, _webhook = _guild_setup() + thread.history_error = _http_error(500) + + with caplog.at_level(logging.WARNING): + answered = await adapter.is_first_reply( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + f"{ROOT_MESSAGE_ID}:901", + ) + + assert answered is False + assert "not the first reply" in caplog.text diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index 58e22c102..03b4a9781 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -437,7 +437,7 @@ async def test_an_uncertain_post_is_found_again_by_its_marker() -> None: } found = await adapter.find_request_card( - "chan-1", "root-1", "tok-1", datetime.now(UTC) + "chan-1", "root-1", "tok-1", datetime.now(UTC), "R7" ) assert found == "post-9" @@ -454,7 +454,9 @@ async def test_a_token_quoted_by_a_person_is_not_the_post_that_carries_it() -> N } assert ( - await adapter.find_request_card("chan-1", "root-1", "tok-1", datetime.now(UTC)) + await adapter.find_request_card( + "chan-1", "root-1", "tok-1", datetime.now(UTC), "R7" + ) is None ) @@ -463,7 +465,7 @@ async def test_a_rootless_search_asks_the_channel_from_just_before_the_post() -> adapter = _adapter() created_at = datetime(2026, 9, 14, 12, 0, tzinfo=UTC) - await adapter.find_request_card("chan-1", None, "tok-1", created_at) + await adapter.find_request_card("chan-1", None, "tok-1", created_at, "R7") channel_id, params = _posts(adapter).channel_calls[0] assert channel_id == "chan-1" @@ -478,7 +480,9 @@ async def test_a_search_that_could_not_run_is_not_found_rather_than_a_guess() -> _posts(adapter).read_error = RuntimeError("500 server error") assert ( - await adapter.find_request_card("chan-1", "root-1", "tok-1", datetime.now(UTC)) + await adapter.find_request_card( + "chan-1", "root-1", "tok-1", datetime.now(UTC), "R7" + ) is None ) diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_block_fallback.py b/core/tests/switch_core/bridges/collaboration/test_slack_block_fallback.py index 855f62762..acf98f01b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_block_fallback.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_block_fallback.py @@ -71,7 +71,7 @@ async def test_rejected_blocks_publish_one_threaded_recoverable_text_card( } assert ( await adapter.find_request_card( - "channel-demo", "channel-demo:1.0", "delivery-demo", datetime.now(UTC) + "channel-demo", "channel-demo:1.0", "delivery-demo", datetime.now(UTC), "R7" ) == "channel-demo:2.0" ) diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 9404232c8..8e872e429 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -54,7 +54,7 @@ async def update_rich(self, channel, ref, content): self.edit_refs.append(ref) self.messages[ref] = self._render_rich(content) - async def find_request_card(self, channel, thread, token, created_at): + async def find_request_card(self, channel, thread, token, created_at, handle): for ref, message in self.messages.items(): if any( block.get("block_id") == f"switch-request:{token}" diff --git a/core/tests/switch_core/sessions/test_publication_retries.py b/core/tests/switch_core/sessions/test_publication_retries.py index 28af84103..fe66bf94e 100644 --- a/core/tests/switch_core/sessions/test_publication_retries.py +++ b/core/tests/switch_core/sessions/test_publication_retries.py @@ -40,7 +40,7 @@ class RecoverablePlatform(Platform): - async def find_request_card(self, channel, thread, token, created_at): + async def find_request_card(self, channel, thread, token, created_at, handle): for posted_channel, text, blocks, posted_thread in self.posts: if ( posted_channel == channel @@ -293,7 +293,7 @@ async def test_slack_recovery_pages_and_requires_own_bot(thread): adapter._web_client = client assert ( await adapter.find_request_card( - "channel", thread, "token", datetime(2026, 1, 1, tzinfo=UTC) + "channel", thread, "token", datetime(2026, 1, 1, tzinfo=UTC), "R7" ) == "channel:102.0" ) From 92118d4189d0576ab4d1e82ffd760e7445ff6005 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Mon, 14 Sep 2026 22:33:09 +0100 Subject: [PATCH 006/120] Fix the Task 4 review findings on the Discord publication seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six defects found in review of 433a46c2, all in how a session publication is placed, found again, and failed. Destination. A publication whose thread could not be resolved fell back to the parent channel whatever the reason. A private thread the bot has lost access to and a thread that does not exist yet are indistinguishable from the error alone, and only one of them makes the parent an acceptable place for a request card: the other hands a private conversation's question and options to an audience that was never in it. The fallback now applies to exactly one case β€” no thread exists and none could be made, so the channel root the turn was addressed at is still its origin. Everything else refuses before anything is sent, which leaves the caller's reservation intact. Recovery. Publications now go through a second webhook of their own, "Switch Sessions", which nothing else ever posts to. Sharing the agents' webhook made authorship useless as evidence β€” an agent's reply saying "the request `R7`" satisfied the same test as the card, and binding to it meant every later settlement edit overwrote a sentence while the real card went on saying the request was open. Among publications, a card is picked out by its heading line: bold from the first character, ending in its own handle, matched line by line rather than at the top, because the top of a card is the mention that notifies whoever asked. In a DM, where there are no webhooks, the author is the bot and the heading test carries the weight. That is weaker, and is written down rather than left to be discovered. Throttling. A 429 reaches this seam two ways. The library raises RateLimited when it declines to sleep through one; the webhook transport raises a plain HTTPException once it has exhausted its own retries, or when the response lacks the header it classifies by. Only the first was read as a throttle, so the second discarded the reservation and threw away the delay Discord had just supplied. Both are throttles now, taking the delay from the Retry-After header and falling back to five seconds β€” a floor, because retrying a throttle immediately is how a throttle becomes a ban. Also: a DM redraw reads the agent's name back off the message it is about to edit when the in-memory note of who posted it is gone, since in a DM the body is the only durable record of the sender; and a Forbidden on reaction *removal* is logged as an error rather than as the same shrug as a reaction that could not be added, because a mark left on a finished turn is a false statement rather than an absence and no retry takes it back. Two findings are deliberately not fixed. A status still cannot be recovered after an uncertain send, and the library's webhook sender can still duplicate a post by retrying a 5xx below us. They are one trade-off, not two β€” both are the price of per-agent webhook identity on the status β€” and choosing between that identity and a bot post with an idempotency nonce is a product decision. Recorded as D17 for Simon. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 392 ++++++++++++++---- .../collaboration/test_discord_adapter.py | 25 +- .../collaboration/test_discord_sdk_only.py | 337 +++++++++++++-- 3 files changed, 639 insertions(+), 115 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 88e7183c6..58fa9bfcd 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -60,6 +60,11 @@ # via per-message username/avatar overrides. _WEBHOOK_NAME = "Switch Bridge" +# A second webhook in the same channels, carrying session publications and +# nothing else, so that one of ours can be told apart from an agent's own words +# when the only record left to read is the channel history. +_PUBLICATION_WEBHOOK_NAME = "Switch Sessions" + _READY_TIMEOUT = 30.0 # Put on the message an agent is working on for as long as its turn lasts. @@ -76,6 +81,11 @@ # mentioned still resolves. _NO_MASS_MENTIONS = discord.AllowedMentions(everyone=False) +# The `**agent**: ` a DM post carries in place of the sender identity a webhook +# would have given it. Bounded and single-line so that a body opening with bold +# text of its own cannot be read as one. +_BODY_LABEL = re.compile(r"^\*\*[^*\n]{1,80}\*\*: ") + # Inserted after the `<` of anything that looks like a Discord entity. Discord # has no escape for `<`, so the syntax is broken rather than escaped β€” the same # technique discord.py uses on `@`. @@ -88,6 +98,27 @@ _RECOVERY_SKEW = timedelta(seconds=30) _RECOVERY_LIMIT = 100 +# Waited when Discord says it is rate limiting but does not say for how long. +# Its buckets are short, so this is a floor that keeps the caller's backoff in +# the right order of magnitude rather than a figure Discord commits to. +_THROTTLE_FALLBACK = 5.0 + + +def _throttle_delay(error: discord.HTTPException) -> float: + """How long a 429 asks us to wait, from wherever Discord put the number. + + `RateLimited` carries it as a float. An `HTTPException` does not: the + library parses the 429 body down to its `message` and drops the rest, so + the header is what is left. Falls back to a constant rather than to zero β€” + retrying a throttle immediately is how a throttle becomes a ban. + """ + response = getattr(error, "response", None) + raw = getattr(response, "headers", {}).get("Retry-After") if response else None + try: + return float(raw) if raw is not None else _THROTTLE_FALLBACK + except (TypeError, ValueError): + return _THROTTLE_FALLBACK + def _as_rich_failure( error: Exception, *, description: str, text: str @@ -104,9 +135,18 @@ def _as_rich_failure( attempts may have been the one that landed before the response was lost. Neither is a timeout or a dropped connection, which arrive as `aiohttp` and `asyncio` errors rather than as anything Discord said. + + A 429 is an answer, but not that one. It arrives two ways β€” as + `RateLimited` when the library declines to sleep through it, and as a plain + `HTTPException` when the webhook transport has exhausted its own retries or + when the response is missing the header the library needs to classify it β€” + and both mean wait, not stop. Reading only the first shape turned a + throttle into a refusal and threw away the delay Discord had just supplied. """ if isinstance(error, discord.RateLimited): return RichContentThrottled(retry_after=error.retry_after, text=text) + if isinstance(error, discord.HTTPException) and error.status == 429: + return RichContentThrottled(retry_after=_throttle_delay(error), text=text) if isinstance(error, discord.DiscordServerError): return None if isinstance(error, discord.HTTPException | ValueError): @@ -277,8 +317,8 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: self._tree: app_commands.CommandTree[Any] | None = None self._connect_task: asyncio.Task[None] | None = None self._bot_user_id: int = 0 - # channel id -> webhook the bridge posts through in that channel. - self._webhooks: dict[int, discord.Webhook] = {} + # (channel id, webhook name) -> webhook the bridge posts through there. + self._webhooks: dict[tuple[int, str], discord.Webhook] = {} # Ids of webhooks the bridge has minted/adopted, for echo dropping. self._webhook_ids: set[int] = set() self._seen_ids: OrderedDict[int, None] = OrderedDict() @@ -860,24 +900,15 @@ def _draw( ) return f"{prefix}{lead}{body}{tail}" - async def _render_rich( - self, content: RichContent, *, agent_name: str | None, lobby: bool - ) -> str: + def _render_rich(self, content: RichContent, *, prefix: str) -> str: """Draw `content` for one place on Discord. - `lobby` is a real DM, which has no webhook and so no per-message - identity: the agent's name is inlined the way `send_message` inlines - it, and charged to the same 2,000 characters as everything else. + `prefix` is the inlined agent name a DM needs and a guild channel does + not: a webhook message carries its sender's name and face, and a bot + post in a DM carries the bot's, so there the name goes in the body the + way `send_message` puts it there, charged to the same 2,000 characters + as everything else. """ - prefix = "" - if lobby: - if agent_name is None: - logger.warning( - "Redrawing a Discord DM publication without knowing which " - "agent posted it, so it loses its name prefix." - ) - else: - prefix = f"**{await self.agent_label_for_body(agent_name)}**: " responder = ( self._mention(content.responder_external_id) if isinstance(content, RequestCard) @@ -926,12 +957,20 @@ async def post_rich( transport's own error and keeps the reservation. A thread that cannot be resolved is where the two kinds of content part - company. Progress is suppressed rather than spilled into the main - channel: the reply the agent is working on will arrive there anyway, - and a channel narrating every turn is the noise this presentation - exists to avoid. A card is posted at the channel root instead, because - a question in the wrong place still gets answered and one nobody can - see does not. + company, but only in the one case where nothing is given away by it. + Where the turn began at the channel root and the reply thread has not + been made yet, the channel root is the origin: everyone who could read + the question there can read it there still, so a card is posted there + rather than not at all, while progress is suppressed because the + channel narrating every turn is the noise this presentation exists to + avoid. + + Where a thread already exists and Discord will not let us into it, both + are refused. That thread may be private, and a request carries the + agent's question and its options β€” posting it to the parent would hand + the contents of a conversation to people who were not in it. A question + nobody can see is bad; a question the wrong people can see is worse, + and unlike the first it cannot be undone. """ fallback = self.rich_fallback_text(content) try: @@ -944,7 +983,8 @@ async def post_rich( ) from error lobby = self._channel_type_of(target) == "lobby" - text = await self._render_rich(content, agent_name=agent_name, lobby=lobby) + prefix = f"**{await self.agent_label_for_body(agent_name)}**: " if lobby else "" + text = self._render_rich(content, prefix=prefix) if lobby: try: sent = await target.send( @@ -960,27 +1000,12 @@ async def post_rich( thread: Any = None if thread_root_id: - try: - thread = await self._ensure_thread(int(channel_id), thread_root_id) - except Exception as error: - if isinstance(content, TurnActivity): - raise RichContentFailed( - f"Discord has no thread under {thread_root_id} in channel " - f"{channel_id} to show this turn's progress in, and the " - f"channel root is not a substitute for one: {error}", - text=text, - ) from error - logger.warning( - "Could not resolve the Discord thread under %s in channel %s " - "(%s); posting the request at the channel root instead, where " - "it can at least be answered.", - thread_root_id, - channel_id, - error, - ) + thread = await self._publication_thread( + int(channel_id), thread_root_id, content, text + ) try: - webhook = await self._get_webhook(int(channel_id)) + webhook = await self._publication_webhook(int(channel_id)) agent = await self.agent_rendering(agent_name) payload: dict[str, Any] = { "content": text, @@ -1000,6 +1025,89 @@ async def post_rich( ) from error return self._remember_rich(f"{sent.channel.id}:{sent.id}", agent_name) + async def _publication_thread( + self, channel_id: int, thread_root_id: str, content: RichContent, text: str + ) -> Any: + """The thread this publication goes in, or `None` to use its origin. + + `None` is returned in exactly one situation: no thread exists under the + root message yet and one could not be made. The turn was addressed at + the channel root, so the root is where it came from and where its + readers already are β€” a card posted there reaches the same people who + asked, which is why this is a fallback and not a disclosure. + + Every other failure raises. A thread that exists and will not open may + be private, and the difference between "in a thread" and "in the + channel" is then the difference between a conversation and an audience. + Progress raises too even in the first case: nobody asked the channel to + be told what a turn is doing, and the agent's reply is coming to it + anyway. + """ + existing = await self._reachable_thread(channel_id, thread_root_id, text) + if existing is not None: + return existing + try: + return await self._ensure_thread(channel_id, thread_root_id) + except Exception as error: + # The create may have been refused because the thread is already + # there β€” the one failure that means the opposite of what it looks + # like. Ask again before treating the root as this turn's origin. + settled = await self._reachable_thread(channel_id, thread_root_id, text) + if settled is not None: + return settled + if isinstance(content, TurnActivity): + raise RichContentFailed( + f"Discord has no thread under {thread_root_id} in channel " + f"{channel_id} to show this turn's progress in, and the " + f"channel root is not a substitute for one: {error}", + text=text, + ) from error + logger.warning( + "Could not open a Discord thread under %s in channel %s (%s); " + "posting the request where it was asked, at the channel root.", + thread_root_id, + channel_id, + error, + ) + return None + + async def _reachable_thread( + self, channel_id: int, thread_root_id: str, text: str + ) -> Any: + """The thread already hanging from this message, if there is one. + + `None` means Discord said there is none: a message with no thread under + it answers a channel fetch with "unknown channel", because a Discord + thread is a channel whose id is the message's own. Anything else it + says is not that answer, and is refused rather than read as absence β€” + "there is no thread here" and "this thread is not yours" must not be + confused, because the first invites posting in the channel instead and + the second is how a private conversation becomes a public one. + + Refusing is safe for the caller's reservation in a way a failed send is + not: nothing has been posted at this point, so there is no message + anywhere that a retry could duplicate. + """ + thread_id = self._thread_channel_id(thread_root_id) + if thread_id is None: + return None + client = self._require_client() + cached = client.get_channel(thread_id) + if cached is not None: + return cached + try: + return await client.fetch_channel(thread_id) + except discord.NotFound: + return None + except Exception as error: + raise RichContentFailed( + f"Discord will not say what is under {thread_root_id} in channel " + f"{channel_id}, so this publication has nowhere it is known to " + f"belong. The channel is not a substitute: a thread this bridge " + f"cannot open may be one the channel cannot read either. {error}", + text=text, + ) from error + async def update_rich( self, channel_id: str, message_ref: str, content: RichContent ) -> None: @@ -1039,19 +1147,56 @@ async def update_rich( ) from error lobby = self._channel_type_of(target) == "lobby" + prefix = ( + await self._lobby_prefix(target, message_ref, message_id) if lobby else "" + ) # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the channel that never reaches anybody # it has not already reached. - text = await self._render_rich( - replace(content, notify_external_id=None), - agent_name=self._rich_agents.get(message_ref), - lobby=lobby, + text = self._render_rich( + replace(content, notify_external_id=None), prefix=prefix ) if self._is_flat(channel_id, message_ref) and _turn_has_ended(content): await self._retire_rich(channel_id, message_ref, text, lobby=lobby) return await self._edit_rich(channel_id, message_ref, text, lobby=lobby) + async def _lobby_prefix( + self, target: Any, message_ref: str, message_id: str + ) -> str: + """The `**agent**: ` a DM redraw has to write again. + + A webhook message keeps its sender through an edit because Discord + keeps it; a DM has no webhook, so the name lives in the body and an + edit that forgets it publishes the turn as the bot. This process + remembers which agent posted what, but only until it restarts, so the + message that already carries the name is asked for it: the prefix on + the post is the durable record of who made it, and reading it back + costs one fetch on a path that is about to edit that message anyway. + """ + remembered = self._rich_agents.get(message_ref) + if remembered is not None: + return f"**{await self.agent_label_for_body(remembered)}**: " + try: + existing = await target.fetch_message(int(message_id)) + match = _BODY_LABEL.match(str(existing.content or "")) + except Exception as e: + logger.warning( + "Could not read the Discord DM publication %s to recover which " + "agent posted it: %s.", + message_ref, + e, + ) + return "" + if match is None: + logger.warning( + "The Discord DM publication %s carries no agent name, so its " + "redraw cannot restore one.", + message_ref, + ) + return "" + return match.group(0) + def _is_flat(self, channel_id: str, message_ref: str) -> bool: """Whether a publication is sitting in the channel rather than a thread. @@ -1071,7 +1216,7 @@ async def _retire_rich( target = await self._get_channel(int(location_id or channel_id)) await target.get_partial_message(int(message_id)).delete() else: - webhook = await self._get_webhook(int(channel_id)) + webhook = await self._publication_webhook(int(channel_id)) await webhook.delete_message(int(message_id)) except discord.NotFound: pass @@ -1113,7 +1258,7 @@ async def _edit_rich( kwargs: dict[str, Any] = {} if location_id and location_id != channel_id: kwargs["thread"] = discord.Object(id=int(location_id)) - webhook = await self._get_webhook(int(channel_id)) + webhook = await self._publication_webhook(int(channel_id)) await webhook.edit_message( int(message_id), content=text, @@ -1152,17 +1297,25 @@ async def find_request_card( question twice, so a lookup that cannot be trusted comes back as `None`: the reservation survives and the question is asked again later. - Matched on the handle, because on Discord there is nothing else to - match on. A webhook message carries no metadata and no props, so the - marker that serves Slack and Mattermost has nowhere to live β€” but the - card prints its own handle, in a phrase (``request `R7` ``) that no - answer typed back at it reproduces. Narrowed further to messages this - bridge posted, so that a person quoting the card is not mistaken for - it. + Two things have to hold, and neither is enough alone. The message must + have been posted by the publication webhook, which nothing but a status + or a card is ever sent through, so an agent's own words can never be + mistaken for one however closely they read like it β€” a reply beginning + "I can explain the request `R7` syntax" arrives on the webhook agents + speak through, and is not a candidate here at all. And it must carry a + card's heading line for this handle, which is what picks this card out + from the other publications beside it. + + The heading is looked for line by line rather than at the top, because + the top of a card is the mention that notifies whoever asked, and in a + DM it is the agent's name as well. + + A DM has no webhooks, so there the bot is the author and the heading + test is carrying the weight on its own. See `_is_publication`. `handle` is `None` for a turn's activity, which prints no handle and so cannot be found this way. That publication stays unconfirmed rather - than being posted twice β€” see the warning below. + than being posted twice β€” see the warning below, and D14. """ if handle is None: logger.warning( @@ -1185,16 +1338,18 @@ async def find_request_card( stamped = created_at if created_at.tzinfo else created_at.replace(tzinfo=UTC) after = stamped - _RECOVERY_SKEW - wanted = f"request `{handle}`" + wanted = f"Β· request `{self._rich_escape(handle)}`" for place in await self._recovery_places(channel_id, thread_root_id): - authors = await self._bridge_author_ids(place) + author = await self._publication_author(place) + if author is None: + continue try: async for message in place.history( after=after, limit=_RECOVERY_LIMIT, oldest_first=True ): - if message.webhook_id not in authors: + if not self._is_publication(message, author): continue - if wanted in (message.content or ""): + if self._heads_a_card(message.content or "", wanted): return f"{message.channel.id}:{message.id}" except Exception as e: logger.warning( @@ -1205,6 +1360,21 @@ async def find_request_card( ) return None + @staticmethod + def _heads_a_card(content: str, wanted: str) -> bool: + """Whether this text carries a card's heading line for one handle. + + A heading is a whole line, bold from its first character and ending in + the handle it answers to. Requiring both ends of the line means a + sentence that happens to quote the handle is not enough, and requiring + a line rather than the start of the message means the mention above it + does not hide it. + """ + return any( + line.startswith("**") and line.endswith(wanted) + for line in content.split("\n") + ) + async def _recovery_places( self, channel_id: str, thread_root_id: str | None ) -> list[Any]: @@ -1234,26 +1404,49 @@ async def _recovery_places( logger.warning("Could not open Discord channel %s: %s.", channel_id, e) return places - async def _bridge_author_ids(self, place: Any) -> set[int]: - """The webhook ids a publication in `place` could have been posted by. + async def _publication_author(self, place: Any) -> int | None: + """Who a publication in `place` would have been posted by. + + The publication webhook's id in a guild, the bot's own id in a DM, + where there are no webhooks to have. `None` means the question cannot + be answered here, and a search that cannot say who wrote a message has + no business adopting one β€” better an unbound reservation than a + reservation bound to somebody else's sentence. - Resolved rather than read off the cache: after a restart nothing has + Resolved rather than read off the cache. After a restart nothing has posted to this channel yet, so the cache is empty and every message in - it would look like somebody else's. + it would look like a stranger's. """ parent = getattr(place, "parent", None) or place if self._channel_type_of(parent) == "lobby": - return set() + return self._bot_user_id or None try: - self._webhook_ids.add((await self._get_webhook(parent.id)).id) + return (await self._publication_webhook(parent.id)).id except Exception as e: logger.warning( - "Could not resolve the Discord webhook for channel %s, so a " - "publication there cannot be told from anybody else's message: %s.", + "Could not resolve the Discord publication webhook for channel " + "%s, so nothing there can be told from anybody else's message: " + "%s.", parent.id, e, ) - return set(self._webhook_ids) + return None + + @staticmethod + def _is_publication(message: Any, author: int) -> bool: + """Whether this message came from the sender publications come from. + + A guild message says so exactly: `webhook_id` is set by Discord, not by + anything that wrote the content, and only publications go through that + webhook. A DM message can only say that the bot sent it β€” the bot also + relays the agent's ordinary replies there, so in a DM this narrows the + field rather than settling it, and the caller's heading-line test is + what settles it. Recorded in D18 as the weaker of the two. + """ + webhook_id = getattr(message, "webhook_id", None) + if webhook_id is not None: + return bool(webhook_id == author) + return bool(getattr(getattr(message, "author", None), "id", None) == author) async def is_first_reply( self, channel_id: str, root_ref: str, message_ref: str @@ -1523,6 +1716,13 @@ async def _react(self, message_ref: str, *, working: bool) -> None: Two endings are final rather than worth retrying: the message is gone, or this guild will never allow the reaction. Everything else is left to raise, so a caller that can try again knows it should. + + The two finals are not the same failure, and the log says which. A mark + that could not be added is a mark nobody sees, and the turn goes on + without it. A mark that could not be *removed* is still on the message, + saying an agent is working on something it finished β€” worse than the + first, because it is not an absence but a false statement, and no + retry here will take it back. """ location_id, message_id = self._parse_message_ref(message_ref) client = self._require_client() @@ -1540,11 +1740,25 @@ async def _react(self, message_ref: str, *, working: bool) -> None: # wanted either way. self._eyes.discard(message_ref) except discord.Forbidden: - logger.warning( - "Discord refused the working reaction on %s β€” the bot is missing " - "the Add Reactions permission here. Turns show the posted status " - "message; only the mark on the message being answered is missing. " - "Re-invite the bot with the permissions in DISCORD_SETUP.md.", + if working: + logger.warning( + "Discord refused the working reaction on %s β€” the bot is " + "missing the Add Reactions permission here. Turns still show " + "their status message; only the mark on the message being " + "answered is missing. Re-invite the bot with the permissions " + "in DISCORD_SETUP.md.", + message_ref, + ) + return + logger.error( + "Discord refused to take the working reaction off %s, so %s is " + "left on a message whose turn has ended and the channel shows an " + "agent still working on something it has finished. Removing our " + "own reaction needs no permission of its own, so this is the " + "bot's access to the channel rather than the reaction: check it " + "can still see %s. The mark will not come off by retrying.", + message_ref, + _WORKING_REACTION, message_ref, ) @@ -2282,20 +2496,40 @@ async def _get_member(guild: Any, user_external_id: str) -> Any: return member async def _get_webhook(self, channel_id: int) -> discord.Webhook: - cached = self._webhooks.get(channel_id) + return await self._named_webhook(channel_id, _WEBHOOK_NAME) + + async def _publication_webhook(self, channel_id: int) -> discord.Webhook: + """The webhook nothing but a session publication is ever sent through. + + A second webhook in the same channel, for one reason: it is the only + thing about a Discord message that says who wrote it and cannot be + written by anyone else. Recovery has to find a status or a card again + after a send whose outcome was lost, and it has nothing but the channel + history to look in. Sharing the agents' webhook made the sender useless + as evidence β€” every relayed reply came from it too, so an agent that + merely talked about a request looked exactly like the card, and + adopting one would have redrawn a sentence as a settled question. + + Nobody but this method posts here, so anything found on it is a + publication. Guilds only: a DM has no webhooks at all. + """ + return await self._named_webhook(channel_id, _PUBLICATION_WEBHOOK_NAME) + + async def _named_webhook(self, channel_id: int, name: str) -> discord.Webhook: + cached = self._webhooks.get((channel_id, name)) if cached is not None: return cached channel = await self._get_channel(channel_id) webhook: discord.Webhook | None = None for existing in await channel.webhooks(): - if existing.name == _WEBHOOK_NAME and existing.token: + if existing.name == name and existing.token: webhook = existing break if webhook is None: - webhook = await channel.create_webhook(name=_WEBHOOK_NAME) + webhook = await channel.create_webhook(name=name) - self._webhooks[channel_id] = webhook + self._webhooks[(channel_id, name)] = webhook self._webhook_ids.add(webhook.id) return webhook diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py index c1d012187..6a6c6d7b5 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py @@ -10,6 +10,7 @@ from switch_core.bridges.agent.commands import COMMANDS from switch_core.bridges.collaboration.discord import adapter as adapter_module from switch_core.bridges.collaboration.discord.adapter import ( + _WEBHOOK_NAME, DiscordAdapter, DiscordConnectionConfig, ) @@ -560,7 +561,7 @@ def test_send_message_posts_via_webhook_with_agent_identity() -> None: channel = _FakeChannel() adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook ref = _run(adapter.send_message(str(CHANNEL_ID), "my-agent", "**hello**")) @@ -580,7 +581,7 @@ def test_long_message_is_split_across_posts_not_dropped() -> None: channel = _FakeChannel() adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook body = "\n".join(f"line {i}" for i in range(1000)) ref = _run(adapter.send_message(str(CHANNEL_ID), "my-agent", body)) @@ -615,7 +616,7 @@ def test_failed_part_leaves_a_visible_truncation_notice() -> None: channel = _FakeChannel() adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook body = "\n".join(f"line {i}" for i in range(1000)) sends = {"n": 0} @@ -644,7 +645,7 @@ def test_send_message_with_thread_root_posts_into_thread() -> None: channel.messages[4000] = root adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook ref = _run( adapter.send_message( @@ -664,7 +665,7 @@ def test_send_message_reuses_existing_thread() -> None: thread = _FakeThread(parent=channel, thread_id=4000) adapter._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook _run( adapter.send_message( @@ -694,7 +695,7 @@ def test_send_attachment_posts_via_webhook_with_agent_identity() -> None: channel = _FakeChannel() adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook ref = _run( adapter.send_attachment( @@ -725,7 +726,7 @@ def test_send_attachment_without_caption_sends_empty_content() -> None: channel = _FakeChannel() adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook _run( adapter.send_attachment( @@ -743,7 +744,7 @@ def test_send_attachment_into_thread() -> None: thread = _FakeThread(parent=channel, thread_id=4000) adapter._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook ref = _run( adapter.send_attachment( @@ -787,7 +788,7 @@ async def send(self, **kwargs: Any) -> Any: channel = _FakeChannel() adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FileRejectingWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook ref = _run( adapter.send_attachment( @@ -825,7 +826,7 @@ def test_update_message_edits_via_webhook_with_thread() -> None: channel = _FakeChannel() adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook _run(adapter.update_message(str(CHANNEL_ID), "4000:901", "new text")) @@ -843,7 +844,7 @@ def test_update_message_falls_back_to_bot_message_edit() -> None: adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() webhook.edit_raises_not_found = True - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook _run(adapter.update_message(str(CHANNEL_ID), f"{CHANNEL_ID}:502", "edited")) @@ -926,7 +927,7 @@ def _runtime_setup() -> tuple[DiscordAdapter, _FakeChannel, _FakeWebhook]: channel = _FakeChannel() adapter._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = webhook + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook return adapter, channel, webhook diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index 95428b59b..ee6a1a6a6 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -27,6 +27,8 @@ TurnActivity, ) from switch_core.bridges.collaboration.discord.adapter import ( + _PUBLICATION_WEBHOOK_NAME, + _WEBHOOK_NAME, DiscordAdapter, DiscordConnectionConfig, ) @@ -46,6 +48,7 @@ DM_CHANNEL_ID = 555 BOT_USER_ID = 42 WEBHOOK_ID = 77 +PUBLICATION_WEBHOOK_ID = 78 ROOT_MESSAGE_ID = 321 ASKER_ID = "60606" @@ -54,13 +57,18 @@ class _Response: - status = 400 - reason = "Bad Request" + def __init__(self) -> None: + self.status = 400 + self.reason = "Bad Request" + self.headers: dict[str, str] = {} -def _http_error(status: int) -> discord.HTTPException: +def _http_error( + status: int, *, headers: dict[str, str] | None = None +) -> discord.HTTPException: response = _Response() - response.status = status # type: ignore[misc] + response.status = status + response.headers = headers if headers is not None else {} if status >= 500: return discord.DiscordServerError(response, "upstream") # type: ignore[arg-type] return discord.HTTPException(response, "refused") # type: ignore[arg-type] @@ -80,11 +88,17 @@ class _Overwrite: view_channel = True +class _Author: + def __init__(self, user_id: int) -> None: + self.id = user_id + + class _Message: def __init__(self, channel: Any, message_id: int, content: str = "") -> None: self.id = message_id self.channel = channel self.content = content + self.author = _Author(0) self.webhook_id: int | None = None self.edited: str | None = None self.deleted = False @@ -97,7 +111,7 @@ async def delete(self) -> None: self.channel.deleted_ids.append(self.id) async def create_thread(self, *, name: str) -> Any: - raise AssertionError("nothing in this seam may create a thread") + return self.channel.open_thread(self.id, name) class _PartialMessage: @@ -105,6 +119,9 @@ def __init__(self, channel: Any, message_id: int) -> None: self.id = message_id self._channel = channel + async def create_thread(self, *, name: str) -> Any: + return self._channel.open_thread(self.id, name) + async def delete(self) -> None: if self._channel.delete_error is not None: raise self._channel.delete_error @@ -138,10 +155,27 @@ def __init__(self, channel_id: int = CHANNEL_ID, *, guild: Any | None = None): self.delete_error: Exception | None = None self.existing_webhooks: list[Any] = [] self.webhook_error: Exception | None = None + self.thread_error: Exception | None = None + self.client: _Client | None = None def overwrites_for(self, role: Any) -> _Overwrite: return _Overwrite() + def open_thread(self, message_id: int, name: str) -> Any: + """Start a thread under one of this channel's messages. + + Discord gives the thread the message's own id, and the bridge relies on + that everywhere, so the fake has to do the same β€” and has to make the + new thread visible to the client, which is where the bridge looks for + it next. + """ + if self.thread_error is not None: + raise self.thread_error + thread = _Thread(self, message_id) + if self.client is not None: + self.client.add(thread) + return thread + async def send(self, content: str, **kwargs: Any) -> Any: if self.send_error is not None: raise self.send_error @@ -168,7 +202,7 @@ async def webhooks(self) -> list[Any]: return self.existing_webhooks async def create_webhook(self, *, name: str) -> Any: - webhook = _Webhook() + webhook = _Webhook(name) self.existing_webhooks.append(webhook) return webhook @@ -206,10 +240,16 @@ def __init__(self, parent: _Channel, thread_id: int = ROOT_MESSAGE_ID) -> None: self.parent_id = parent.id +_WEBHOOK_IDS = { + _WEBHOOK_NAME: WEBHOOK_ID, + _PUBLICATION_WEBHOOK_NAME: PUBLICATION_WEBHOOK_ID, +} + + class _Webhook: - def __init__(self) -> None: - self.id = WEBHOOK_ID - self.name = "Switch Bridge" + def __init__(self, name: str) -> None: + self.id = _WEBHOOK_IDS[name] + self.name = name self.token = "tok" self.sent: list[dict[str, Any]] = [] self.edits: list[dict[str, Any]] = [] @@ -241,11 +281,23 @@ class _Client: def __init__(self, channels: dict[int, Any]) -> None: self._channels = channels self.user = object() + # What Discord says instead of answering, keyed by channel id. A thread + # the bot cannot open answers here, not with "unknown channel". + self.fetch_errors: dict[int, Exception] = {} + for channel in channels.values(): + channel.client = self + + def add(self, channel: Any) -> None: + self._channels[channel.id] = channel + channel.client = self def get_channel(self, channel_id: int) -> Any | None: return self._channels.get(channel_id) async def fetch_channel(self, channel_id: int) -> Any: + error = self.fetch_errors.get(channel_id) + if error is not None: + raise error channel = self._channels.get(channel_id) if channel is None: raise discord.NotFound(_Response(), "unknown channel") # type: ignore[arg-type] @@ -262,13 +314,21 @@ def _adapter(channels: dict[int, Any]) -> DiscordAdapter: def _guild_setup() -> tuple[DiscordAdapter, _Channel, _Thread, _Webhook]: + """A guild channel carrying both of the bridge's webhooks, and a thread. + + The publication webhook is the one returned, because everything here that + inspects what was sent is inspecting a publication. The agents' webhook + exists in every one of these channels for the same reason it does in a real + one β€” and so that a test can post an agent's own words through it. + """ channel = _Channel() thread = _Thread(channel) adapter = _adapter({CHANNEL_ID: channel, ROOT_MESSAGE_ID: thread}) - webhook = _Webhook() - adapter._webhooks[CHANNEL_ID] = webhook - adapter._webhook_ids.add(webhook.id) - return adapter, channel, thread, webhook + channel.existing_webhooks = [ + _Webhook(_WEBHOOK_NAME), + _Webhook(_PUBLICATION_WEBHOOK_NAME), + ] + return adapter, channel, thread, channel.existing_webhooks[1] def _activity(**kwargs: Any) -> TurnActivity: @@ -364,12 +424,36 @@ async def test_a_dm_inlines_the_agent_name_because_there_is_no_webhook() -> None assert ref == f"{DM_CHANNEL_ID}:501" +def _no_thread_yet() -> tuple[DiscordAdapter, _Channel, _Webhook]: + """A channel whose root message has no reply thread hanging from it.""" + channel = _Channel() + adapter = _adapter({CHANNEL_ID: channel}) + channel.existing_webhooks = [ + _Webhook(_WEBHOOK_NAME), + _Webhook(_PUBLICATION_WEBHOOK_NAME), + ] + return adapter, channel, channel.existing_webhooks[1] + + +async def test_a_turn_opens_the_reply_thread_it_belongs_in() -> None: + """The thread a turn is published into is the ordinary reply thread, and + the first reply to a channel message is what makes it.""" + adapter, channel, webhook = _no_thread_yet() + channel.messages[ROOT_MESSAGE_ID] = _Message(channel, ROOT_MESSAGE_ID, "do it") + + await adapter.post_rich( + str(CHANNEL_ID), "my-agent", _activity(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert channel.sent == [] + assert webhook.sent[0]["thread"].id == ROOT_MESSAGE_ID + + async def test_progress_is_suppressed_rather_than_spilled_into_the_channel() -> None: """The channel shows what the agent was asked and what it answers; a turn whose thread cannot be made does not get to narrate itself there instead.""" - channel = _Channel() - adapter = _adapter({CHANNEL_ID: channel}) - adapter._webhooks[CHANNEL_ID] = _Webhook() + adapter, channel, webhook = _no_thread_yet() + channel.thread_error = discord.Forbidden(_Response(), "no Create Threads") # type: ignore[arg-type] with pytest.raises(RichContentFailed): await adapter.post_rich( @@ -377,16 +461,19 @@ async def test_progress_is_suppressed_rather_than_spilled_into_the_channel() -> ) assert channel.sent == [] + assert webhook.sent == [] async def test_a_card_falls_back_to_the_channel_root_when_its_thread_will_not_open( caplog: pytest.LogCaptureFixture, ) -> None: - """A question in the wrong place is answerable; one nobody can see is not.""" - channel = _Channel() - adapter = _adapter({CHANNEL_ID: channel}) - webhook = _Webhook() - adapter._webhooks[CHANNEL_ID] = webhook + """A question in the wrong place is answerable; one nobody can see is not. + + The root is a fallback only because the turn was addressed there: everyone + who could read the question can read the card. + """ + adapter, channel, webhook = _no_thread_yet() + channel.thread_error = discord.Forbidden(_Response(), "no Create Threads") # type: ignore[arg-type] with caplog.at_level(logging.WARNING): ref = await adapter.post_rich( @@ -401,6 +488,32 @@ async def test_a_card_falls_back_to_the_channel_root_when_its_thread_will_not_op assert ref.endswith(":901") +async def test_a_thread_the_bot_cannot_open_never_becomes_the_whole_channel() -> None: + """A private thread's request is not republished to its parent. + + A denied thread and an absent one look alike from outside, and only one of + them makes the channel an acceptable substitute. A request carries the + agent's question and its options: handing that to the parent channel gives + a private conversation an audience, and nothing takes it back. + """ + adapter, channel, webhook = _no_thread_yet() + client: Any = adapter._client + client.fetch_errors[ROOT_MESSAGE_ID] = discord.Forbidden( # type: ignore[arg-type] + _Response(), "not a member of this thread" + ) + + with pytest.raises(RichContentFailed): + await adapter.post_rich( + str(CHANNEL_ID), + "my-agent", + await _card(), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + ) + + assert channel.sent == [] + assert webhook.sent == [] + + # ── Failure semantics ──────────────────────────────────────────────────────── @@ -443,6 +556,34 @@ async def test_being_rate_limited_says_how_long_to_wait() -> None: assert raised.value.retry_after == 2.5 +async def test_the_webhooks_own_shape_of_429_is_a_throttle_too() -> None: + """The webhook transport does not raise `RateLimited`. + + It exhausts its own 429 retries and then raises a plain `HTTPException`, + which is the shape that actually reaches this seam in production. Read as + a refusal it would throw the reservation away and lose the wait Discord + asked for, so the status of the response is what decides. + """ + adapter, _channel, _thread, webhook = _guild_setup() + webhook.send_error = _http_error(429, headers={"Retry-After": "3.5"}) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.post_rich(str(CHANNEL_ID), "my-agent", _activity(), None) + + assert raised.value.retry_after == 3.5 + + +async def test_a_429_that_says_nothing_still_waits_rather_than_hammering() -> None: + """Retrying a throttle immediately is how a throttle becomes a ban.""" + adapter, _channel, _thread, webhook = _guild_setup() + webhook.send_error = _http_error(429, headers={}) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.post_rich(str(CHANNEL_ID), "my-agent", _activity(), None) + + assert raised.value.retry_after > 0 + + async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> None: adapter, _channel, _thread, webhook = _guild_setup() webhook.edit_error = _http_error(403) @@ -505,6 +646,42 @@ async def test_a_dm_redraw_writes_the_agent_name_back_into_the_body() -> None: assert dm.messages[501].edited.startswith("**my-agent**: ") +async def test_a_dm_redraw_reads_the_agent_name_back_off_the_message() -> None: + """The in-memory note of who posted what does not survive a restart. + + In a DM the name is in the body rather than on the sender, so the message + itself is the durable record β€” and a redraw that forgot it would republish + somebody's turn as the bot. + """ + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + + ref = await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", _activity(), None) + adapter._rich_agents.clear() + await adapter.update_rich(str(DM_CHANNEL_ID), ref, _activity()) + + assert dm.messages[501].edited is not None + assert dm.messages[501].edited.startswith("**my-agent**: ") + + +async def test_a_dm_redraw_that_cannot_recover_the_name_says_so( + caplog: pytest.LogCaptureFixture, +) -> None: + """Better a turn published without a name than a silently wrong one.""" + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + dm.messages[501] = _Message(dm, 501, "no name here") + + with caplog.at_level(logging.WARNING): + await adapter.update_rich( + str(DM_CHANNEL_ID), f"{DM_CHANNEL_ID}:501", _activity() + ) + + assert "carries no agent name" in caplog.text + assert dm.messages[501].edited is not None + assert not dm.messages[501].edited.startswith("**my-agent**") + + async def test_a_settled_card_is_never_taken_down() -> None: """It is the record of a decision, and it says what became of it.""" adapter, _channel, _thread, webhook = _guild_setup() @@ -551,7 +728,9 @@ def _posted_card(channel: Any, message_id: int, content: str, webhook_id: int | async def test_an_uncertainly_delivered_card_is_found_by_its_own_handle() -> None: adapter, channel, thread, webhook = _guild_setup() thread.history_messages = [ - _posted_card(thread, 901, "**Permission needed** Β· request `R7`", WEBHOOK_ID) + _posted_card( + thread, 901, "**Permission needed** Β· request `R7`", PUBLICATION_WEBHOOK_ID + ) ] found = await adapter.find_request_card( @@ -582,10 +761,93 @@ async def test_somebody_quoting_the_handle_is_not_mistaken_for_the_card() -> Non assert found is None +async def test_the_mention_above_a_card_does_not_hide_it() -> None: + """A card that notifies its asker opens with the mention, not the heading, + and it is the one kind of card recovery most needs to find.""" + adapter, channel, thread, webhook = _guild_setup() + thread.history_messages = [ + _posted_card( + thread, + 901, + f"<@{ASKER_ID}>\n**Permission needed** Β· request `R7`\nDeploy?", + PUBLICATION_WEBHOOK_ID, + ) + ] + + found = await adapter.find_request_card( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + "tok-1", + datetime.now(UTC), + "R7", + ) + + assert found == f"{ROOT_MESSAGE_ID}:901" + + +async def test_an_agent_explaining_a_card_is_not_bound_to_as_one() -> None: + """An agent's own words arrive on the bridge's other webhook. + + "the request `R7`" is a phrase an agent can write, and its reply is posted + by the bridge, so neither the sender being us nor the handle being present + tells a card from a sentence about one. Binding to the sentence would mean + every settlement edit overwrites an agent's reply while the real card sits + there still saying the request is open. + """ + adapter, channel, thread, webhook = _guild_setup() + thread.history_messages = [ + _posted_card(thread, 902, "I can explain the request `R7`", WEBHOOK_ID), + _posted_card( + thread, 903, "**Permission needed** Β· request `R7`", PUBLICATION_WEBHOOK_ID + ), + ] + + found = await adapter.find_request_card( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + "tok-1", + datetime.now(UTC), + "R7", + ) + + assert found == f"{ROOT_MESSAGE_ID}:903" + + +async def test_a_dm_card_is_recovered_from_the_bots_own_message() -> None: + """A DM has no webhooks, so the bot is the only sender a card can have.""" + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + card = _posted_card(dm, 501, "**Permission needed** Β· request `R7`", None) + card.author = _Author(BOT_USER_ID) + dm.history_messages = [card] + + found = await adapter.find_request_card( + str(DM_CHANNEL_ID), None, "tok-1", datetime.now(UTC), "R7" + ) + + assert found == f"{DM_CHANNEL_ID}:501" + + +async def test_a_dm_reply_from_the_same_bot_is_still_not_the_card() -> None: + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + reply = _posted_card(dm, 502, "**my-agent**: about request `R7` β€” ", None) + reply.author = _Author(BOT_USER_ID) + dm.history_messages = [reply] + + found = await adapter.find_request_card( + str(DM_CHANNEL_ID), None, "tok-1", datetime.now(UTC), "R7" + ) + + assert found is None + + async def test_a_card_that_fell_back_to_the_channel_root_is_still_found() -> None: adapter, channel, thread, webhook = _guild_setup() channel.history_messages = [ - _posted_card(channel, 903, "**Permission needed** Β· request `R7`", WEBHOOK_ID) + _posted_card( + channel, 903, "**Permission needed** Β· request `R7`", PUBLICATION_WEBHOOK_ID + ) ] found = await adapter.find_request_card( @@ -602,7 +864,9 @@ async def test_a_card_that_fell_back_to_the_channel_root_is_still_found() -> Non async def test_a_naive_timestamp_is_read_as_utc_rather_than_as_local_time() -> None: adapter, _channel, thread, _webhook = _guild_setup() thread.history_messages = [ - _posted_card(thread, 901, "**Permission needed** Β· request `R7`", WEBHOOK_ID) + _posted_card( + thread, 901, "**Permission needed** Β· request `R7`", PUBLICATION_WEBHOOK_ID + ) ] found = await adapter.find_request_card( @@ -700,6 +964,31 @@ async def test_a_missing_permission_is_not_retried_for_the_life_of_the_turn( assert "Add Reactions" in caplog.text +async def test_a_mark_that_cannot_be_taken_off_is_an_error_not_a_shrug( + caplog: pytest.LogCaptureFixture, +) -> None: + """A missing mark is an absence; a stuck one is a false statement. + + The channel goes on showing an agent working on something it finished, and + no retry here takes it back, so this is not the same event as a mark that + could not be added in the first place. + """ + adapter, channel, _thread, _webhook = _guild_setup() + ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", working=True + ) + channel.reaction_error = discord.Forbidden(_Response(), "cannot see the channel") # type: ignore[arg-type] + + with caplog.at_level(logging.WARNING): + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", working=False + ) + + assert [record.levelname for record in caplog.records] == ["ERROR"] + assert "will not come off by retrying" in caplog.text + + async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> None: adapter, channel, _thread, _webhook = _guild_setup() channel.reaction_error = _http_error(500) From 5847405c01b8bb373701b3853734532840dd420e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 00:43:57 +0100 Subject: [PATCH 007/120] Telegram SDK session parity, and disclose a card that can never be found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram gains the SDK publication path the other adapters have: its own send and edit seam that raises instead of returning nothing, HTML markup, the expandable activity block inside the one status message, forum topic ids kept apart from reply targets, per-chat pacing that always sends the final state, and the capability flags that describe all of it. Two shared seams moved to make that possible. `update_rich` now takes the agent's name, and so does `SessionRequestCards.refresh`. A platform that writes the name into the message body β€” Telegram always, Discord in a DM β€” could only rebuild it from an in-memory map, so a restart mid-turn redrew the message as somebody else. The name now comes from the caller on every call, and both per-adapter maps are deleted. The neutral renderer takes a markup seam for the same reason: Telegram's body is HTML, and one renderer with a markup argument beats a second copy of the budget and faithfulness logic. The rest is delivery under uncertainty. A Telegram bot cannot search its own history, so a send whose outcome is unknown can never be resolved: the row stays reserved with `external_post_id` equal to its token, the answer guard refuses every typed answer to it, and the publisher retries a lookup that will never succeed. A question sits in the chat and typing the answer does nothing. `recovers_uncertain_posts` separates the two meanings of `None` from `find_request_card` β€” "not there yet" on Slack, Mattermost and Discord, "never" here. Where it is false the publisher sends one message naming the request and linking Console, records it on the new `unconfirmed_notice_at` column, and then leaves the row alone instead of failing the publish on every cycle. The column is written before the send, so a crash loses the notice rather than repeating it; a notice that cannot be sent is logged and not retried. The reservation and the answer guard are untouched, and nothing is reposted. A turn's status goes the other way: an unrecoverable status reservation is released and the slot posts again, with a warning that it may duplicate a message already in the chat. Holding it would also swallow that turn's later attention message, and a silent turn is the worse fault. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 41 +- .../bridges/collaboration/discord/adapter.py | 81 +-- .../collaboration/mattermost/adapter.py | 44 +- .../bridges/collaboration/session/demo.py | 6 +- .../bridges/collaboration/session/outbound.py | 103 ++- .../session/renderers/__init__.py | 32 + .../session/renderers/neutral.py | 79 ++- .../bridges/collaboration/slack/adapter.py | 10 +- .../bridges/collaboration/telegram/adapter.py | 625 +++++++++++++++++- core/switch_core/db/models.py | 10 + ...7b21d63_request_post_unconfirmed_notice.py | 20 + core/switch_core/sessions/publication.py | 27 + .../collaboration/test_discord_sdk_only.py | 68 +- .../collaboration/test_mattermost_sdk_only.py | 62 +- .../collaboration/test_rich_content_port.py | 8 +- .../test_runtime_indicator_race.py | 26 +- .../test_session_card_posting.py | 4 +- .../test_session_compact_presentation.py | 2 +- .../test_session_neutral_forms.py | 9 +- .../test_session_request_lifecycle.py | 5 +- .../test_session_review_regressions.py | 6 +- .../test_session_text_answers.py | 15 + .../collaboration/test_slack_sdk_only.py | 6 +- .../collaboration/test_telegram_adapter.py | 62 +- .../collaboration/test_telegram_sdk_only.py | 575 ++++++++++++++++ .../sessions/test_activity_durability.py | 95 ++- .../switch_core/sessions/test_publication.py | 2 +- .../sessions/test_publication_retries.py | 166 +++++ .../sessions/test_session_presentation.py | 4 +- .../test_turn_activity_publication.py | 2 +- 30 files changed, 1965 insertions(+), 230 deletions(-) create mode 100644 core/switch_core/migrations/versions/a9c4e7b21d63_request_post_unconfirmed_notice.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 54cf936b0..9f9dc8ab2 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -23,7 +23,11 @@ InboundUserJoin, OutboundAttachment, ) -from switch_core.bridges.collaboration.session.renderers import RequestReference +from switch_core.bridges.collaboration.session.renderers import ( + MARKDOWN, + Markup, + RequestReference, +) from switch_core.bridges.collaboration.session.renderers.neutral import ( request_summary, turn_summary, @@ -310,6 +314,18 @@ class CollaborationAdapter(ABC): #: that trade is the platform's to make. runtime_state_follows_anchor: ClassVar[bool] = False + #: Whether `find_request_card` can actually search this platform. + #: + #: False here because the base `find_request_card` returns `None` for + #: every call: it has nowhere to look. An adapter that implements the + #: search sets this True, and the difference is not cosmetic β€” `None` + #: from a platform that searched means "not there yet, ask again", while + #: `None` from a platform that cannot search means "never, however long + #: you wait". Retrying the second one forever leaves a card visible in + #: the chat that silently refuses the answer it asks for, which is the + #: outcome the publisher discloses instead. + recovers_uncertain_posts: 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 @@ -616,7 +632,11 @@ async def post_rich( return ref async def update_rich( - self, channel_id: str, message_ref: str, content: RichContent + self, + channel_id: str, + agent_name: str, + message_ref: str, + content: RichContent, ) -> None: """Redraw what `post_rich` posted, in place. @@ -626,6 +646,12 @@ async def update_rich( raises on any non-2xx status), so this catches broadly rather than trusting the convention: whichever it does, a caller of `update_rich` sees `RichContentFailed` or nothing. + + `agent_name` is the same name `post_rich` was given, and is here for + the platform that writes it into the body: one bot identity means the + name is part of what was drawn, so a redraw that did not know it would + quietly rewrite the message as somebody else. Passing it on every call + keeps that out of an in-memory map that a restart empties. """ text = self.rich_fallback_text(content) try: @@ -680,9 +706,20 @@ def rich_fallback_text(self, content: RichContent) -> str: content.reference, escape=escape, limit=self.rich_fallback_limit(), + markup=self.rich_markup(), unavailable_reason=content.unavailable_reason, ) + def rich_markup(self) -> Markup: + """How this platform spells emphasis, a copyable literal and a link. + + Markdown by default, which is what every platform reaching the neutral + renderer today parses. A platform whose message body is something else + β€” Telegram's is HTML β€” overrides this rather than carrying a renderer + of its own, so the budget and faithfulness logic stays in one copy. + """ + return MARKDOWN + def _rich_escape(self, label: str) -> str: """`rich_fallback_text`'s host text, neutralised and then rendered. diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 58fa9bfcd..83ec3f242 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -81,11 +81,6 @@ # mentioned still resolves. _NO_MASS_MENTIONS = discord.AllowedMentions(everyone=False) -# The `**agent**: ` a DM post carries in place of the sender identity a webhook -# would have given it. Bounded and single-line so that a body opening with bold -# text of its own cannot be read as one. -_BODY_LABEL = re.compile(r"^\*\*[^*\n]{1,80}\*\*: ") - # Inserted after the `<` of anything that looks like a Discord entity. Discord # has no escape for `<`, so the syntax is broken rather than escaped β€” the same # technique discord.py uses on `@`. @@ -309,6 +304,11 @@ class DiscordAdapter(CollaborationAdapter): # retained, not reachable β€” removing it is its own task. renders_legacy_runtime_state: ClassVar[bool] = False + # `find_request_card` reads a channel's history back and matches a card by + # the handle printed on it, so an unacknowledged send can still be bound to + # the message it produced. + recovers_uncertain_posts: ClassVar[bool] = True + def __init__(self, *, config: DiscordConnectionConfig) -> None: super().__init__() self._config = config @@ -334,11 +334,6 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: # marked several messages. self._eyes: set[str] = set() self._agent_eyes: dict[tuple[str, str], set[str]] = {} - # Which agent a published status or card was posted as. Needed only in - # a DM, where the name is inlined in the body and an edit has to write - # it again; a webhook post keeps its identity through an edit by itself. - self._rich_agents: OrderedDict[str, str] = OrderedDict() - self._rich_agents_max = 1000 # Publications this adapter has taken down at the end of a turn, so a # later redraw of one is recognised as finished rather than reported as # a message Discord has lost. @@ -866,6 +861,7 @@ def _draw( ) -> str: escape = self._rich_escape limit = max(1, self.rich_fallback_limit() - len(prefix)) + markup = self.rich_markup() if isinstance(content, TurnActivity): # Charged to the same budget as the status it follows: a message # that just fits, plus a line saying it reached nobody, is a @@ -877,6 +873,7 @@ def _draw( content.turn, escape=escape, limit=max(1, limit - len(tail)), + markup=markup, elapsed_seconds=content.elapsed_seconds, session_url=content.session_url, mention=mention, @@ -895,6 +892,7 @@ def _draw( content.reference, escape=escape, limit=max(1, limit - len(lead) - len(tail)), + markup=markup, responder=responder, unavailable_reason=content.unavailable_reason, ) @@ -996,7 +994,7 @@ async def post_rich( raise self._rich_failure( error, f"Discord refused the post in DM {channel_id}", text ) from error - return self._remember_rich(f"{sent.channel.id}:{sent.id}", agent_name) + return f"{sent.channel.id}:{sent.id}" thread: Any = None if thread_root_id: @@ -1023,7 +1021,7 @@ async def post_rich( raise self._rich_failure( error, f"Discord refused the post in channel {channel_id}", text ) from error - return self._remember_rich(f"{sent.channel.id}:{sent.id}", agent_name) + return f"{sent.channel.id}:{sent.id}" async def _publication_thread( self, channel_id: int, thread_root_id: str, content: RichContent, text: str @@ -1109,7 +1107,11 @@ async def _reachable_thread( ) from error async def update_rich( - self, channel_id: str, message_ref: str, content: RichContent + self, + channel_id: str, + agent_name: str, + message_ref: str, + content: RichContent, ) -> None: """Redraw a publication in place β€” or take it down, where it has served its purpose and staying would just be clutter. @@ -1126,6 +1128,11 @@ async def update_rich( status line nobody is waiting on and wrong here: a card that failed to redraw is still showing a settled request as open, and the caller has a reply to post about that β€” but only if it is told. + + `agent_name` is what a DM redraw writes back into the body. A webhook + message keeps its sender through an edit because Discord keeps it; a DM + has no webhook, so the name is part of the message and an edit that + forgot it would publish the turn as the bot. """ _, message_id = self._parse_message_ref(message_ref) if not message_id: @@ -1147,9 +1154,7 @@ async def update_rich( ) from error lobby = self._channel_type_of(target) == "lobby" - prefix = ( - await self._lobby_prefix(target, message_ref, message_id) if lobby else "" - ) + prefix = f"**{await self.agent_label_for_body(agent_name)}**: " if lobby else "" # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the channel that never reaches anybody # it has not already reached. @@ -1161,42 +1166,6 @@ async def update_rich( return await self._edit_rich(channel_id, message_ref, text, lobby=lobby) - async def _lobby_prefix( - self, target: Any, message_ref: str, message_id: str - ) -> str: - """The `**agent**: ` a DM redraw has to write again. - - A webhook message keeps its sender through an edit because Discord - keeps it; a DM has no webhook, so the name lives in the body and an - edit that forgets it publishes the turn as the bot. This process - remembers which agent posted what, but only until it restarts, so the - message that already carries the name is asked for it: the prefix on - the post is the durable record of who made it, and reading it back - costs one fetch on a path that is about to edit that message anyway. - """ - remembered = self._rich_agents.get(message_ref) - if remembered is not None: - return f"**{await self.agent_label_for_body(remembered)}**: " - try: - existing = await target.fetch_message(int(message_id)) - match = _BODY_LABEL.match(str(existing.content or "")) - except Exception as e: - logger.warning( - "Could not read the Discord DM publication %s to recover which " - "agent posted it: %s.", - message_ref, - e, - ) - return "" - if match is None: - logger.warning( - "The Discord DM publication %s carries no agent name, so its " - "redraw cannot restore one.", - message_ref, - ) - return "" - return match.group(0) - def _is_flat(self, channel_id: str, message_ref: str) -> bool: """Whether a publication is sitting in the channel rather than a thread. @@ -1564,15 +1533,7 @@ async def notify_working( e, ) - def _remember_rich(self, message_ref: str, agent_name: str) -> str: - self._rich_agents[message_ref] = agent_name - self._rich_agents.move_to_end(message_ref) - while len(self._rich_agents) > self._rich_agents_max: - self._rich_agents.popitem(last=False) - return message_ref - def _retire_ref(self, message_ref: str) -> None: - self._rich_agents.pop(message_ref, None) self._rich_retired[message_ref] = None self._rich_retired.move_to_end(message_ref) while len(self._rich_retired) > self._rich_retired_max: diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 7a1f8fb64..78649c0d7 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -229,6 +229,11 @@ class MattermostAdapter(CollaborationAdapter): #: what the flag describes. runtime_state_follows_anchor: ClassVar[bool] = True + #: `find_request_card` reads the channel's recent posts back, so a card + #: whose send was never acknowledged can be bound to what is actually + #: there instead of being disclosed as lost. + recovers_uncertain_posts: ClassVar[bool] = True + def __init__(self, *, config: MattermostConnectionConfig) -> None: super().__init__() self._config = config @@ -271,14 +276,6 @@ def __init__(self, *, config: MattermostConnectionConfig) -> None: # touched. self._agent_eyes: dict[tuple[str, str], set[str]] = {} - # post id -> the agent whose bot posted it, for editing a publication - # back as itself. Bounded because it grows with every turn, and an - # entry only matters while the message it names is still being redrawn; - # past that the admin driver can patch it and Mattermost keeps the - # original author either way. - self._rich_authors: OrderedDict[str, str] = OrderedDict() - self._rich_authors_max = 1000 - # Mattermost user id -> username, because a mention is written with the # handle and Switch stores the id. Stable for the life of a user, so a # hit here saves a round trip on every redraw that carries a mention. @@ -650,6 +647,7 @@ def _draw( ) -> str: escape = self._rich_escape limit = self.rich_fallback_limit() + markup = self.rich_markup() if isinstance(content, TurnActivity): # Charged to the same budget as the status it follows: a post that # just fits, plus a line saying it reached nobody, is a post @@ -661,6 +659,7 @@ def _draw( content.turn, escape=escape, limit=max(1, limit - len(tail)), + markup=markup, elapsed_seconds=content.elapsed_seconds, session_url=content.session_url, mention=mention, @@ -681,6 +680,7 @@ def _draw( content.reference, escape=escape, limit=max(1, limit - len(lead) - len(tail)), + markup=markup, responder=responder, unavailable_reason=content.unavailable_reason, ) @@ -746,11 +746,14 @@ async def post_rich( if failure is None: raise raise failure from error - self._remember_author(ref, agent_name) return ref async def update_rich( - self, channel_id: str, message_ref: str, content: RichContent + self, + channel_id: str, + agent_name: str, + message_ref: str, + content: RichContent, ) -> None: """Redraw a publication in place, and say so when it did not happen. @@ -759,11 +762,11 @@ async def update_rich( failed to redraw is still showing a settled request as open, and the caller has a reply to post about it β€” but only if it is told. - Edited as the bot that posted it where that is still known. Mattermost - keeps the original author through a patch either way, so the fallback - to the admin driver changes who a reader sees the post from not at all; - what it changes is the permission the edit is made with, and an agent - bot editing its own post is the narrower of the two. + Edited as the agent's own bot where there is one. Mattermost keeps the + original author through a patch either way, so the fallback to the + admin driver changes who a reader sees the post from not at all; what + it changes is the permission the edit is made with, and an agent bot + editing its own post is the narrower of the two. Says "did not happen" only where Mattermost refused the edit. An edit whose outcome is unknown may well have landed, and reporting it as a @@ -773,10 +776,7 @@ async def update_rich( # redraw would be a handle in the channel that never resolves to # anything new for the person it names. text = await self._render_rich(replace(content, notify_external_id=None)) - driver = ( - self._bot_drivers.get(self._rich_authors.get(message_ref, "")) - or self._admin_driver - ) + driver = self._bot_drivers.get(agent_name) or self._admin_driver loop = self._main_loop if driver is None or loop is None: raise RichContentFailed( @@ -947,12 +947,6 @@ async def notify_working( """ await self._post_typing(channel_id, agent_name, thread_root_id) - def _remember_author(self, post_id: str, agent_name: str) -> None: - self._rich_authors[post_id] = agent_name - self._rich_authors.move_to_end(post_id) - while len(self._rich_authors) > self._rich_authors_max: - self._rich_authors.popitem(last=False) - async def _mention(self, external_user_id: str | None) -> str | None: """`@handle` for a Mattermost user id, or None if it cannot be resolved. diff --git a/core/switch_core/bridges/collaboration/session/demo.py b/core/switch_core/bridges/collaboration/session/demo.py index 848e16aa6..0cc9798e3 100644 --- a/core/switch_core/bridges/collaboration/session/demo.py +++ b/core/switch_core/bridges/collaboration/session/demo.py @@ -235,7 +235,11 @@ async def _finish(self, showing: _Showing) -> SessionRequestPost: f"The recording lost request {showing.request_id} on the way to " f"the end of its turn, so there is nothing to redraw the card from." ) - await self._cards.refresh(showing.post, settled) + await self._cards.refresh( + showing.post, + settled, + agent_name=showing.projection.snapshot.session.agent_id, + ) return showing.post async def _publish( diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 342c7f7fc..2893cc6fb 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -163,6 +163,7 @@ def __init__( ) self._timer_redraws = getattr(adapter, "redraws_for_elapsed_time", False) self._only_mentions_notify = getattr(adapter, "notifies_only_by_mention", False) + self._recovers_posts = getattr(adapter, "recovers_uncertain_posts", False) self._anchors: OrderedDict[tuple[str, str], _Anchor] = OrderedDict() self._thread_turns: dict[tuple[str, str, str], set[tuple[str, str]]] = {} self._attention: OrderedDict[tuple[str, str], tuple[str, str]] = OrderedDict() @@ -369,9 +370,9 @@ async def _refresh_attention( "attention", ) if saved: - await self._adapter.update_rich(channel_id, ref, content) + await self._adapter.update_rich(channel_id, agent_name, ref, content) else: - await self._adapter.update_rich(channel_id, ref, content) + await self._adapter.update_rich(channel_id, agent_name, ref, content) if record: record.data["attention_state"] = state await record.save() @@ -399,6 +400,23 @@ async def _post_activity( if record is None: return await self._adapter.post_rich(channel, agent, content, thread) delivery = record.data.get(slot) + if delivery and not delivery.get("ref") and not self._recovers_posts: + # Nothing will ever find this one, so holding the reservation holds + # the turn's only voice shut: no status, and no attention message + # when something goes wrong later. A second status message is worth + # more than a permanently silent turn, so the reservation is given + # up and this slot starts again. + logger.warning( + "Activity delivery for %s in %s was never confirmed and this " + "platform cannot search for it. Posting a new %s message, which " + "may duplicate one already in the channel.", + delivery["token"], + delivery["channel"], + slot, + ) + del record.data[slot] + await record.save() + delivery = None if delivery: saved_ref = delivery.get("ref") if saved_ref: @@ -656,6 +674,7 @@ async def _edit( try: await self._adapter.update_rich( anchor.channel_id, + anchor.agent_name, anchor.message_ref, TurnActivity( items, @@ -706,7 +725,7 @@ async def _draw_log( ) else: await self._adapter.update_rich( - anchor.channel_id, anchor.log_ref, content + anchor.channel_id, anchor.agent_name, anchor.log_ref, content ) except RichContentThrottled: raise @@ -875,6 +894,26 @@ def notifies_only_by_mention(self) -> bool: """ return bool(getattr(self._adapter, "notifies_only_by_mention", False)) + @property + def renders_custom_url_schemes(self) -> bool: + """Whether this platform makes a `switchdash://` link clickable. + + Read where a Console link is put in front of someone, for the same + reason `SessionTurnActivity` reads it: where it is False the link has + to be rewritten as the gateway's https redirect or it is dead text. + """ + return bool(getattr(self._adapter, "renders_custom_url_schemes", True)) + + @property + def recovers_uncertain_posts(self) -> bool: + """Whether a card whose send was never acknowledged can be found again. + + Where it is False there is nothing to wait for: the publisher stops + searching and discloses the card as unanswerable in the channel rather + than re-asking a question the platform cannot answer. + """ + return bool(getattr(self._adapter, "recovers_uncertain_posts", False)) + async def post( self, request: SnapshotRequest, @@ -980,6 +1019,57 @@ async def recover(self, post: SessionRequestPost) -> SessionRequestPost: await session.commit() return stored + async def disclose_unconfirmed( + self, post: SessionRequestPost, *, console_url: str | None + ) -> None: + """Say in the channel that this card cannot be answered there. + + For the platform that cannot search its own history, an unconfirmed + delivery never resolves: the card may be sitting in the chat asking a + question, and `command_for_text` refuses every typed answer to it + because nothing can prove the card exists. Left alone that is the worst + of the failure modes β€” it looks like it is working. So the channel is + told once, in a separate message, and pointed at Console, which can + answer the request without needing the card at all. + + Exactly one attempt is ever made, and the row records it before the + message is sent rather than after. A second notice would say nothing + the first did not, and this is reached on every publication cycle for + as long as the request stays open β€” so the durable mark has to be in + place before anything can go wrong, even at the cost of losing the + notice entirely if this process dies mid-send. + + The reservation itself is kept. It is what stops the card being posted + a second time, and the handle it holds is the one printed on whatever + did arrive. + """ + async with self._session_factory() as session: + stored = await session.get( + SessionRequestPost, post.id, with_for_update=True + ) + if stored is None or stored.unconfirmed_notice_at is not None: + return + stored.unconfirmed_notice_at = datetime.now(UTC) + await session.commit() + console = ( + f"[Switch Console]({console_url})" if console_url else "Switch Console" + ) + sent = await self._adapter.admin_message( + post.external_channel_id, + f"Switch could not confirm that request **{post.handle}** reached " + "this chat. If a card for it is here, answering it here will not " + f"work β€” answer it in {console} instead.", + post.thread_id, + ) + if sent is None: + logger.error( + "Could not tell channel %s that card %s was never confirmed. The " + "request can still be answered in Console, but nothing in the " + "channel says so, and this is not attempted again.", + post.external_channel_id, + post.handle, + ) + async def _reserve( self, session: AsyncSession, @@ -1084,10 +1174,16 @@ async def refresh( post: SessionRequestPost, request: SnapshotRequest, *, + agent_name: str, unavailable_reason: str | None = None, ) -> None: """Redraw the card for `request` where it was posted. + `agent_name` is the agent whose session asked, the same name the card + was posted under. A platform that writes the name into the body needs + it again to redraw the card as the same agent, and the row does not + carry it: the session does, and every caller here has the session. + When the edit fails the outcome is posted into the thread instead. A stale card is the one failure that cannot be left silent: it goes on showing buttons for a request that has already settled, and a reader has @@ -1137,6 +1233,7 @@ async def refresh( try: await self._adapter.update_rich( post.external_channel_id, + agent_name, post.external_post_id, RequestCard( request, diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index c27aebd37..b5be6a5fa 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -196,6 +196,38 @@ def parse_answer_action(action_id: str) -> str | None: return option_id or None +class Markup: + """The three marks the neutral renderer makes, in one platform's spelling. + + Emphasis, a literal a reader is meant to copy, and a link. Everything else + the renderer writes is plain text. They live behind this rather than being + written into the renderer because a platform that does not parse Markdown + is otherwise forced to choose between a renderer of its own β€” the whole of + the budget and faithfulness logic, copied and left to drift β€” and shipping + `**Working…**` to a reader as those characters. + + Not an escaper. Host text is neutralised by the adapter's own escape before + it reaches here, and what these produce is measured against the message + budget like anything else, so a spelling that costs more characters costs + them out of the same allowance. + """ + + def bold(self, text: str) -> str: + return f"**{text}**" + + def code(self, text: str) -> str: + return f"`{text}`" + + def link(self, label: str, url: str) -> str: + # A `)` inside the destination closes the link early and spills the + # rest of the URL into the body as text. Percent-encoding is the one + # transform that keeps the link working and cannot be read as syntax. + return f"[{label}]({url.replace(')', '%29')})" + + +MARKDOWN = Markup() + + @dataclass(frozen=True) class RequestReference: """How a platform refers back to a request, without carrying the session. diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index a8bee66c7..03b99fb17 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -20,12 +20,14 @@ with the typed-answer grammar the card is asking for spelled out against this particular form. -`escape` and `limit` are the platform's: every value that came from a host is -host text and needs neutralising the way that platform's body text does, and -the result has to fit inside one message rather than assume there is room to -spare. Markdown is assumed β€” emphasis, a numbered list, an inline link β€” which -is what the platforms without a card renderer render today; a platform that -parses something else supplies its own renderer rather than bending this one. +`escape`, `limit` and `markup` are the platform's: every value that came from a +host is host text and needs neutralising the way that platform's body text +does, the result has to fit inside one message rather than assume there is room +to spare, and emphasis, a copyable literal and a link are spelled the way the +platform spells them. A numbered list is plain text either way. What is still +assumed of a platform arriving here is only that it renders those three marks +somehow; one that renders none of them is better served by a renderer of its +own than by a `Markup` that returns its argument. """ from __future__ import annotations @@ -51,6 +53,7 @@ CLOSED, NO_OPTIONS, SURFACES, + Markup, RequestReference, example_value, turn_state, @@ -160,6 +163,7 @@ def turn_status( *, escape: Callable[[str], str], limit: int, + markup: Markup, elapsed_seconds: float | None = None, session_url: str | None = None, mention: str | None = None, @@ -196,8 +200,8 @@ def turn_status( budget = _room(limit, mention) state = turn_state(items, turn, elapsed_seconds=elapsed_seconds) - head = f"**{state}**" - link = _link(_CONSOLE, session_url) + head = markup.bold(state) + link = _link(_CONSOLE, session_url, markup) if link and len(head) + 3 + len(link) <= budget: head = f"{head} Β· {link}" @@ -261,6 +265,7 @@ def request_summary( *, escape: Callable[[str], str], limit: int, + markup: Markup, responder: str | None = None, unavailable_reason: str | None = None, ) -> str: @@ -297,11 +302,23 @@ def request_summary( content = request.content if isinstance(content, ApprovalContent): head, body, footer, invites_answer = _approval_form( - request, content, reference, escape=escape, limit=limit, responder=responder + request, + content, + reference, + escape=escape, + limit=limit, + markup=markup, + responder=responder, ) else: head, body, footer, invites_answer = _questions_form( - request, content, reference, escape=escape, limit=limit, responder=responder + request, + content, + reference, + escape=escape, + limit=limit, + markup=markup, + responder=responder, ) if unavailable_reason and request.state in {"open", "submitting"}: body = [] @@ -323,11 +340,12 @@ def _approval_form( *, escape: Callable[[str], str], limit: int, + markup: Markup, responder: str | None, ) -> tuple[list[str], list[str], str, bool]: fit = _Faithful(escape) handle = escape(reference.handle) - head = [f"**{_HEADINGS[request.state]}** Β· request `{handle}`"] + head = [f"{markup.bold(_HEADINGS[request.state])} Β· request {markup.code(handle)}"] head.append(fit(content.title, _share(limit, 1500, 3))) if content.detail: head.append(fit(content.detail, _share(limit, 1200, 4))) @@ -348,7 +366,13 @@ def _approval_form( head, body, _approval_footer( - request, content, handle, escape=escape, limit=limit, responder=responder + request, + content, + handle, + escape=escape, + limit=limit, + markup=markup, + responder=responder, ), invites_answer, ) @@ -361,6 +385,7 @@ def _approval_footer( *, escape: Callable[[str], str], limit: int, + markup: Markup, responder: str | None, ) -> str: if request.state == "open": @@ -370,7 +395,7 @@ def _approval_footer( # around it are not part of the answer: `"R42 1"` parses as a handle of # `"R42`, which resolves to nothing and changes nothing on the card. # The grammar strips the backticks the span is drawn from. - return f"Reply with `{handle} 1`." + return f"Reply with {markup.code(f'{handle} 1')}." if request.state == "submitting": return _in_flight(request, responder=responder, limit=limit, escape=escape) if request.state == "resolved": @@ -416,12 +441,14 @@ def _questions_form( *, escape: Callable[[str], str], limit: int, + markup: Markup, responder: str | None, ) -> tuple[list[str], list[str], str, bool]: fit = _Faithful(escape) handle = escape(reference.handle) head = [ - f"**{_QUESTION_HEADINGS[request.state]}** Β· request `{handle}`", + f"{markup.bold(_QUESTION_HEADINGS[request.state])} Β· request " + f"{markup.code(handle)}", fit(content.title, _share(limit, 1500, 3)), ] @@ -430,7 +457,9 @@ def _questions_form( budget = _label_budget(limit) for position, question in enumerate(content.questions, start=1): title = fit(question.title, budget) if question.title else "" - body.append(f"**{position}. {title}**" if title else f"**{position}.**") + body.append( + markup.bold(f"{position}. {title}" if title else f"{position}.") + ) if question.prompt: body.append(fit(question.prompt, _share(limit, 800, 4))) for index, option in enumerate(question.options, start=1): @@ -442,7 +471,13 @@ def _questions_form( head, body, _questions_footer( - request, content, handle, escape=escape, limit=limit, responder=responder + request, + content, + handle, + escape=escape, + limit=limit, + markup=markup, + responder=responder, ), invites_answer, ) @@ -475,13 +510,14 @@ def _questions_footer( *, escape: Callable[[str], str], limit: int, + markup: Markup, responder: str | None, ) -> str: if request.state == "open": stuck = unanswerable(content.questions) if stuck is not None: return stuck - example = f"`{_example(handle, content.questions)}`" + example = markup.code(_example(handle, content.questions)) if len(content.questions) > 1: return f"Reply with {example} β€” every question needs an answer." return f"Reply with {example}." @@ -686,14 +722,11 @@ def _room(limit: int, mention: str | None) -> int: return max(1, limit - (len(mention) + 1 if mention else 0)) -def _link(label: str, url: str | None) -> str: - """`url` as Markdown, or nothing at all if it is not a scheme worth linking.""" +def _link(label: str, url: str | None, markup: Markup) -> str: + """`url` as a link, or nothing at all if it is not a scheme worth linking.""" if not url or not url.startswith(_LINK_SCHEMES): return "" - # A `)` inside the destination closes the link early and spills the rest of - # the URL into the body as text. Percent-encoding is the one transform that - # keeps the link working and cannot be read as syntax. - return f"[{label}]({url.replace(')', '%29')})" + return markup.link(label, url) def _label_budget(limit: int) -> int: diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 465c5121e..cef9ad28f 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -166,6 +166,7 @@ class SlackAdapter(CollaborationAdapter): redraws_for_elapsed_time: ClassVar[bool] = True supports_activity_reactions: ClassVar[bool] = True renders_legacy_runtime_state: ClassVar[bool] = False + recovers_uncertain_posts: 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 @@ -517,10 +518,17 @@ async def post_rich( return ref async def update_rich( - self, channel_id: str, message_ref: str, content: RichContent + self, + channel_id: str, + agent_name: str, + message_ref: str, + content: RichContent, ) -> None: """Redraw what `post_rich` posted, in place. + `agent_name` is not read: a Slack message carries its sender's name + and face, so the name is never part of what was drawn. + Chains `SlackApiError` as `RichContentFailed` rather than letting it through raw, so a caller that no longer imports this module still has one thing to catch. diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 06883fd45..915ebbd0b 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -21,13 +21,25 @@ Update, ) from telegram.constants import ChatAction, ChatType, ParseMode -from telegram.error import BadRequest, Conflict, TelegramError +from telegram.error import ( + BadRequest, + ChatMigrated, + Conflict, + Forbidden, + RetryAfter, + TelegramError, +) from telegram.ext import Application, ApplicationBuilder, TypeHandler from switch_core.bridges.agent.commands import COMMANDS, COMMANDS_BY_NAME, CommandArg from switch_core.bridges.collaboration.adapter import ( CollaborationAdapter, LiveRuntimeIndicator, + RequestCard, + RichContent, + RichContentFailed, + RichContentThrottled, + TurnActivity, ) from switch_core.bridges.collaboration.models import ( Attachment, @@ -43,10 +55,16 @@ InboundUserJoin, OutboundAttachment, ) +from switch_core.bridges.collaboration.session.renderers import Markup +from switch_core.bridges.collaboration.session.renderers.neutral import ( + request_summary, + turn_status, +) from switch_core.bridges.collaboration.telegram.chunking import ( MAX_MESSAGE, chunk_message, ) +from switch_core.sessions.contract import TURN_ENDED logger = logging.getLogger(__name__) @@ -116,6 +134,103 @@ ) +# Waited when Telegram says to slow down without saying for how long. Every +# real 429 carries `retry_after`, so this is only reached when the field is +# missing or unreadable β€” a floor, not a figure Telegram committed to. +_THROTTLE_FALLBACK = 5.0 + +# The shortest gap the bridge will leave between two redraws of one running +# turn. Telegram's own ceiling for edits in a group is roughly one a second +# and it enforces it with a 429 that then applies to everything in the chat, +# including the agent's actual reply. Pacing ourselves under it costs a +# redraw its freshness; being paced by Telegram costs the conversation. +# +# Only intermediate progress is held back. A turn's last state, the attention +# slot and every request card go through immediately, because a reader waiting +# on one of those is waiting on the thing this pacing would delay. +_REDRAW_INTERVAL = 1.5 + + +def _throttle_delay(error: RetryAfter) -> float: + """How long Telegram's 429 asks us to wait. + + `retry_after` is documented as seconds and arrives as an int, but it is + read defensively and floored: a zero or a missing value would turn a + throttle into a tight retry loop, which is how a throttled bot becomes a + blocked one. + """ + raw: Any = getattr(error, "retry_after", None) + try: + return max(1.0, float(raw)) + except (TypeError, ValueError): + return _THROTTLE_FALLBACK + + +def _as_rich_failure( + error: Exception, *, description: str, text: str +) -> RichContentFailed | None: + """Telegram's answer, or no answer at all. + + `None` means the call may or may not have landed and the caller must keep + its reservation: `RichContentFailed` is a licence to discard one and try + again, which on a request card is a licence to ask the same question twice. + + The order here is load-bearing, and not the order it looks like. In + python-telegram-bot `BadRequest` is a subclass of `NetworkError`, so a + `NetworkError` branch written first swallows every rejection Telegram + actually made and reports a definite refusal as an unknown outcome β€” the + reservation would then be held open for a card the API has already refused + to post, forever. The definite answers are therefore tested first. + + `RetryAfter` is an answer, but not that one: it means wait, and it carries + the delay to wait for. `Forbidden` (the bot was removed or blocked) and + `ChatMigrated` (the chat id is no longer the chat) are definite: the call + did not happen and repeating it unchanged will not make it happen. + `TimedOut` and the rest of `NetworkError` are the uncertain ones β€” the + request may have reached Telegram and the response been lost. + """ + if isinstance(error, RetryAfter): + return RichContentThrottled(retry_after=_throttle_delay(error), text=text) + if isinstance(error, BadRequest | Forbidden | ChatMigrated): + return RichContentFailed(f"{description}: {error}", text=text) + return None + + +class _TelegramMarkup(Markup): + """The neutral renderer's three marks, spelled as Telegram's HTML subset. + + Telegram's message bodies are sent with `parse_mode=HTML`, so `**bold**` + would reach a reader as those four characters around the word. What these + return is finished HTML, inserted into a string whose host text has already + been escaped by `_rich_escape` β€” so nothing here escapes again, and nothing + here is given anything that still needs escaping. + """ + + def bold(self, text: str) -> str: + return f"{text}" + + def code(self, text: str) -> str: + return f"{text}" + + def link(self, label: str, url: str) -> str: + """An anchor, or the address as tap-to-copy text where one would vanish. + + Telegram renders `` only for the schemes it knows, and the neutral + renderer's one link may be a `switchdash://` deeplink. An anchor + carrying that is not rendered as written β€” the API rejects the whole + message with "unsupported URL protocol", or the client keeps the label + and silently drops the address β€” so the degradation is made here and + made visible: the reader gets the address itself, in a span Telegram + makes tap-to-copy, instead of a label that goes nowhere. + """ + if not url.lower().startswith(_LINKABLE_SCHEMES): + return f"{html.escape(url, quote=False)}" + return f'{label}' + + +TELEGRAM_HTML = _TelegramMarkup() + + class _ChatVisibility(NamedTuple): """What the bridge can see in one chat, and how certain that is. @@ -164,6 +279,43 @@ class TelegramAdapter(CollaborationAdapter): supports_directory_search: ClassVar[bool] = False renders_custom_url_schemes: ClassVar[bool] = False + publishes_sdk_sessions: ClassVar[bool] = True + + #: One message for the whole of a turn's progress. + #: + #: Telegram has no collapsed disclosure inside an ordinary message that a + #: second post would buy, and it prices edits per chat rather than per + #: message: a separate log would double the edit rate of every turn and + #: spend the chat's budget on the half nobody is waiting for. The compact + #: status already carries the tool counts. + separate_activity_log: ClassVar[bool] = False + + #: A problem somebody has to act on gets its own message. + #: + #: An edit does not notify on Telegram. Folded into the status, a failure + #: would land as a silent rewrite of a message the reader has already + #: scrolled past, which is the one case where being told matters most. + separate_attention_slot: ClassVar[bool] = True + + #: Everyone in a Telegram chat is notified of a new message without being + #: named, so a mention is an emphasis rather than the only route to a + #: reader. Naming the asker still happens; it is not what delivery rests on. + notifies_only_by_mention: ClassVar[bool] = False + + #: The status is the turn's one post, so the seconds ride along with the + #: next real change rather than rewriting it on a timer. Telegram's edit + #: limits make that more than a preference: a clock redrawn every few + #: seconds is a turn spending the chat's whole allowance on itself. + redraws_for_elapsed_time: ClassVar[bool] = False + + supports_activity_reactions: ClassVar[bool] = True + + #: One bot posts for every agent here, and a reaction belongs to the + #: account that added it, so there is one mark between them all. + activity_reactions_per_agent: ClassVar[bool] = False + + renders_legacy_runtime_state: ClassVar[bool] = False + def __init__(self, *, config: TelegramConnectionConfig) -> None: super().__init__() self._config = config @@ -218,6 +370,19 @@ def __init__(self, *, config: TelegramConnectionConfig) -> None: # (chat id, agent name) -> every message that agent has marked. An # agent asked two things at once marks both, and the turn ends once. self._agent_reactions: dict[tuple[str, str], set[str]] = {} + # chat id -> whether it is a forum. What a thread root means depends on + # the answer, and nothing in a message ref says which kind it is. + self._forum_chats: dict[str, bool] = {} + # When Telegram will next accept an update, from the last 429 it sent. + # A 429 is charged to the chat, not the message, so one throttled + # redraw pauses every publication rather than only its own. + self._rich_update_after = 0.0 + # (chat id, message id) -> when that publication was last redrawn, so + # intermediate progress can be paced without holding back the states a + # reader is actually waiting on. Bounded like the other per-message + # caches: an entry is only ever a timestamp to compare against. + self._rich_drawn_at: OrderedDict[str, float] = OrderedDict() + self._rich_drawn_at_max = 1000 # ── Lifecycle ──────────────────────────────────────────────────────────── @@ -641,7 +806,7 @@ async def send_attachment( ) bot = self._require_bot() - kwargs = self._reply_kwargs(thread_root_id) + kwargs = await self._anchor_kwargs(channel_id, thread_root_id) try: if self._is_photo(mimetype, len(data)): sent = await bot.send_photo( @@ -752,7 +917,7 @@ async def send_attachments( sent = await bot.send_media_group( chat_id=self._chat_id(channel_id), media=media, - **self._reply_kwargs(thread_root_id), + **await self._anchor_kwargs(channel_id, thread_root_id), ) except TelegramError as e: logger.error( @@ -991,6 +1156,11 @@ async def _apply_runtime_state( ) -> None: """Render runtime state as persistent, deletable status messages. + Superseded: `renders_legacy_runtime_state` is False, so nothing calls + this. Kept until the legacy indicator is removed everywhere, because + deleting one platform's copy ahead of the others makes the comparison + between them impossible to read. + A Telegram bot deletes its own messages cleanly (no tombstone), so β€” like Slack and Discord β€” the "working on it…" indicator and any "needs your input" pings are posted while relevant and removed when the turn ends. @@ -998,11 +1168,10 @@ async def _apply_runtime_state( mid-turn, just paused) and the pings go with it when the turn ends or resumes. - In a 1:1 chat Telegram will draw the progress itself, and better: an - animated "Thinking…" attributed to the bot rather than a message in the - history. Where that is available the posted message is not used, and - any earlier one is taken down. It is a private-chat method, so a - bridged group always gets the posted message. + The same posted message in a 1:1 chat as in a group. Telegram has no + per-bot progress affordance to prefer over it β€” `sendChatAction` is the + only one, it is a five-second one-shot with no cancel, and it says + "typing" rather than what the agent is doing. """ key = (channel_id, agent_name) if state in ("working", "awaiting-input"): @@ -1051,6 +1220,391 @@ async def _clear_input_pings(self, channel_id: str, agent_name: str) -> None: for ref in refs: await self.delete_message(channel_id, ref) + # ── SDK session publication ────────────────────────────────────────────── + + def rich_fallback_limit(self) -> int: + """Telegram's own ceiling for a message body, which is also an edit's. + + The renderers cut to this so the budget is measured on the HTML that + actually goes on the wire β€” `_rich_escape` has already run + `translate_outbound`, and that is what turns one `&` into five + characters. + """ + return MAX_MESSAGE + + def rich_markup(self) -> Markup: + return TELEGRAM_HTML + + def rich_fallback_text(self, content: RichContent) -> str: + """The same drawing `post_rich` sends, without the agent's name on it. + + Only the text an error carries, so there is nothing here to attribute: + it is what a caller logs or shows in the Console when the post did not + happen, not something anyone reads in the chat. + """ + return self._draw(content, mention=None, responder=None, prefix="") + + def _draw( + self, + content: RichContent, + *, + mention: str | None, + responder: str | None, + prefix: str, + ) -> str: + escape = self._rich_escape + limit = max(1, self.rich_fallback_limit() - len(prefix)) + markup = self.rich_markup() + if isinstance(content, TurnActivity): + # Charged to the same budget as the status it follows: a message + # that just fits, plus a line saying it reached nobody, is a + # message Telegram refuses β€” and an edit has no chunking to fall + # back on. + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + body = ( + turn_status( + content.items, + content.turn, + escape=escape, + limit=max(1, limit - len(tail)), + markup=markup, + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + mention=mention, + error_summary=content.error_summary, + ) + + tail + ) + return f"{prefix}{body}" + # The mention goes on its own line rather than in front of the heading: + # a card is a block, and a handle wedged before "Permission needed" + # reads as part of the heading. + lead = f"{mention}\n" if mention else "" + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + body = request_summary( + content.request, + content.reference, + escape=escape, + limit=max(1, limit - len(lead) - len(tail)), + markup=markup, + responder=responder, + unavailable_reason=content.unavailable_reason, + ) + return f"{prefix}{lead}{body}{tail}" + + async def _render_rich(self, content: RichContent, agent_name: str) -> str: + """Draw `content` as the agent, for one Telegram chat. + + The name is always in the body. Telegram gives a bot no per-message + identity β€” no name or avatar override, no webhook equivalent β€” so one + bot posts for every agent and the prefix `_attribute` writes is the + whole of what tells them apart. It is charged to the same message + budget as the drawing under it. + """ + agent = await self.agent_rendering(agent_name) + prefix = ( + f"{self._agent_marker(agent_name)} " + f"{html.escape(agent.body_label, quote=False)}\n" + ) + responder = ( + self._mention(content.responder_external_id) + if isinstance(content, RequestCard) + else None + ) + return self._draw( + content, + mention=self._mention(content.notify_external_id), + responder=responder, + prefix=prefix, + ) + + def _mention(self, external_user_id: str | None) -> str | None: + """A real Telegram mention for a user id, or nothing. + + `tg://user?id=N` notifies whether or not the account has a public + handle, which a bare `@name` does not, so the id is the thing worth + linking. The anchor still needs text, and the only honest text is the + name this bridge has actually seen the account use: an account that + has never spoken here gets no mention rather than a made-up handle + that names the wrong person or nobody. + + Losing it costs emphasis, not delivery. Telegram notifies everyone in + a chat of a new message without anyone being named, which is why + `notifies_only_by_mention` is False here. + """ + if not external_user_id: + return None + try: + user_id = int(external_user_id) + except ValueError: + logger.warning( + "Cannot mention %r on Telegram: it is not a user id.", + external_user_id[:64], + ) + return None + name = self._user_names.get(user_id) + if not name: + logger.debug( + "No name known for Telegram user %s, so the publication names " + "nobody. Everyone in the chat is notified of it regardless.", + user_id, + ) + return None + label = html.escape(f"@{name}", quote=False) + return f'{label}' + + async def post_rich( + self, + channel_id: str, + agent_name: str, + content: RichContent, + thread_root_id: str | None = None, + ) -> str: + """Post a turn's status or a request's card, attributed to the agent. + + Raises on every failure, unlike `send_message`, which reports one by + returning `None`: a publication that silently did not happen is a + reservation nothing retries and a turn the channel never sees. What it + raises is the point β€” `RichContentFailed` is the caller's licence to + discard the reservation and try again, so it is reserved for a refusal + Telegram actually gave. A send whose outcome nobody knows raises the + transport's own error and keeps the reservation. + + No plain-text retry, which `_send_chunk` has and this deliberately does + not. That retry exists for relayed host text, where losing the markup + beats losing the message; here the markup is Switch's own and a chat + that refuses it is a chat where the next redraw will be refused too. + Reporting the refusal is what lets the publisher fall back once, in one + place, instead of each platform inventing a degraded card of its own. + + Not chunked either. A publication is edited for the life of a turn, and + an edit cannot be split, so a drawing that would not fit into one + message must not be posted across two β€” the renderers cut to + `rich_fallback_limit` for exactly that reason and `_clamp` is the + backstop if something still overruns. + """ + text = await self._render_rich(content, agent_name) + self._refuse_while_throttled(text) + try: + anchor = await self._anchor_kwargs(channel_id, thread_root_id) + sent = await self._require_bot().send_message( + chat_id=self._chat_id(channel_id), + text=self._clamp(text), + parse_mode=ParseMode.HTML, + link_preview_options=_NO_PREVIEW, + **anchor, + ) + except Exception as error: + raise self._rich_failure( + error, f"Telegram refused the post in chat {channel_id}", text + ) from error + ref = self._ref(sent) + self._note_redraw(ref) + return ref + + async def update_rich( + self, + channel_id: str, + agent_name: str, + message_ref: str, + content: RichContent, + ) -> None: + """Redraw a publication in place. + + Nothing is ever taken down. On the platforms that delete a finished + status the status was a separate thing from the turn's reply; here it + is a message in the chat like any other, deleting it leaves the reply + with nothing saying what produced it, and a Telegram client that has + already shown the notification cannot unshow it. + + `agent_name` is what the redraw writes back into the body. The name is + the message here β€” one bot posts for every agent β€” so an edit that did + not know it would republish this turn as whoever the process last + happened to remember, or as nobody. + + Not `update_message`, which logs and returns. That is right for a + status line nobody is waiting on and wrong here: a card that failed to + redraw is still showing a settled request as open, and the caller has a + reply to post about that β€” but only if it is told. + """ + chat_id, message_id = self._parse_message_ref(message_ref) + if not message_id: + raise RichContentFailed( + f"Cannot redraw Telegram publication {message_ref!r}: it is not a " + "chat:message reference.", + text=self.rich_fallback_text(content), + ) + # A post notifies; an edit does not. Repeating the mention on every + # redraw would be a handle in the chat that never reaches anybody it + # has not already reached. + text = await self._render_rich( + replace(content, notify_external_id=None), agent_name + ) + self._refuse_while_throttled(text) + self._pace_redraw(message_ref, content, text) + try: + await self._require_bot().edit_message_text( + chat_id=self._chat_id(chat_id or channel_id), + message_id=int(message_id), + text=self._clamp(text), + parse_mode=ParseMode.HTML, + link_preview_options=_NO_PREVIEW, + ) + except BadRequest as error: + # The one refusal that means the work is already done: an edit to + # the text Telegram is already showing. + if "not modified" in str(error).lower(): + self._note_redraw(message_ref) + return + raise self._rich_failure( + error, + f"Telegram refused the edit to {message_ref} in chat {channel_id}", + text, + ) from error + except Exception as error: + raise self._rich_failure( + error, + f"Telegram refused the edit to {message_ref} in chat {channel_id}", + text, + ) from error + self._note_redraw(message_ref) + + def _rich_failure(self, error: Exception, description: str, text: str) -> Exception: + """The exception to raise for `error`: Telegram's refusal, or its own. + + Raising the original back is what keeps an uncertain send's reservation + alive, so this returns rather than raises β€” the caller writes + `raise ... from error` and the chain stays intact either way. + + A `RetryAfter` on the way through records when the chat will accept + anything again. Telegram charges the limit to the chat rather than to + the message, so one throttled redraw is the whole chat asking for + quiet, and the next publication in it waits rather than discovering + the same thing for itself. + """ + failure = _as_rich_failure(error, description=description, text=text) + if isinstance(failure, RichContentThrottled): + self._rich_update_after = time.monotonic() + failure.retry_after + return failure or error + + def _refuse_while_throttled(self, text: str) -> None: + """Wait out a 429 Telegram has already sent for this bot. + + Raised rather than slept through: the caller is a durable publisher + that knows what it is holding and can come back, and sleeping here + would hold up every other chat this bridge serves. + """ + remaining = self._rich_update_after - time.monotonic() + if remaining > 0: + raise RichContentThrottled(retry_after=remaining, text=text) + + def _pace_redraw(self, message_ref: str, content: RichContent, text: str) -> None: + """Hold back intermediate progress that is arriving faster than the chat + can take it. + + Only progress. A turn's final state, the attention slot and every + request card go through however recently the last redraw was, because + a reader waiting on one of those is waiting on precisely the thing + this would delay β€” and the publisher retries a throttle, so what is + held back here is postponed rather than lost. + """ + if not isinstance(content, TurnActivity): + return + if content.turn.status in TURN_ENDED or content.error_summary: + return + drawn_at = self._rich_drawn_at.get(message_ref) + if drawn_at is None: + return + remaining = drawn_at + _REDRAW_INTERVAL - time.monotonic() + if remaining > 0: + raise RichContentThrottled(retry_after=remaining, text=text) + + def _note_redraw(self, message_ref: str) -> None: + self._rich_drawn_at.pop(message_ref, None) + self._rich_drawn_at[message_ref] = time.monotonic() + while len(self._rich_drawn_at) > self._rich_drawn_at_max: + self._rich_drawn_at.popitem(last=False) + + async def mark_activity( + self, + channel_id: str, + message_ref: str, + *, + agent_name: str, + working: bool, + force: bool = False, + ) -> None: + """Put πŸ‘€ on the message being worked on, or take it off. + + One mark between every agent, because every agent reacts through the + one bot account and Telegram has a single reaction per account per + message. The publisher counts the turns holding it, so the first to + want it adds it and the last to finish removes it. + + `force` is the durable publisher reconciling after a restart, when this + process's record of what is already on the message is empty and wrong + rather than empty and right. + + Raises where another attempt might work, so the publisher retries and + records the turn as drawn only once the chat shows what it says it + shows. A chat with reactions switched off is not that: it would be + retried for the life of the turn and refused every time, so it is + reported once and the turn goes on without the mark. + """ + _, message_id = self._parse_message_ref(message_ref) + if not message_id: + logger.warning( + "Cannot mark %s as being worked on: not a Telegram message reference.", + message_ref, + ) + return + key = (channel_id, message_id) + if not force and working == (key in self._reacted): + return + try: + await self._require_bot().set_message_reaction( + chat_id=self._chat_id(channel_id), + message_id=int(message_id), + reaction=[ReactionTypeEmoji(_WORKING_REACTION)] if working else [], + ) + except (BadRequest, Forbidden) as error: + # Reactions are off in this chat, or the bot may not react in it. + # Refused now is refused for the rest of the turn. + logger.warning( + "Telegram will not %s the working reaction on %s in chat %s " + "(%s); the turn goes on without it.", + "add" if working else "remove", + message_id, + channel_id, + error, + ) + return + if working: + self._reacted.add(key) + else: + self._reacted.discard(key) + + async def notify_working( + self, channel_id: str, agent_name: str, thread_root_id: str | None + ) -> None: + """The one-shot typing nudge, where the agent was asked. + + Telegram expires it after about five seconds, so it costs the chat + nothing and it is the only signal that arrives before the first post. + Best effort by nature: the status carries the state from here on. + """ + try: + await self._require_bot().send_chat_action( + chat_id=self._chat_id(channel_id), action=ChatAction.TYPING + ) + except Exception as error: + logger.warning( + "Could not signal in Telegram chat %s that %s has started: %s.", + channel_id, + agent_name, + error, + ) + # ── Channels ───────────────────────────────────────────────────────────── async def create_channel( @@ -1960,13 +2514,46 @@ def _attribute(cls, sender_name: str, label: str, content: str) -> str: return name return f"{name}\n{content}" if "\n" in content else f"{name}: {content}" - @staticmethod - def _reply_kwargs(thread_root_id: str | None) -> dict[str, Any]: - """Anchor a post to its thread root. + async def _is_forum(self, channel_id: str) -> bool: + """Whether this chat splits into topics. + + The answer decides what a thread root *means* here, so it is read from + the chat rather than guessed from the ref: an inbound message carries + `message_thread_id` in a forum and a reply target everywhere else, and + both arrive as a bare number that says nothing about which it is. - `allow_sending_without_reply` matters: a root that has since been - deleted would otherwise fail the whole send, and a reply landing at the - chat root is much better than no message at all.""" + Cached per chat. A group is converted to a forum rarely and never back + and forth mid-turn, and the alternative is a getChat on every post. + A lookup that fails is not cached and not guessed at: it raises, and + the caller decides whether the post can proceed without an answer. + """ + known = self._forum_chats.get(channel_id) + if known is not None: + return known + chat = await self._require_bot().get_chat(self._chat_id(channel_id)) + is_forum = bool(getattr(chat, "is_forum", False)) + self._forum_chats[channel_id] = is_forum + return is_forum + + async def _anchor_kwargs( + self, channel_id: str, thread_root_id: str | None + ) -> dict[str, Any]: + """Anchor a post where the conversation it belongs to is. + + Two different things are spelled the same way. In a forum the root is + the topic, and a topic is addressed with `message_thread_id` β€” every + message in it carries that id, not the id of anything one of them + replied to. Everywhere else Telegram has no thread at all and the root + is a message to reply to. Sending one as the other is not a formatting + difference: a topic id used as a reply target is a reply to whichever + message happens to hold that number, and it lands in the General topic + the moment the topic's opening message is gone β€” so a card asked for in + one topic would be put to the whole group instead. + + A reply target that has since been deleted does not stop the send. + Detaching there costs the quote, not the audience: it is the same chat + either way, and a reply nobody can trace back beats no message at all. + """ if not thread_root_id: return {} try: @@ -1974,6 +2561,8 @@ def _reply_kwargs(thread_root_id: str | None) -> dict[str, Any]: except ValueError: logger.error("Ignoring unparseable Telegram thread root %s", thread_root_id) return {} + if await self._is_forum(channel_id): + return {"message_thread_id": root} return { "reply_parameters": ReplyParameters( message_id=root, allow_sending_without_reply=True @@ -2031,9 +2620,15 @@ async def _send_text( Returns the ref of the first message so an edit or delete targets the head of the run.""" bot = self._require_bot() + anchor = await self._anchor_kwargs(channel_id, thread_root_id) + # A topic is where the message lives, so every chunk carries it or the + # tail of a long answer lands in General. A reply target is a pointer + # at one message, and repeating it on each chunk would quote the same + # message several times over. + topic = "message_thread_id" in anchor first_ref: str | None = None for index, chunk in enumerate(chunk_message(body)): - kwargs = self._reply_kwargs(thread_root_id) if index == 0 else {} + kwargs = anchor if topic or index == 0 else {} sent = await self._send_chunk(bot, channel_id, chunk, kwargs) if sent is None: return first_ref diff --git a/core/switch_core/db/models.py b/core/switch_core/db/models.py index 0a4d6ba81..c0ac660c9 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -1466,6 +1466,16 @@ class SessionRequestPost(TenantScoped, Base): # it builds is one option or one per question, and it is read rather than # inferred. `session/form.py` is both ends of the shape. form: Mapped[dict] = mapped_column(JSONB, nullable=False) + # When the channel was told that this card's delivery was never confirmed + # and the request has to be answered in Console instead. Set only on a + # platform whose history cannot be searched, where `external_post_id` stuck + # at `token` is permanent rather than a state a later lookup resolves. It is + # what makes that notice happen once: a second one says nothing new, and the + # publisher retries this row on every cycle for as long as the request is + # open. + unconfirmed_notice_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/core/switch_core/migrations/versions/a9c4e7b21d63_request_post_unconfirmed_notice.py b/core/switch_core/migrations/versions/a9c4e7b21d63_request_post_unconfirmed_notice.py new file mode 100644 index 000000000..eae2232af --- /dev/null +++ b/core/switch_core/migrations/versions/a9c4e7b21d63_request_post_unconfirmed_notice.py @@ -0,0 +1,20 @@ +"""Record when a card's unconfirmed delivery was disclosed.""" + +import sqlalchemy as sa +from alembic import op + +revision = "a9c4e7b21d63" +down_revision = "a7b319ce2048" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "session_request_posts", + sa.Column("unconfirmed_notice_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("session_request_posts", "unconfirmed_notice_at") diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index b38ac646c..10652ee50 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -91,6 +91,7 @@ async def refresh_cards( session_id: str, cards: SessionRequestCards, *, + gateway_public_url: str | None = None, recovery_allowed: Callable[[str], bool] = _always_recover, recovery_succeeded: Callable[[str], None] = _ignore_recovery, refresh_needed: Callable[[str, tuple[int, str]], bool] = _always_refresh, @@ -117,6 +118,11 @@ async def refresh_cards( process, which has no memory to trust yet) get the defaults for both, which always act and track nothing: every confirmed card is compared against what is actually recorded for it, every time. + + `gateway_public_url` is here for one message: the notice sent when a card's + delivery can never be confirmed, which is only useful if it can say where + the request *can* be answered. It may be None, and then the notice names + Console without linking to it. """ posts = SessionRequestPostStore() async with session_factory() as db: @@ -194,6 +200,13 @@ async def refresh_cards( thread_id, recipient, asking and recipient is None and cards.notifies_only_by_mention, + deeplink_for_platform( + session_console_url( + gateway_public_url, agent.id, room.id, row.id + ), + gateway_public_url, + cards.renders_custom_url_schemes, + ), ) ) epoch = row.epoch @@ -209,6 +222,7 @@ async def refresh_cards( thread_id, recipient, unreachable, + console_url, ) in publications: state = ( request.revision, @@ -237,6 +251,16 @@ async def refresh_cards( ) refreshed(new_post.token, state) elif post.external_post_id == post.token: + if post.unconfirmed_notice_at is not None: + # Already disclosed as undeliverable. There is no message + # to edit and nothing further to try, and treating it as a + # failure again on every cycle would keep the session + # reporting an error that has already been dealt with as + # well as it can be. + continue + if not cards.recovers_uncertain_posts: + await cards.disclose_unconfirmed(post, console_url=console_url) + continue if not recovery_allowed(post.token): backed_off += 1 continue @@ -245,6 +269,7 @@ async def refresh_cards( await cards.refresh( post, request, + agent_name=agent_name, **( {"unavailable_reason": unavailable_reason} if unavailable_reason @@ -256,6 +281,7 @@ async def refresh_cards( await cards.refresh( post, request, + agent_name=agent_name, **( {"unavailable_reason": unavailable_reason} if unavailable_reason @@ -971,6 +997,7 @@ async def publish_pending(self) -> None: self._bridge_id, session_id, self._cards, + gateway_public_url=self._gateway_public_url, recovery_allowed=self._recovery.allowed, recovery_succeeded=self._recovery.succeeded, refresh_needed=self._redraw.needed, diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index ee6a1a6a6..cdbcc9cfe 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -590,7 +590,7 @@ async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> N with pytest.raises(RichContentFailed): await adapter.update_rich( - str(CHANNEL_ID), f"{ROOT_MESSAGE_ID}:901", await _card() + str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", await _card() ) @@ -600,7 +600,9 @@ async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> N async def test_a_running_turn_is_redrawn_in_place_inside_its_thread() -> None: adapter, _channel, _thread, webhook = _guild_setup() - await adapter.update_rich(str(CHANNEL_ID), f"{ROOT_MESSAGE_ID}:901", _activity()) + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", _activity() + ) assert webhook.deletes == [] assert webhook.edits[0]["message_id"] == 901 @@ -610,7 +612,9 @@ async def test_a_running_turn_is_redrawn_in_place_inside_its_thread() -> None: async def test_a_thread_keeps_the_finished_turn_as_its_record() -> None: adapter, _channel, _thread, webhook = _guild_setup() - await adapter.update_rich(str(CHANNEL_ID), f"{ROOT_MESSAGE_ID}:901", _ended()) + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", _ended() + ) assert webhook.deletes == [] assert webhook.edits[0]["message_id"] == 901 @@ -619,7 +623,9 @@ async def test_a_thread_keeps_the_finished_turn_as_its_record() -> None: async def test_a_flat_channel_loses_the_status_when_the_turn_ends() -> None: adapter, _channel, _thread, webhook = _guild_setup() - await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", _ended()) + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended() + ) assert webhook.edits == [] assert webhook.deletes[0]["message_id"] == 901 @@ -630,7 +636,9 @@ async def test_a_dm_loses_it_too_and_never_asks_for_a_webhook() -> None: adapter = _adapter({DM_CHANNEL_ID: dm}) dm.messages[501] = _Message(dm, 501) - await adapter.update_rich(str(DM_CHANNEL_ID), f"{DM_CHANNEL_ID}:501", _ended()) + await adapter.update_rich( + str(DM_CHANNEL_ID), "my-agent", f"{DM_CHANNEL_ID}:501", _ended() + ) assert dm.deleted_ids == [501] @@ -640,46 +648,26 @@ async def test_a_dm_redraw_writes_the_agent_name_back_into_the_body() -> None: adapter = _adapter({DM_CHANNEL_ID: dm}) ref = await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", _activity(), None) - await adapter.update_rich(str(DM_CHANNEL_ID), ref, _activity()) + await adapter.update_rich(str(DM_CHANNEL_ID), "my-agent", ref, _activity()) assert dm.messages[501].edited is not None assert dm.messages[501].edited.startswith("**my-agent**: ") -async def test_a_dm_redraw_reads_the_agent_name_back_off_the_message() -> None: - """The in-memory note of who posted what does not survive a restart. - - In a DM the name is in the body rather than on the sender, so the message - itself is the durable record β€” and a redraw that forgot it would republish - somebody's turn as the bot. +async def test_a_dm_redraw_still_names_the_agent_after_a_restart() -> None: + """In a DM the name is in the body rather than on the sender, so a redraw + that did not know it would republish somebody's turn as the bot. It comes + with the call, so nothing here depends on this process having posted it. """ dm = _DMChannel() adapter = _adapter({DM_CHANNEL_ID: dm}) - ref = await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", _activity(), None) - adapter._rich_agents.clear() - await adapter.update_rich(str(DM_CHANNEL_ID), ref, _activity()) - - assert dm.messages[501].edited is not None - assert dm.messages[501].edited.startswith("**my-agent**: ") + restarted = _adapter({DM_CHANNEL_ID: dm}) + await restarted.update_rich(str(DM_CHANNEL_ID), "my-agent", ref, _activity()) -async def test_a_dm_redraw_that_cannot_recover_the_name_says_so( - caplog: pytest.LogCaptureFixture, -) -> None: - """Better a turn published without a name than a silently wrong one.""" - dm = _DMChannel() - adapter = _adapter({DM_CHANNEL_ID: dm}) - dm.messages[501] = _Message(dm, 501, "no name here") - - with caplog.at_level(logging.WARNING): - await adapter.update_rich( - str(DM_CHANNEL_ID), f"{DM_CHANNEL_ID}:501", _activity() - ) - - assert "carries no agent name" in caplog.text assert dm.messages[501].edited is not None - assert not dm.messages[501].edited.startswith("**my-agent**") + assert dm.messages[501].edited.startswith("**my-agent**: ") async def test_a_settled_card_is_never_taken_down() -> None: @@ -687,7 +675,7 @@ async def test_a_settled_card_is_never_taken_down() -> None: adapter, _channel, _thread, webhook = _guild_setup() card = await _card() - await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", card) + await adapter.update_rich(str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", card) assert webhook.deletes == [] assert webhook.edits[0]["message_id"] == 901 @@ -700,7 +688,9 @@ async def test_a_status_that_cannot_be_removed_is_left_saying_what_happened( webhook.delete_error = _http_error(403) with caplog.at_level(logging.WARNING): - await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", _ended()) + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended() + ) assert "leaving its final state" in caplog.text assert webhook.edits[0]["message_id"] == 901 @@ -709,8 +699,12 @@ async def test_a_status_that_cannot_be_removed_is_left_saying_what_happened( async def test_redrawing_a_retired_status_is_not_reported_as_a_lost_message() -> None: adapter, _channel, _thread, webhook = _guild_setup() - await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", _ended()) - await adapter.update_rich(str(CHANNEL_ID), f"{CHANNEL_ID}:901", _ended()) + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended() + ) + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended() + ) assert len(webhook.deletes) == 1 assert webhook.edits == [] diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index 03b4a9781..0d24b4584 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -52,6 +52,10 @@ class _FakePosts: def __init__(self) -> None: self.created: list[dict[str, Any]] = [] self.patched: list[tuple[str, dict[str, Any]]] = [] + # Whose driver made each call, in order. One shared log across the + # drivers, because which bot Mattermost saw is the thing under test. + self.created_by: list[str] = [] + self.patched_by: list[str] = [] self.thread: dict[str, dict[str, Any]] = {} self.channel: dict[str, dict[str, Any]] = {} self.create_error: Exception | None = None @@ -140,9 +144,28 @@ def make_request( return {"status": "OK"} +class _DriverPosts: + """One driver's view of the shared post log, tagged with whose it is.""" + + def __init__(self, posts: _FakePosts, owner: str) -> None: + self._posts = posts + self._owner = owner + + def __getattr__(self, name: str) -> Any: + return getattr(self._posts, name) + + def create_post(self, post: dict[str, Any]) -> dict[str, str]: + self._posts.created_by.append(self._owner) + return self._posts.create_post(post) + + def patch_post(self, post_id: str, body: dict[str, Any]) -> dict[str, str]: + self._posts.patched_by.append(self._owner) + return self._posts.patch_post(post_id, body) + + class _FakeDriver: - def __init__(self, posts: _FakePosts, users: _FakeUsers) -> None: - self.posts = posts + def __init__(self, posts: _FakePosts, users: _FakeUsers, owner: str) -> None: + self.posts = _DriverPosts(posts, owner) self.users = users self.reactions = _FakeReactions() self.client = _FakeClient() @@ -161,16 +184,16 @@ def _adapter(*agents: str, **users: str) -> MattermostAdapter: directory = _FakeUsers(**users) for name in agents or ("worker",): adapter._agent_bots[name] = {"user_id": f"bot-{name}"} - adapter._bot_drivers[name] = _FakeDriver(posts, directory) # type: ignore[assignment] + adapter._bot_drivers[name] = _FakeDriver(posts, directory, name) # type: ignore[assignment] adapter._bridge_bot_ids.add(f"bot-{name}") - adapter._admin_driver = _FakeDriver(posts, directory) # type: ignore[assignment] + adapter._admin_driver = _FakeDriver(posts, directory, "admin") # type: ignore[assignment] adapter._main_loop = asyncio.get_event_loop() return adapter def _posts(adapter: MattermostAdapter) -> _FakePosts: driver: Any = adapter._admin_driver - return driver.posts + return driver.posts._posts def _users(adapter: MattermostAdapter) -> _FakeUsers: @@ -228,7 +251,8 @@ async def test_a_publication_is_posted_by_the_agents_own_bot_in_its_thread() -> assert len(created) == 1 assert created[0]["channel_id"] == "chan-1" assert created[0]["root_id"] == "root-1" - assert adapter._rich_authors[ref] == "worker" + assert _posts(adapter).created_by == ["worker"] + assert ref async def test_the_recovery_marker_travels_in_props_where_no_reader_sees_it() -> None: @@ -349,10 +373,22 @@ async def test_a_redraw_is_patched_by_the_bot_that_posted_it() -> None: adapter = _adapter("worker", "other") ref = await adapter.post_rich("chan-1", "worker", _activity()) - await adapter.update_rich("chan-1", ref, _activity()) + await adapter.update_rich("chan-1", "worker", ref, _activity()) - driver: Any = adapter._bot_drivers["worker"] - assert driver.posts.patched[0][0] == ref + assert _posts(adapter).patched[0][0] == ref + assert _posts(adapter).patched_by == ["worker"] + + +async def test_a_redraw_is_still_the_agents_own_bot_after_a_restart() -> None: + """The name comes with the call, so nothing about a redraw depends on this + process having been the one that posted the card.""" + adapter = _adapter("worker", "other") + ref = await adapter.post_rich("chan-1", "worker", _activity()) + + restarted = _adapter("worker", "other") + await restarted.update_rich("chan-1", "worker", ref, _activity()) + + assert _posts(restarted).patched_by == ["worker"] async def test_a_failed_redraw_raises_rather_than_leaving_a_stale_card() -> None: @@ -363,7 +399,7 @@ async def test_a_failed_redraw_raises_rather_than_leaving_a_stale_card() -> None _posts(adapter).patch_error = ResourceNotFound("404 post not found") with pytest.raises(RichContentFailed) as excinfo: - await adapter.update_rich("chan-1", ref, await _card()) + await adapter.update_rich("chan-1", "worker", ref, await _card()) assert isinstance(excinfo.value.__cause__, ResourceNotFound) assert excinfo.value.text @@ -378,7 +414,7 @@ async def test_a_redraw_that_may_have_landed_is_not_reported_as_refused() -> Non _posts(adapter).patch_error = _http_error(503) with pytest.raises(requests.HTTPError): - await adapter.update_rich("chan-1", ref, await _card()) + await adapter.update_rich("chan-1", "worker", ref, await _card()) async def test_a_rate_limited_redraw_carries_the_wait_back_to_the_caller() -> None: @@ -387,7 +423,7 @@ async def test_a_rate_limited_redraw_carries_the_wait_back_to_the_caller() -> No _posts(adapter).patch_error = _http_error(429, **{"Retry-After": "8"}) with pytest.raises(RichContentThrottled) as excinfo: - await adapter.update_rich("chan-1", ref, await _card()) + await adapter.update_rich("chan-1", "worker", ref, await _card()) assert excinfo.value.retry_after == 8 @@ -400,7 +436,7 @@ async def test_a_redraw_does_not_mention_the_recipient_a_second_time() -> None: ref = await adapter.post_rich("chan-1", "worker", card) assert "@owner" in _posts(adapter).created[0]["message"] - await adapter.update_rich("chan-1", ref, card) + await adapter.update_rich("chan-1", "worker", ref, card) assert "@owner" not in _posts(adapter).patched[0][1]["message"] diff --git a/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py b/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py index c6b81c814..9c8e0df79 100644 --- a/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py +++ b/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py @@ -162,7 +162,7 @@ async def test_update_rich_falls_back_the_same_way() -> None: items = [_item(kind="assistant-message", title="", text="Looking now.")] turn = _turn("completed") - await adapter.update_rich("C1", "C1:1.0", TurnActivity(items, turn)) + await adapter.update_rich("C1", "agent", "C1:1.0", TurnActivity(items, turn)) assert len(adapter.updated) == 1 channel_id, message_ref, content = adapter.updated[0] @@ -182,7 +182,9 @@ async def test_update_rich_does_not_raise_when_the_platform_only_swallows() -> N adapter = _BareAdapter() items = [_item(kind="assistant-message", title="", text="Looking now.")] - await adapter.update_rich("C1", "C1:1.0", TurnActivity(items, _turn("completed"))) + await adapter.update_rich( + "C1", "agent", "C1:1.0", TurnActivity(items, _turn("completed")) + ) async def test_update_rich_raises_when_the_platform_raises() -> None: @@ -199,7 +201,7 @@ def _explode(_content: str) -> None: with pytest.raises(RichContentFailed) as excinfo: await adapter.update_rich( - "C1", "C1:1.0", TurnActivity(items, _turn("completed")) + "C1", "agent", "C1:1.0", TurnActivity(items, _turn("completed")) ) assert isinstance(excinfo.value.__cause__, RuntimeError) 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 b416a8cf3..e8d1970a0 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 @@ -10,8 +10,12 @@ deleted, and the message the move posted is referenced by nothing β€” so the end-of-turn clear cannot remove it and it stays in the channel forever. -Telegram still uses this shared runtime-indicator path. Slack now uses SDK -publication and does not participate in these races. +Every platform that publishes SDK sessions has left this path, so the races are +reproduced against a Telegram adapter with the legacy indicator switched back +on. The path itself is shared and still live β€” Teams renders its runtime state +through exactly this locking β€” and Telegram remains the adapter whose +implementation exercises it most directly, so this is a fixture for a real +defect rather than a test of dead code. The invariant each test asserts is the same: whatever is still posted on the platform is exactly what the adapter thinks is posted. @@ -21,7 +25,7 @@ import asyncio import time -from typing import Any +from typing import Any, ClassVar from switch_core.bridges.collaboration.adapter import LiveRuntimeIndicator from switch_core.bridges.collaboration.telegram.adapter import ( @@ -29,6 +33,20 @@ TelegramConnectionConfig, ) + +class _LegacyIndicator(TelegramAdapter): + """Telegram with the legacy runtime indicator still switched on. + + The lock these tests are about lives in the public `apply_runtime_state` / + `reposition_runtime_state`, above the flag that now turns the whole path + off β€” so calling the adapter's own `_apply_runtime_state` instead would + bypass the very thing under test. Re-enabling the flag keeps both callers + going through the real entry point. + """ + + renders_legacy_runtime_state: ClassVar[bool] = True + + CHANNEL = "chan-1" AGENT = "worker" KEY = (CHANNEL, AGENT) @@ -73,7 +91,7 @@ async def delete_message(channel_id: str, message_ref: str) -> None: def _adapter() -> tuple[TelegramAdapter, _Platform]: - adapter = TelegramAdapter( + adapter = _LegacyIndicator( config=TelegramConnectionConfig(bot_token="test", bot_username="test_bot") ) adapter._working_msg[KEY] = LiveRuntimeIndicator( diff --git a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py index 52b6b8321..780a305e3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py @@ -520,7 +520,7 @@ async def test_a_redrawn_card_is_answered_at_the_revision_it_now_shows( cards, bridge_id, room_id = await _cards(session_factory, client) post = await _post_one(cards, room_id) - await cards.refresh(post, await _revised_request()) + await cards.refresh(post, await _revised_request(), agent_name="agent") command = await _interactions(session_factory, bridge_id).command_for_text( InboundMessage( @@ -556,7 +556,7 @@ async def test_a_redraw_slack_refused_leaves_the_row_on_what_is_on_screen( client.update_error = "message_not_found" with pytest.raises(RichContentFailed): - await cards.refresh(post, await _revised_request()) + await cards.refresh(post, await _revised_request(), agent_name="agent") async with session_factory() as session: row = await SessionRequestPostStore().get_by_token( diff --git a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py index 9f5d62118..6834d929e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py @@ -121,7 +121,7 @@ async def test_attention_post_mentions_once_and_edit_removes_mention(): assert client.posted[0]["text"].startswith("<@UOWNER>") assert client.posted[0]["thread_ts"] == "root" assert not client.posted[0].get("reply_broadcast") - await adapter.update_rich("C1", ref, content) + await adapter.update_rich("C1", "Agent", ref, content) assert "<@UOWNER>" not in client.updated[0]["text"] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py index a255cac36..cea614907 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py @@ -9,7 +9,10 @@ from __future__ import annotations -from switch_core.bridges.collaboration.session.renderers import RequestReference +from switch_core.bridges.collaboration.session.renderers import ( + MARKDOWN, + RequestReference, +) from switch_core.bridges.collaboration.session.renderers.neutral import request_summary from switch_core.sessions.contract import ( ApprovalContent, @@ -76,7 +79,9 @@ def _option(option_id: str, label: str, decision: str = "accept") -> ApprovalOpt def _render(request: SnapshotRequest, *, limit: int = 4000) -> str: - return request_summary(request, REFERENCE, escape=_identity, limit=limit) + return request_summary( + request, REFERENCE, escape=_identity, limit=limit, markup=MARKDOWN + ) def test_a_permission_label_is_shown_whole_rather_than_cut_to_a_short_ceiling(): diff --git a/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py index 50886370e..a67c51a91 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py @@ -323,7 +323,7 @@ async def test_the_card_is_edited_in_place_rather_than_reposted() -> None: adapter, client = _adapter() post = _post() - await _cards(adapter, post).refresh(post, request) + await _cards(adapter, post).refresh(post, request, agent_name="agent") assert len(client.updated) == 1 edit = client.updated[0] @@ -361,7 +361,7 @@ async def test_a_failed_edit_puts_the_outcome_in_the_thread_instead() -> None: cards = _cards(adapter, post) for _ in range(2): with pytest.raises(RichContentFailed): - await cards.refresh(post, request) + await cards.refresh(post, request, agent_name="agent") assert client.updated == [] assert len(client.posted) == 1 @@ -382,6 +382,7 @@ async def test_resolved_plan_uses_display_name_and_keeps_slack_mention_in_detail ) await adapter.update_rich( "C1", + "agent", "C1:111.0", RequestCard(request, REFERENCE, responder_external_id="UOWNER123"), ) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py b/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py index ba4f71a15..d5b9c1938 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py @@ -136,14 +136,14 @@ async def test_slack_update_cooldown_honors_retry_after_across_messages(monkeypa monkeypatch.setattr(adapter, "update_blocks", update) content = TurnActivity([], _turn(), status_only=True) with pytest.raises(RichContentThrottled) as first: - await adapter.update_rich("C1", "C1:1", content) + await adapter.update_rich("C1", "Agent", "C1:1", content) assert first.value.retry_after == 17 clock[0] += 16 with pytest.raises(RichContentThrottled): - await adapter.update_rich("C1", "C1:2", content) + await adapter.update_rich("C1", "Agent", "C1:2", content) assert update.await_count == 1 clock[0] += 1 - await adapter.update_rich("C1", "C1:2", content) + await adapter.update_rich("C1", "Agent", "C1:2", content) assert update.await_count == 2 diff --git a/core/tests/switch_core/bridges/collaboration/test_session_text_answers.py b/core/tests/switch_core/bridges/collaboration/test_session_text_answers.py index a4a3b53ed..f7d6a8b30 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_text_answers.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_text_answers.py @@ -30,6 +30,7 @@ from .test_session_answers import ( EXAMPLES_PATH, + TOKEN, _approval_form, _interactions, _post, @@ -232,6 +233,20 @@ def test_a_number_the_card_has_no_option_at_answers_nothing() -> None: assert isinstance(_run(interactions.command_for_text(_typed("R42 9"))), Refused) +def test_a_card_whose_delivery_was_never_confirmed_answers_nothing() -> None: + """A reservation still holding its own token is a card nothing can prove. + + It may be in the channel and it may not, and an answer accepted against a + card that was never posted decides something nobody asked. On a platform + that can search its history the reservation is bound to the real message + and this stops applying; on one that cannot, the channel is told to answer + in Console instead β€” the notice is the way out, not a loosening of this. + """ + interactions = _interactions(_post(external_post_id=TOKEN)) + + assert _run(interactions.command_for_text(_typed("R42 1"))) is None + + def test_a_handle_from_another_channel_names_no_request_here() -> None: """A handle is only unambiguous as far as a person can see, so no further.""" interactions = _interactions(_post(external_channel_id="C2")) diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py index 9d7a27cb7..d3efaddff 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py @@ -116,9 +116,9 @@ async def test_activity_layout_is_an_adapter_capability_not_a_slack_type_check() status, log = platform.post_rich.call_args_list assert status.args[2].status_only assert log.args[2].tool_log - assert [call.args[1] for call in platform.update_rich.call_args_list] == [ - "C1:status", - "C1:log", + assert [call.args[1:3] for call in platform.update_rich.call_args_list] == [ + ("worker", "C1:status"), + ("worker", "C1:log"), ] assert [ call.kwargs["working"] for call in platform.mark_activity.call_args_list diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py index 34a7cf139..bc0e86cd3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py @@ -46,11 +46,13 @@ def __init__( chat_type: str = "supergroup", title: str | None = "general", username: str | None = None, + is_forum: bool = False, ) -> None: self.id = chat_id self.type = chat_type self.title = title self.username = username + self.is_forum = is_forum class _FakeUser: @@ -163,6 +165,8 @@ def __init__(self) -> None: self.send_message_error: Exception | None = None self.send_photo_error: Exception | None = None self.send_album_error: Exception | None = None + # Set to an exception to make the next edit_message_text raise it once. + self.edit_error: Exception | None = None def _mint(self, chat_id: Any) -> _FakeSentMessage: self._next_id += 1 @@ -197,6 +201,10 @@ async def send_media_group(self, **kwargs: Any) -> list[_FakeSentMessage]: return [self._mint(kwargs["chat_id"]) for _ in kwargs["media"]] async def edit_message_text(self, **kwargs: Any) -> None: + if self.edit_error is not None: + error = self.edit_error + self.edit_error = None + raise error self.edits.append(kwargs) async def delete_message(self, **kwargs: Any) -> None: @@ -1141,12 +1149,20 @@ def test_a_rejected_album_still_delivers_the_files() -> None: # ── Runtime state ──────────────────────────────────────────────────────────── +# These drive `_apply_runtime_state` rather than the public entry point because +# the public one no longer reaches it: Telegram now publishes SDK sessions and +# declares `renders_legacy_runtime_state = False`, so the base class stops the +# legacy path before the adapter sees it. The implementation is still here and +# still correct; what it no longer has is a caller. Removing it is its own task +# β€” until then these keep it honest, and `test_telegram_sdk_only.py` covers +# what replaced it. + def test_working_posts_a_status_message() -> None: adapter = _adapter() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) @@ -1158,13 +1174,13 @@ def test_working_posts_a_status_message() -> None: def test_working_again_edits_the_status_rather_than_reposting() -> None: adapter = _adapter() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", @@ -1182,7 +1198,7 @@ def test_awaiting_input_pings_the_operator() -> None: adapter = _adapter() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "awaiting-input", @@ -1198,12 +1214,12 @@ def test_awaiting_input_pings_the_operator() -> None: def test_going_idle_removes_the_status_and_the_pings() -> None: adapter = _adapter() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "awaiting-input", @@ -1213,7 +1229,7 @@ def test_going_idle_removes_the_status_and_the_pings() -> None: ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "idle", mention_handle=None, thread_root_id=None ) ) @@ -1228,13 +1244,13 @@ def test_the_status_message_follows_the_conversation() -> None: # the indicator β€” this asserts Telegram does. adapter = _adapter() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) original = adapter._working_msg[(str(CHAT_ID), "scout")].message_ref - _run(adapter.reposition_runtime_state(str(CHAT_ID), "scout", "88")) + _run(adapter._reposition_runtime_state(str(CHAT_ID), "scout", "88")) moved = adapter._working_msg[(str(CHAT_ID), "scout")] assert moved.message_ref != original @@ -1261,7 +1277,7 @@ def test_working_puts_the_eyes_on_the_message_that_asked() -> None: _ask(adapter, message_id=11) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) @@ -1274,13 +1290,13 @@ def test_the_eyes_come_off_when_the_turn_ends() -> None: adapter = _adapter() _ask(adapter, message_id=11) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "idle", mention_handle=None, thread_root_id=None ) ) @@ -1295,7 +1311,7 @@ def test_the_eyes_go_up_once_however_often_the_activity_changes() -> None: for detail in ("reading", "editing", "running tests"): _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", @@ -1313,13 +1329,13 @@ def test_the_eyes_stay_up_while_the_agent_waits_for_input() -> None: adapter = _adapter() _ask(adapter, message_id=11) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "awaiting-input", @@ -1337,19 +1353,19 @@ def test_the_eyes_follow_the_newest_question_in_the_chat() -> None: adapter = _adapter() _ask(adapter, message_id=11) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "idle", mention_handle=None, thread_root_id=None ) ) _ask(adapter, message_id=12) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) @@ -1363,7 +1379,7 @@ def test_a_reply_is_marked_on_itself_not_on_what_it_replied_to() -> None: _ask(adapter, message_id=12, reply_to_message=_FakeInbound(message_id=11)) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id="11" ) ) @@ -1379,14 +1395,14 @@ def test_every_message_an_agent_marked_is_cleared_by_the_one_turn_ending() -> No _ask(adapter, message_id=21, chat=_FakeChat(chat_id=-100999)) for channel in (str(CHAT_ID), other): _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( channel, "scout", "working", mention_handle=None, thread_root_id=None ) ) for channel in (str(CHAT_ID), other): _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( channel, "scout", "idle", mention_handle=None, thread_root_id=None ) ) @@ -1400,7 +1416,7 @@ def test_a_chat_that_never_spoke_is_not_reacted_to() -> None: adapter = _adapter() _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None ) ) @@ -1419,7 +1435,7 @@ def test_a_refused_reaction_is_logged_and_the_turn_carries_on( with caplog.at_level(logging.WARNING): _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( str(CHAT_ID), "scout", "working", diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py new file mode 100644 index 000000000..45d58f071 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -0,0 +1,575 @@ +"""Telegram publishes SDK sessions, and the legacy renderer no longer runs. + +What is under test here is the rich-content seam on the one platform with no +per-message identity at all: the compact status and the request card, drawn as +HTML rather than Markdown, attributed in the body because a bot cannot be +attributed anywhere else, anchored to a forum topic or to a reply target +depending on what the chat actually is, edited in place, paced under Telegram's +own limits, and loud when any of that fails. + +The old runtime-state renderer is still in the file (removing it is its own +task) but nothing routes to it any more. The first test holds that line: the +two renderers must not both draw, or every turn appears twice. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from telegram.error import ( + BadRequest, + ChatMigrated, + Forbidden, + NetworkError, + RetryAfter, + TimedOut, +) + +from switch_core.bridges.collaboration.adapter import ( + CollaborationAdapter, + RequestCard, + RichContentFailed, + RichContentThrottled, + TurnActivity, +) +from switch_core.bridges.collaboration.session.renderers import RequestReference +from switch_core.bridges.collaboration.session.transport import ( + FixtureEventSource, + project, +) +from switch_core.bridges.collaboration.telegram.adapter import ( + _REDRAW_INTERVAL, + TelegramAdapter, +) + +from .test_session_activity import _item, _turn +from .test_telegram_adapter import CHAT_ID, _adapter, _bot + +REPO_ROOT = Path(__file__).resolve().parents[5] +EXAMPLES_PATH = REPO_ROOT / "console/packages/shared/src/session-v1/examples.json" + +CHANNEL = str(CHAT_ID) +TOPIC_ID = "88" +ASKER_ID = 60606 +ASKER = str(ASKER_ID) + + +def _activity(**kwargs: Any) -> TurnActivity: + items = [_item(kind="assistant-message", title="", text="Looking now.")] + return TurnActivity(items, _turn("running"), **kwargs) + + +def _ended(**kwargs: Any) -> TurnActivity: + items = [_item(kind="assistant-message", title="", text="Done.")] + return TurnActivity(items, _turn("completed"), **kwargs) + + +async def _card(**kwargs: Any) -> RequestCard: + source = FixtureEventSource.from_examples(EXAMPLES_PATH, events=[]) + projection = await project(source, "session-demo") + request = projection.open_requests()[0] + return RequestCard(request, RequestReference(token="tok-1", handle="R7"), **kwargs) + + +def _forum(adapter: TelegramAdapter) -> None: + _bot(adapter).chat.is_forum = True + + +def _posted(adapter: TelegramAdapter) -> dict[str, Any]: + return _bot(adapter).messages[0] + + +def _edited(adapter: TelegramAdapter) -> dict[str, Any]: + return _bot(adapter).edits[-1] + + +# ── The legacy renderer is off ─────────────────────────────────────────────── + + +async def test_the_legacy_renderer_no_longer_draws_alongside_the_sdk_one() -> None: + """Both would draw the same turn, and the chat would show it twice.""" + adapter = _adapter() + + for state in ("working", "awaiting-input", "idle"): + await adapter.apply_runtime_state( + CHANNEL, + "my-agent", + state, + mention_handle="someone", + thread_root_id=None, + ) + await adapter.reposition_runtime_state(CHANNEL, "my-agent", None) + + assert _bot(adapter).messages == [] + assert _bot(adapter).edits == [] + assert adapter.renders_legacy_runtime_state is False + assert adapter.publishes_sdk_sessions is True + + +def test_telegram_notifies_a_chat_without_anybody_being_named() -> None: + """Everyone in a Telegram chat is told about a new message, so a mention is + emphasis rather than the only route to a reader.""" + adapter = _adapter() + + assert adapter.notifies_only_by_mention is False + assert adapter.separate_attention_slot is True + assert adapter.separate_activity_log is False + assert adapter.redraws_for_elapsed_time is False + assert adapter.supports_activity_reactions is True + assert adapter.activity_reactions_per_agent is False + + +# ── The body is HTML, and it carries the agent's name ──────────────────────── + + +async def test_a_status_is_drawn_as_html_because_telegram_parses_no_markdown() -> None: + """`**Working…**` would reach a reader as those four characters.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + text = _posted(adapter)["text"] + assert _posted(adapter)["parse_mode"] == "HTML" + assert "" in text + assert "**" not in text + + +async def test_every_publication_says_which_agent_it_is() -> None: + """One bot posts for all of them, so the name in the body is the only thing + telling two agents in a chat apart.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + assert "my-agent" in _posted(adapter)["text"] + + +async def test_a_redraw_still_names_the_agent_after_a_restart() -> None: + """The name comes with the call, so nothing about a redraw depends on this + process having been the one that posted.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + restarted = _adapter() + await restarted.update_rich(CHANNEL, "my-agent", ref, _activity()) + + assert "my-agent" in _edited(restarted)["text"] + + +async def test_a_card_prints_the_handle_it_answers_to_in_a_code_span() -> None: + """A reader is meant to copy it, and Telegram makes a `` span + tap-to-copy β€” backticks would just be backticks.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + text = _posted(adapter)["text"] + assert "request R7" in text + assert "`R7`" not in text + + +async def test_a_console_deeplink_is_offered_as_text_rather_than_a_dead_link() -> None: + """Telegram renders an anchor only for the schemes it knows. A + `switchdash://` one is rejected outright or silently stripped of its + address, so the address itself is shown instead.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, + "my-agent", + _activity(session_url="switchdash://session/abc"), + None, + ) + + text = _posted(adapter)["text"] + assert "switchdash://session/abc" in text + assert " None: + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, + "my-agent", + _activity(session_url="https://switch.example/s/abc"), + None, + ) + + assert '' in _posted(adapter)["text"] + + +# ── Naming the person who asked ────────────────────────────────────────────── + + +async def test_the_asker_is_mentioned_by_id_so_a_private_account_is_reached() -> None: + """A bare `@handle` only notifies an account that has a public one; + `tg://user?id=` notifies either way.""" + adapter = _adapter() + adapter._user_names[ASKER_ID] = "alice" + + await adapter.post_rich( + CHANNEL, "my-agent", await _card(notify_external_id=ASKER), None + ) + + text = _posted(adapter)["text"] + assert f'@alice' in text + + +async def test_an_account_this_bridge_has_never_seen_is_not_given_a_made_up_name() -> ( + None +): + """A `tg://user` anchor needs visible text, and the only honest text is a + name the account has actually used here. Nothing is lost by leaving it out: + the chat is notified of the message regardless.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, "my-agent", await _card(notify_external_id=ASKER), None + ) + + text = _posted(adapter)["text"] + assert "tg://user" not in text + assert "@" not in text + + +async def test_a_redraw_does_not_repeat_the_mention() -> None: + """An edit does not notify, so a handle added on every redraw is a handle + that reaches nobody it has not already reached.""" + adapter = _adapter() + adapter._user_names[ASKER_ID] = "alice" + ref = await adapter.post_rich( + CHANNEL, "my-agent", await _card(notify_external_id=ASKER), None + ) + + await adapter.update_rich( + CHANNEL, "my-agent", ref, await _card(notify_external_id=ASKER) + ) + + assert "tg://user" in _posted(adapter)["text"] + assert "tg://user" not in _edited(adapter)["text"] + + +# ── A topic and a reply target are not the same thing ──────────────────────── + + +async def test_a_card_in_a_forum_is_addressed_to_its_topic() -> None: + """In a forum the root is the topic. Sent as a reply target instead, the + card replies to whichever message happens to hold that number and lands in + General the moment the topic's opening message is gone β€” which is the + agent's question put to the whole group rather than to the people in it.""" + adapter = _adapter() + _forum(adapter) + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), TOPIC_ID) + + assert _posted(adapter)["message_thread_id"] == int(TOPIC_ID) + assert "reply_parameters" not in _posted(adapter) + + +async def test_a_card_in_an_ordinary_group_replies_to_what_was_asked() -> None: + """Outside a forum Telegram has no thread, so the root is a message.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), TOPIC_ID) + + assert "message_thread_id" not in _posted(adapter) + assert _posted(adapter)["reply_parameters"].message_id == int(TOPIC_ID) + + +async def test_the_chat_is_asked_once_rather_than_on_every_publication() -> None: + adapter = _adapter() + calls: list[Any] = [] + original = _bot(adapter).get_chat + + async def counted(chat_id: Any) -> Any: + calls.append(chat_id) + return await original(chat_id) + + _bot(adapter).get_chat = counted # type: ignore[method-assign] + + await adapter.post_rich(CHANNEL, "my-agent", _activity(), TOPIC_ID) + await adapter.post_rich(CHANNEL, "my-agent", _activity(), TOPIC_ID) + + assert len(calls) == 1 + + +async def test_a_chat_that_will_not_say_what_it_is_keeps_its_reservation() -> None: + """Guessing would put a card in the wrong topic, and treating the lookup as + a refusal would let the caller repost a question it may already have + asked.""" + adapter = _adapter() + + async def refuse(chat_id: Any) -> Any: + raise TimedOut() + + _bot(adapter).get_chat = refuse # type: ignore[method-assign] + + with pytest.raises(TimedOut): + await adapter.post_rich(CHANNEL, "my-agent", await _card(), TOPIC_ID) + assert _bot(adapter).messages == [] + + +# ── A send whose outcome is unknown ────────────────────────────────────────── + + +def test_telegram_says_it_cannot_find_a_card_again_rather_than_implying_it_might() -> ( + None +): + """A bot cannot read a chat's history, so `find_request_card` has nowhere + to look and always answers None. The flag is what tells the publisher that + the None is permanent: waiting for a later lookup to succeed would keep a + question in the chat that silently refuses the answer it asks for.""" + assert TelegramAdapter.recovers_uncertain_posts is False + assert ( + TelegramAdapter.find_request_card is CollaborationAdapter.find_request_card # noqa: E501 β€” the base's "nowhere to look" + ) + + +async def test_the_notice_pointing_at_console_is_sent_as_telegram_html() -> None: + """The one message that goes out when a card cannot be confirmed. + + It is written as Switch Markdown by the publisher, like every other admin + notice, so the link has to survive the conversion or it reaches the chat + with its brackets showing on the one platform where the raw deeplink would + not have been a link at all. + """ + adapter = _adapter() + + await adapter.admin_message( + CHANNEL, + "Switch could not confirm that request **R1** reached this chat. " + "Answer it in [Switch Console](https://switch.example/deeplink/session?x=1) " + "instead.", + TOPIC_ID, + ) + + sent = _bot(adapter).messages[0]["text"] + assert "R1" in sent + assert '' in sent + assert "**" not in sent and "[" not in sent + + +# ── Failing loudly, and only where Telegram actually refused ───────────────── + + +async def test_a_refusal_is_reported_as_one_so_the_reservation_is_dropped() -> None: + adapter = _adapter() + _bot(adapter).send_message_error = BadRequest("chat not found") + + with pytest.raises(RichContentFailed): + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + +async def test_a_bad_request_is_a_refusal_even_though_it_is_a_network_error() -> None: + """python-telegram-bot makes `BadRequest` a subclass of `NetworkError`. An + uncertain-outcome branch written first would swallow every rejection + Telegram actually made and hold the reservation open forever.""" + assert issubclass(BadRequest, NetworkError) + adapter = _adapter() + _bot(adapter).send_message_error = BadRequest("message text is empty") + + with pytest.raises(RichContentFailed) as caught: + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + assert not isinstance(caught.value, RichContentThrottled) + + +@pytest.mark.parametrize( + "error", + [Forbidden("bot was kicked"), ChatMigrated(new_chat_id=-100999)], + ids=["forbidden", "migrated"], +) +async def test_the_other_definite_refusals_are_refusals_too(error: Exception) -> None: + adapter = _adapter() + _bot(adapter).send_message_error = error + + with pytest.raises(RichContentFailed): + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + +@pytest.mark.parametrize( + "error", + [TimedOut(), NetworkError("connection reset")], + ids=["timeout", "network"], +) +async def test_an_unknown_outcome_keeps_its_reservation_by_raising_itself( + error: Exception, +) -> None: + """The send may have landed and the response been lost. Reported as a + refusal, the caller would discard the reservation and ask again.""" + adapter = _adapter() + _bot(adapter).send_message_error = error + + with pytest.raises(type(error)): + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + +async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> None: + """A card that failed to redraw is still showing a settled request as open, + and the caller has a reply to post about that β€” but only if it is told.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + _bot(adapter).edit_error = BadRequest("message to edit not found") + + with pytest.raises(RichContentFailed): + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card()) + + +async def test_an_edit_telegram_calls_unchanged_is_not_a_failure() -> None: + """ "message is not modified" means the chat already shows what was asked + for, which is the outcome the caller wanted.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + _bot(adapter).edit_error = BadRequest("Message is not modified") + + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + + +async def test_a_publication_is_never_retried_as_stripped_plain_text() -> None: + """`_send_chunk` does that for relayed host text, where losing the markup + beats losing the message. Here the markup is Switch's own, and a silent + downgrade would leave the publisher believing the card posted properly.""" + adapter = _adapter() + _bot(adapter).send_message_error = BadRequest("can't parse entities") + + with pytest.raises(RichContentFailed): + await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + assert _bot(adapter).messages == [] + + +# ── Pacing, so a turn does not spend the chat's whole allowance ────────────── + + +async def test_being_rate_limited_says_how_long_to_wait() -> None: + adapter = _adapter() + _bot(adapter).send_message_error = RetryAfter(17) + + with pytest.raises(RichContentThrottled) as caught: + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + assert caught.value.retry_after == 17 + + +async def test_a_429_pauses_the_whole_chat_rather_than_the_one_message() -> None: + """Telegram charges the limit to the chat, so the next publication in it + waits rather than discovering the same thing for itself.""" + adapter = _adapter() + _bot(adapter).send_message_error = RetryAfter(17) + with pytest.raises(RichContentThrottled): + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + with pytest.raises(RichContentThrottled): + await adapter.post_rich(CHANNEL, "other-agent", await _card(), None) + assert _bot(adapter).messages == [] + + +async def test_progress_arriving_faster_than_the_chat_can_take_it_waits() -> None: + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + with pytest.raises(RichContentThrottled) as caught: + await adapter.update_rich(CHANNEL, "my-agent", ref, _activity()) + assert 0 < caught.value.retry_after <= _REDRAW_INTERVAL + assert _bot(adapter).edits == [] + + +async def test_the_end_of_a_turn_is_never_held_back() -> None: + """A reader waiting on the outcome is waiting on precisely the thing the + pacing would delay.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + + assert len(_bot(adapter).edits) == 1 + + +async def test_a_problem_somebody_has_to_act_on_is_never_held_back() -> None: + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + await adapter.update_rich( + CHANNEL, "my-agent", ref, _activity(error_summary="The agent went away.") + ) + + assert "went away" in _edited(adapter)["text"] + + +async def test_a_card_is_never_held_back() -> None: + """A settled card showing as open is wrong in a way no reader can tell from + a card that is simply slow.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card()) + + assert len(_bot(adapter).edits) == 1 + + +# ── The working reaction ───────────────────────────────────────────────────── + + +async def test_one_mark_is_shared_between_agents_and_not_added_twice() -> None: + """Every agent reacts through the one bot account, and Telegram allows it + one reaction per message.""" + adapter = _adapter() + + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + ) + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="two", working=True + ) + + assert len(_bot(adapter).reactions) == 1 + + +async def test_force_marks_again_because_the_record_may_be_empty_and_wrong() -> None: + """After a restart this process knows nothing about what is already on the + message, which is not the same as knowing there is nothing.""" + adapter = _adapter() + + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True, force=True + ) + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True, force=True + ) + + assert len(_bot(adapter).reactions) == 2 + + +async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> None: + """The publisher records the turn as drawn only once the chat shows what it + says it shows.""" + adapter = _adapter() + _bot(adapter).reaction_error = TimedOut() + + with pytest.raises(TimedOut): + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + ) + + +async def test_a_chat_with_reactions_off_is_not_retried_for_the_whole_turn( + caplog: pytest.LogCaptureFixture, +) -> None: + """Refused now is refused every time, so it is said once and the turn goes + on without the mark.""" + adapter = _adapter() + _bot(adapter).reaction_error = BadRequest("REACTION_INVALID") + + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + ) + + assert any("without it" in record.message for record in caplog.records) + + +async def test_the_typing_nudge_is_sent_where_the_agent_was_asked() -> None: + adapter = _adapter() + + await adapter.notify_working(CHANNEL, "my-agent", None) + + assert _bot(adapter).actions[0]["chat_id"] == CHAT_ID diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 8e872e429..8c042e084 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -49,7 +49,7 @@ async def post_rich(self, channel, agent, content, thread): raise TimeoutError("Response lost after Slack accepted the post") return ref - async def update_rich(self, channel, ref, content): + async def update_rich(self, channel, agent, ref, content): assert ref in self.messages self.edit_refs.append(ref) self.messages[ref] = self._render_rich(content) @@ -70,6 +70,18 @@ async def mark_activity(self, channel, ref, *, agent_name, working, force=False) self.reactions.discard(ref) +class UnsearchablePlatform(ActivitySlack): + """A platform that cannot read its own history back, as Telegram cannot. + + Everything else about it is the Slack fake above: what changes is only the + answer to "can an unacknowledged post be found again", and that is what + decides whether an uncertain delivery is something to wait for or something + that will never resolve. + """ + + recovers_uncertain_posts = False + + class PerAgentSlack(ActivitySlack): """A platform where each agent marks the message as its own bot. @@ -159,10 +171,10 @@ async def test_final_log_edit_failure_is_retried_after_restart( await publish(activity(session_factory, platform)) original = platform.update_rich - async def fail_log(channel, ref, content): + async def fail_log(channel, agent, ref, content): if content.tool_log: raise TimeoutError("Final log edit failed") - return await original(channel, ref, content) + return await original(channel, agent, ref, content) with monkeypatch.context() as patch: patch.setattr(platform, "update_rich", fail_log) @@ -305,6 +317,83 @@ async def test_unknown_delivery_without_a_match_never_blindly_reposts(session_fa assert platform.post_count == 1 +async def test_a_turn_a_platform_cannot_search_starts_again_rather_than_going_quiet( + session_factory, caplog +): + """The other half of the test above, for a platform with nowhere to look. + + There the reservation is held because the lookup may yet find the message. + Here no lookup exists, so holding it means this turn never says anything + again β€” not its status, and not the attention message that goes out when + something has gone wrong. A second status message in the channel is a + smaller fault than a turn that silently stops reporting, and the warning + is what makes the trade visible rather than invisible. + """ + await setup(session_factory) + platform = UnsearchablePlatform() + platform.fail_after_post = True + with pytest.raises(TimeoutError): + await publish(activity(session_factory, platform)) + platform.messages.clear() + assert await publish(activity(session_factory, platform)) + # A fresh status to replace the one nothing can find, and the tool log + # that the abandoned turn never got as far as posting. + assert len(platform.messages) == 2 + assert "may duplicate" in caplog.text + + +async def test_an_unconfirmed_status_does_not_swallow_the_attention_message( + session_factory, +): + """A problem still reaches the channel after a status delivery is lost. + + The attention message has its own durable slot, and it is posted rather + than edited precisely so it can notify. If an unconfirmed reservation in + that slot could never be given up, the one message whose whole job is to + say "somebody has to act on this" would be the one guaranteed never to + arrive. + """ + await setup(session_factory) + platform = UnsearchablePlatform() + original_post = platform.post_rich + + async def lose_the_attention_post(channel, agent, content, thread): + if content.status_only: + platform.fail_after_post = True + return await original_post(channel, agent, content, thread) + + platform.post_rich = lose_the_attention_post + renderer = activity(session_factory, platform) + with pytest.raises(TimeoutError): + await renderer.publish( + [], + _turn("running").model_copy(update={"command_id": "message-demo"}), + session_id="session-demo", + channel_id="channel-demo", + thread_root_id="channel-demo:root", + asked_on="channel-demo:question", + agent_name="Agent", + elapsed_seconds=12, + error_summary="The host went away.", + ) + platform.post_rich = original_post + platform.messages.clear() + assert await activity(session_factory, platform).publish( + [], + _turn("running").model_copy(update={"command_id": "message-demo"}), + session_id="session-demo", + channel_id="channel-demo", + thread_root_id="channel-demo:root", + asked_on="channel-demo:question", + agent_name="Agent", + elapsed_seconds=12, + error_summary="The host went away.", + ) + assert any( + "The host went away." in str(message) for message in platform.messages.values() + ) + + async def test_definite_post_rejection_can_be_retried(session_factory, monkeypatch): from unittest.mock import AsyncMock diff --git a/core/tests/switch_core/sessions/test_publication.py b/core/tests/switch_core/sessions/test_publication.py index 1084af41f..f1a7345e5 100644 --- a/core/tests/switch_core/sessions/test_publication.py +++ b/core/tests/switch_core/sessions/test_publication.py @@ -44,7 +44,7 @@ async def post_rich(self, channel, agent, content: RequestCard, thread): self.posts.append((channel, message.text, message.blocks, thread)) return f"{channel}:111.0" - async def update_rich(self, channel, post, content: RequestCard): + async def update_rich(self, channel, agent, post, content: RequestCard): message = render_request( content.request, content.reference, diff --git a/core/tests/switch_core/sessions/test_publication_retries.py b/core/tests/switch_core/sessions/test_publication_retries.py index fe66bf94e..6f1442a22 100644 --- a/core/tests/switch_core/sessions/test_publication_retries.py +++ b/core/tests/switch_core/sessions/test_publication_retries.py @@ -40,6 +40,15 @@ class RecoverablePlatform(Platform): + """A platform that can read its own history back, as Slack's adapter can. + + The flag is what the publisher reads before deciding whether an uncertain + delivery is worth searching for again, so a fake that implements the search + has to declare it too or it is treated as a platform that cannot look. + """ + + recovers_uncertain_posts = True + async def find_request_card(self, channel, thread, token, created_at, handle): for posted_channel, text, blocks, posted_thread in self.posts: if ( @@ -268,6 +277,163 @@ async def test_uncertain_delivery_is_not_blindly_reposted(session_factory, monke assert post.external_post_id == post.token +class UnsearchablePlatform(Platform): + """A platform with no way to look for a message it may have posted. + + Telegram is the real one: a bot cannot read a chat's history, so a send + whose response was lost can never be matched to what is in the chat. It + also declines to linkify a `switchdash://` URL, so the Console link in the + notice has to be the gateway's https redirect to be a link at all. + """ + + renders_custom_url_schemes = False + + def __init__(self): + super().__init__() + self.notices = [] + + async def admin_message(self, channel, content, thread=None, *, message_type=None): + self.notices.append((channel, content, thread)) + return f"{channel}:333.0" + + +async def _lose_the_card(session_factory, platform, monkeypatch): + """Reserve a card, then lose the response to the post that would confirm it.""" + service, epoch = await setup(session_factory) + await opened(service, epoch) + with monkeypatch.context() as patch: + patch.setattr( + platform, + "post_rich", + AsyncMock(side_effect=TimeoutError("response lost")), + ) + with pytest.raises(TimeoutError): + await refresh_cards( + session_factory, + "bridge", + "session-demo", + cards_for(session_factory, platform), + ) + + +async def test_a_card_that_can_never_be_found_is_disclosed_rather_than_left_silent( + session_factory, monkeypatch +): + """The reservation stays, the card is not posted twice, and Console is named. + + On a platform that can search, an unconfirmed delivery is a wait. Here it + is permanent, and the card β€” if it arrived at all β€” asks a question that + typing an answer to does nothing about. That is the failure mode the error + rules rank worst, so the channel is told once and pointed somewhere the + request can actually be answered. + """ + platform = UnsearchablePlatform() + await _lose_the_card(session_factory, platform, monkeypatch) + + await refresh_cards( + session_factory, + "bridge", + "session-demo", + cards_for(session_factory, platform), + gateway_public_url="https://switch.example", + ) + + assert platform.posts == [] + channel, notice, thread = platform.notices[0] + assert channel == "channel-demo" + assert "R1" in notice + assert "https://switch.example/deeplink/session?" in notice + async with session_factory() as db: + post = (await db.scalars(select(SessionRequestPost))).one() + # Retained, and still its own token: the handle stays held, no second + # card is ever posted, and a typed answer is still refused. + assert post.external_post_id == post.token + assert post.unconfirmed_notice_at is not None + + +async def test_the_channel_is_told_once_however_many_times_the_session_republishes( + session_factory, monkeypatch, caplog +): + """A second notice would say nothing the first did not. + + This runs on every publication cycle for as long as the request is open, + so "once" has to survive both the loop and a bridge that restarts and + remembers nothing β€” which is why the record of it is on the row. + """ + platform = UnsearchablePlatform() + await _lose_the_card(session_factory, platform, monkeypatch) + + for _ in range(3): + await refresh_cards( + session_factory, + "bridge", + "session-demo", + cards_for(session_factory, platform), + ) + + assert len(platform.notices) == 1 + assert platform.posts == [] + # Disclosed is a settled state, not a failure to report again every cycle. + assert "will retry" not in caplog.text + + +async def test_a_notice_that_cannot_be_sent_is_not_retried_into_a_cascade( + session_factory, monkeypatch, caplog +): + """The notice can fail too, and its failure must not become the new loop. + + One attempt is made, and the row records that it was made before it is + tried: a notice lost this way is a card that stays undisclosed, which is + where this started, rather than a message the publisher keeps re-sending. + """ + platform = UnsearchablePlatform() + await _lose_the_card(session_factory, platform, monkeypatch) + refused = AsyncMock(return_value=None) + + with monkeypatch.context() as patch: + patch.setattr(platform, "admin_message", refused) + await refresh_cards( + session_factory, + "bridge", + "session-demo", + cards_for(session_factory, platform), + ) + await refresh_cards( + session_factory, + "bridge", + "session-demo", + cards_for(session_factory, platform), + ) + + refused.assert_awaited_once() + assert platform.notices == [] + assert "nothing in the channel says so" in caplog.text + + +async def test_a_platform_that_can_search_still_waits_for_its_card( + session_factory, monkeypatch +): + """The disclosure is for platforms with nowhere to look, and only those. + + Slack's lookup finds the card the lost response belonged to, so nothing is + disclosed and nothing is posted twice β€” the same outcome as before. + """ + platform = RecoverablePlatform() + await _lose_the_card(session_factory, platform, monkeypatch) + + with pytest.raises(CardNotPosted, match="unconfirmed"): + await refresh_cards( + session_factory, + "bridge", + "session-demo", + cards_for(session_factory, platform), + ) + + async with session_factory() as db: + post = (await db.scalars(select(SessionRequestPost))).one() + assert post.unconfirmed_notice_at is None + + @pytest.mark.parametrize("thread", [None, "channel:100.0"]) async def test_slack_recovery_pages_and_requires_own_bot(thread): adapter = SlackAdapter( diff --git a/core/tests/switch_core/sessions/test_session_presentation.py b/core/tests/switch_core/sessions/test_session_presentation.py index 87e96cd2d..d07277268 100644 --- a/core/tests/switch_core/sessions/test_session_presentation.py +++ b/core/tests/switch_core/sessions/test_session_presentation.py @@ -212,7 +212,7 @@ async def post_rich(self, channel, agent, content, thread): self.contents.append(content) return "channel-demo:111.0" - async def update_rich(self, channel, post, content): + async def update_rich(self, channel, agent, post, content): self.contents.append(content) platform = Capture() @@ -266,7 +266,7 @@ async def post_rich(self, channel, agent, content, thread): self.contents.append(content) return "channel-demo:111.0" - async def update_rich(self, channel, post, content): + async def update_rich(self, channel, agent, post, content): self.contents.append(content) platform = MentionOnly() diff --git a/core/tests/switch_core/sessions/test_turn_activity_publication.py b/core/tests/switch_core/sessions/test_turn_activity_publication.py index 46bbbc94f..5c085e83f 100644 --- a/core/tests/switch_core/sessions/test_turn_activity_publication.py +++ b/core/tests/switch_core/sessions/test_turn_activity_publication.py @@ -37,7 +37,7 @@ async def post_rich(self, channel, agent, content, thread): self.posts.append((channel, content, thread)) return f"{channel}:activity.1" - async def update_rich(self, channel, post, content): + async def update_rich(self, channel, agent, post, content): self.edits.append((channel, post, content)) async def notify_working(self, channel, agent, thread_root_id): From d160e76bc2e4d20b4efb297a9e889f8c0acf5c8b Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 01:21:02 +0100 Subject: [PATCH 008/120] Fix the Task 7 review findings on the Telegram publication seam A status whose send was never acknowledged keeps its reservation for good where the platform cannot search for it, instead of being reposted on every cycle. Attention is published even when the status cannot be settled, so the turn keeps its one way of saying something went wrong. A finished turn's status is taken out of the chat in flat chats and forum topics alike, the way the legacy indicator was: a Telegram chat is the conversation, not a side channel, and a permanent "Worked for 12s" per turn is litter. Request cards and anything still reporting a problem or an unreached reader stay. A deletion Telegram refuses leaves the final state showing and says so; an unknown outcome is raised so the publisher can come back to it. A publication with a thread root now refuses definitely rather than detaching: a card that loses its reply target is the agent's question put to the whole chat, and an answer typed at it there binds a request those readers never saw. Relayed conversation keeps the old widening behaviour. Removing a working mark this process added is raised when Telegram refuses, so a turn is not recorded as cleaned up while the chat still shows it running. A refused addition, and a reconciling removal of a mark this process never added, stay reported-once as before. Pacing is charged to the chat and covers sends as well as edits, per the constraints note: Telegram's 429 is the chat's and several agents publish into one group. Terminal states, cleanup, attention and cards are never held back. The comments no longer assert an edit quota Telegram has not published. The status carries the expandable fold it was said to: the latest few tool calls and a count of the rest, inside `
` assembled after escaping, dropped first when the status is already near the limit. Typing goes to the forum topic the command came from, rather than to the chat at large. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 65 ++-- .../session/renderers/neutral.py | 46 +++ .../bridges/collaboration/telegram/adapter.py | 323 +++++++++++++++--- .../collaboration/test_telegram_adapter.py | 6 + .../collaboration/test_telegram_sdk_only.py | 295 +++++++++++++++- .../sessions/test_activity_durability.py | 74 ++-- 6 files changed, 679 insertions(+), 130 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 2893cc6fb..45193b67f 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -248,18 +248,7 @@ async def publish( notify_unreachable: bool = False, error_summary: str | None = None, ) -> bool: - async def draw() -> bool: - drawn = await self._publish( - items, - turn, - session_id=session_id, - channel_id=channel_id, - thread_root_id=thread_root_id, - asked_on=asked_on, - agent_name=agent_name, - elapsed_seconds=elapsed_seconds, - session_url=session_url, - ) + async def attend() -> None: if self._separate_attention_slot: await self._refresh_attention( session_id, @@ -271,6 +260,29 @@ async def draw() -> bool: notify_unreachable, error_summary, ) + + async def draw() -> bool: + try: + drawn = await self._publish( + items, + turn, + session_id=session_id, + channel_id=channel_id, + thread_root_id=thread_root_id, + asked_on=asked_on, + agent_name=agent_name, + elapsed_seconds=elapsed_seconds, + session_url=session_url, + ) + except CardNotPosted: + # A status whose delivery cannot be resolved keeps its + # reservation for good where the platform cannot search for it. + # Attention is a message of its own and the only way this turn + # has of saying something went wrong, so it goes out rather + # than waiting behind a status nobody can settle. + await attend() + raise + await attend() return drawn if self._journal is None: @@ -400,23 +412,6 @@ async def _post_activity( if record is None: return await self._adapter.post_rich(channel, agent, content, thread) delivery = record.data.get(slot) - if delivery and not delivery.get("ref") and not self._recovers_posts: - # Nothing will ever find this one, so holding the reservation holds - # the turn's only voice shut: no status, and no attention message - # when something goes wrong later. A second status message is worth - # more than a permanently silent turn, so the reservation is given - # up and this slot starts again. - logger.warning( - "Activity delivery for %s in %s was never confirmed and this " - "platform cannot search for it. Posting a new %s message, which " - "may duplicate one already in the channel.", - delivery["token"], - delivery["channel"], - slot, - ) - del record.data[slot] - await record.save() - delivery = None if delivery: saved_ref = delivery.get("ref") if saved_ref: @@ -425,6 +420,18 @@ async def _post_activity( "Activity journal message reference must be a string." ) return saved_ref + if not self._recovers_posts: + # The send may well have landed; nothing here can find out. + # Posting again on every cycle would put one unwanted copy in + # the chat per cycle, so the reservation is kept and this slot + # stays as it is. The attention message is published + # separately and is not held up by it. + raise CardNotPosted( + f"The {slot} message sent as {delivery['token']} in " + f"{delivery['channel']} was never acknowledged, and this " + "platform cannot search for it. Keeping its reservation " + "rather than posting a second one that may duplicate it." + ) ref = await self._adapter.find_request_card( delivery["channel"], delivery["thread"], diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 03b99fb17..040cd56e4 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -93,6 +93,11 @@ "declined": "⊘", } +# How much of one tool call's title a disclosed line keeps. A title is host +# text: long enough to recognise the call, short enough that five of them are +# still a glance rather than a page. +_DETAIL_TITLE = 120 + _OUTCOME_WORDS = { "in-progress": "running", "completed": "done", @@ -216,6 +221,47 @@ def turn_status( return _mentioned(mention, "\n".join(lines)) +def activity_detail( + items: list[Item], + *, + escape: Callable[[str], str], + limit: int, + lines: int, +) -> list[str]: + """The last few tool calls as their own lines, oldest first. + + What a platform puts behind a disclosure it already has β€” Telegram's + expandable quotation β€” rather than in the status itself, which stays the + three compact lines `turn_status` draws whether or not anything expands. + The caller supplies the wrapper; this decides what is safe to say inside + it and how much of it there is room for. + + Only the title and how the call went. Arguments and output are host text + with no bound worth trusting, and a status is not a transcript: five + labels say what the turn has been doing, and a count says there was more. + Lines are dropped from the oldest end when the budget is short, because + the reader opening this wants to know what it is doing now. + + `limit` counts the escaped text and the newlines between the lines, not + whatever the caller wraps around them. + """ + did = [item for item in items if item.kind == "tool-activity"] + if not did or limit <= 0 or lines <= 0: + return [] + shown = did[-lines:] + per = max(1, min(_DETAIL_TITLE, limit // len(shown))) + drawn = [ + f"{_OUTCOME[item.status]} {_fit(item.title or 'Tool call', per, escape=escape)}" + for item in shown + ] + hidden = len(did) - len(shown) + if hidden: + drawn.insert(0, f"…{hidden} earlier, not shown.") + while drawn and sum(len(line) + 1 for line in drawn) - 1 > limit: + drawn.pop(0) + return drawn + + def _doing( did: list[Item], turn: TurnUpsert, diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 915ebbd0b..1edc01711 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -57,6 +57,7 @@ ) from switch_core.bridges.collaboration.session.renderers import Markup from switch_core.bridges.collaboration.session.renderers.neutral import ( + activity_detail, request_summary, turn_status, ) @@ -139,17 +140,47 @@ # missing or unreadable β€” a floor, not a figure Telegram committed to. _THROTTLE_FALLBACK = 5.0 -# The shortest gap the bridge will leave between two redraws of one running -# turn. Telegram's own ceiling for edits in a group is roughly one a second -# and it enforces it with a 429 that then applies to everything in the chat, -# including the agent's actual reply. Pacing ourselves under it costs a -# redraw its freshness; being paced by Telegram costs the conversation. +# The shortest gap the bridge will leave between two publications in one chat, +# counting sends and edits alike. Telegram's published figures are send limits β€” +# roughly twenty messages a minute to one group β€” and say nothing about what an +# edit costs, so this is a self-imposed floor under an unknown, not a quota +# Telegram stated. It is charged to the chat because Telegram's own 429 is: +# every agent publishing there shares one budget, and being paced by Telegram +# costs the conversation rather than only the redraw. # -# Only intermediate progress is held back. A turn's last state, the attention -# slot and every request card go through immediately, because a reader waiting -# on one of those is waiting on the thing this pacing would delay. +# Only intermediate progress is held back. A turn's last state and its cleanup, +# the attention slot and every request card go through immediately, because a +# reader waiting on one of those is waiting on the thing this pacing would +# delay. _REDRAW_INTERVAL = 1.5 +# Telegram's own disclosure control: a quotation the reader opens, documented +# under HTML style in the Bot API. Nothing may be nested inside it, and a +# collapsed block still costs its whole length against the message limit, so +# what goes in is bounded to the latest few tool calls and a count of the rest. +_EXPAND_OPEN = "
" +_EXPAND_CLOSE = "
" +_EXPAND_LINES = 5 + + +def _retires(content: RichContent) -> bool: + """Whether this redraw is the end of something that should not stay. + + A status is the thing in the chat saying work is happening, and a Telegram + chat or topic is the conversation itself rather than a side channel, so it + goes when the turn does β€” as the legacy indicator did. Two things stay: a + request card, which is the record of a decision and says on its face what + became of it, and anything still reporting a problem or an unreached + reader, which is the message somebody has to act on and outlives the turn + that raised it. + """ + return ( + isinstance(content, TurnActivity) + and content.turn.status in TURN_ENDED + and not content.error_summary + and not content.notify_unreachable + ) + def _throttle_delay(error: RetryAfter) -> float: """How long Telegram's 429 asks us to wait. @@ -377,12 +408,17 @@ def __init__(self, *, config: TelegramConnectionConfig) -> None: # A 429 is charged to the chat, not the message, so one throttled # redraw pauses every publication rather than only its own. self._rich_update_after = 0.0 - # (chat id, message id) -> when that publication was last redrawn, so - # intermediate progress can be paced without holding back the states a - # reader is actually waiting on. Bounded like the other per-message - # caches: an entry is only ever a timestamp to compare against. + # chat id -> when a publication was last sent or edited in it. Telegram + # charges its limits to the chat, and several agents publish into one + # chat, so intermediate progress is paced against the chat's budget + # rather than each message's own. Bounded like the other caches: an + # entry is only ever a timestamp to compare against. self._rich_drawn_at: OrderedDict[str, float] = OrderedDict() self._rich_drawn_at_max = 1000 + # Publications taken down at the end of their turn, so a later redraw + # of one is a no-op rather than an edit to a message that is gone. + self._rich_retired: OrderedDict[str, None] = OrderedDict() + self._rich_retired_max = 1000 # ── Lifecycle ──────────────────────────────────────────────────────────── @@ -1261,21 +1297,25 @@ def _draw( # message Telegram refuses β€” and an edit has no chunking to fall # back on. tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" - body = ( - turn_status( - content.items, - content.turn, - escape=escape, - limit=max(1, limit - len(tail)), - markup=markup, - elapsed_seconds=content.elapsed_seconds, - session_url=content.session_url, - mention=mention, - error_summary=content.error_summary, + body = turn_status( + content.items, + content.turn, + escape=escape, + limit=max(1, limit - len(tail)), + markup=markup, + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + mention=mention, + error_summary=content.error_summary, + ) + detail = ( + "" + if content.error_summary or content.status_only + else self._expandable( + content, escape=escape, budget=limit - len(body) - len(tail) ) - + tail ) - return f"{prefix}{body}" + return f"{prefix}{body}{detail}{tail}" # The mention goes on its own line rather than in front of the heading: # a card is a block, and a handle wedged before "Permission needed" # reads as part of the heading. @@ -1292,6 +1332,42 @@ def _draw( ) return f"{prefix}{lead}{body}{tail}" + def _expandable( + self, + content: TurnActivity, + *, + escape: Callable[[str], str], + budget: int, + ) -> str: + """What the turn has been doing, folded away under the status. + + Telegram's own disclosure: a collapsed quotation the reader opens if + they want it, in the message that is already there. It is why this + platform has no separate activity log β€” the detail lives inside the + status rather than in a second message that would notify the chat + again. + + Assembled here rather than in the renderer because the tags have to go + on after escaping: `translate_outbound` escapes a body whole and then + re-introduces the tags it knows by pattern, so markup written upstream + of it would reach the chat as visible angle brackets. The lines inside + are escaped host text; the quotation around them is ours. + + A collapsed block still costs its full length against the message + limit, so it is the first thing to go when the status is already large: + the reader loses the detail, not the state. + """ + lines = activity_detail( + content.items, + escape=escape, + limit=budget - len(_EXPAND_OPEN) - len(_EXPAND_CLOSE) - 1, + lines=_EXPAND_LINES, + ) + if not lines: + return "" + quoted = "\n".join(lines) + return f"\n{_EXPAND_OPEN}{quoted}{_EXPAND_CLOSE}" + async def _render_rich(self, content: RichContent, agent_name: str) -> str: """Draw `content` as the agent, for one Telegram chat. @@ -1385,8 +1461,9 @@ async def post_rich( """ text = await self._render_rich(content, agent_name) self._refuse_while_throttled(text) + self._pace_publication(channel_id, content, text) + anchor = await self._publication_anchor(channel_id, thread_root_id, text) try: - anchor = await self._anchor_kwargs(channel_id, thread_root_id) sent = await self._require_bot().send_message( chat_id=self._chat_id(channel_id), text=self._clamp(text), @@ -1399,7 +1476,7 @@ async def post_rich( error, f"Telegram refused the post in chat {channel_id}", text ) from error ref = self._ref(sent) - self._note_redraw(ref) + self._note_publication(channel_id) return ref async def update_rich( @@ -1409,13 +1486,16 @@ async def update_rich( message_ref: str, content: RichContent, ) -> None: - """Redraw a publication in place. + """Redraw a publication in place β€” or take it down, once the turn it + was reporting is over. - Nothing is ever taken down. On the platforms that delete a finished - status the status was a separate thing from the turn's reply; here it - is a message in the chat like any other, deleting it leaves the reply - with nothing saying what produced it, and a Telegram client that has - already shown the notification cannot unshow it. + A Telegram chat and a forum topic are both the conversation itself: + there is no side channel a finished status could sit quietly in, and + the legacy indicator was deleted at the end of a turn for that reason. + The status keeps that lifecycle, so a chat is not left carrying one + permanent "Worked for 12s" per turn. What is still worth reading stays: + a request card is the record of a decision and is never taken down, and + an attention message about a problem outlives the turn that raised it. `agent_name` is what the redraw writes back into the body. The name is the message here β€” one bot posts for every agent β€” so an edit that did @@ -1434,6 +1514,8 @@ async def update_rich( "chat:message reference.", text=self.rich_fallback_text(content), ) + if message_ref in self._rich_retired: + return # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the chat that never reaches anybody it # has not already reached. @@ -1441,7 +1523,56 @@ async def update_rich( replace(content, notify_external_id=None), agent_name ) self._refuse_while_throttled(text) - self._pace_redraw(message_ref, content, text) + if _retires(content): + await self._retire_rich(channel_id, message_ref, text) + return + self._pace_publication(channel_id, content, text) + await self._edit_rich(channel_id, message_ref, text) + + async def _retire_rich(self, channel_id: str, message_ref: str, text: str) -> None: + """Take a finished status out of the chat, or say why it is still there. + + A deletion Telegram refuses is not quietly treated as one that + happened: the message stays, so it is left showing the turn's final + state rather than "Working…", and the refusal is logged. An outcome + nobody knows β€” a timeout, a reset β€” is raised, because the publisher + holds the anchor and can come back to it, and a turn recorded as + cleaned up when it was not is a status that never goes. + """ + _, message_id = self._parse_message_ref(message_ref) + try: + await self._require_bot().delete_message( + chat_id=self._chat_id(channel_id), message_id=int(message_id) + ) + except BadRequest as error: + if "not found" in str(error).lower(): + self._retire_ref(message_ref) + return + logger.warning( + "Telegram would not remove the finished status %s in chat %s " + "(%s); leaving its final state there instead.", + message_ref, + channel_id, + error, + ) + await self._edit_rich(channel_id, message_ref, text) + return + except Forbidden as error: + logger.warning( + "Telegram would not remove the finished status %s in chat %s " + "(%s); leaving its final state there instead.", + message_ref, + channel_id, + error, + ) + await self._edit_rich(channel_id, message_ref, text) + return + self._note_publication(channel_id) + self._retire_ref(message_ref) + + async def _edit_rich(self, channel_id: str, message_ref: str, text: str) -> None: + """Rewrite a publication, reporting a refusal rather than logging it.""" + chat_id, message_id = self._parse_message_ref(message_ref) try: await self._require_bot().edit_message_text( chat_id=self._chat_id(chat_id or channel_id), @@ -1454,7 +1585,7 @@ async def update_rich( # The one refusal that means the work is already done: an edit to # the text Telegram is already showing. if "not modified" in str(error).lower(): - self._note_redraw(message_ref) + self._note_publication(channel_id) return raise self._rich_failure( error, @@ -1467,7 +1598,13 @@ async def update_rich( f"Telegram refused the edit to {message_ref} in chat {channel_id}", text, ) from error - self._note_redraw(message_ref) + self._note_publication(channel_id) + + def _retire_ref(self, message_ref: str) -> None: + self._rich_retired[message_ref] = None + self._rich_retired.move_to_end(message_ref) + while len(self._rich_retired) > self._rich_retired_max: + self._rich_retired.popitem(last=False) def _rich_failure(self, error: Exception, description: str, text: str) -> Exception: """The exception to raise for `error`: Telegram's refusal, or its own. @@ -1498,30 +1635,37 @@ def _refuse_while_throttled(self, text: str) -> None: if remaining > 0: raise RichContentThrottled(retry_after=remaining, text=text) - def _pace_redraw(self, message_ref: str, content: RichContent, text: str) -> None: - """Hold back intermediate progress that is arriving faster than the chat - can take it. - - Only progress. A turn's final state, the attention slot and every - request card go through however recently the last redraw was, because - a reader waiting on one of those is waiting on precisely the thing - this would delay β€” and the publisher retries a throttle, so what is - held back here is postponed rather than lost. + def _pace_publication( + self, channel_id: str, content: RichContent, text: str + ) -> None: + """Hold back progress arriving faster than the chat can take it. + + Charged to the chat and not to the message, for both sends and edits. + Telegram's limits are the chat's, several agents can be working in one + group at once, and nothing published says what an edit costs β€” so two + agents' statuses share one budget the way they share the 429 that + follows from overspending it. + + Only progress. A turn's final state and its cleanup, the attention + slot and every request card go through however recently the chat was + last written to, because a reader waiting on one of those is waiting + on precisely the thing this would delay β€” and the publisher retries a + throttle, so what is held back here is postponed rather than lost. """ if not isinstance(content, TurnActivity): return if content.turn.status in TURN_ENDED or content.error_summary: return - drawn_at = self._rich_drawn_at.get(message_ref) + drawn_at = self._rich_drawn_at.get(channel_id) if drawn_at is None: return remaining = drawn_at + _REDRAW_INTERVAL - time.monotonic() if remaining > 0: raise RichContentThrottled(retry_after=remaining, text=text) - def _note_redraw(self, message_ref: str) -> None: - self._rich_drawn_at.pop(message_ref, None) - self._rich_drawn_at[message_ref] = time.monotonic() + def _note_publication(self, channel_id: str) -> None: + self._rich_drawn_at.pop(channel_id, None) + self._rich_drawn_at[channel_id] = time.monotonic() while len(self._rich_drawn_at) > self._rich_drawn_at_max: self._rich_drawn_at.popitem(last=False) @@ -1547,9 +1691,20 @@ async def mark_activity( Raises where another attempt might work, so the publisher retries and records the turn as drawn only once the chat shows what it says it - shows. A chat with reactions switched off is not that: it would be - retried for the life of the turn and refused every time, so it is - reported once and the turn goes on without the mark. + shows. A chat that refuses to *add* the mark is not that: reactions are + switched off there, or the bot may not use them, and it would be + refused the same way for the life of the turn β€” so it is reported once + and the turn goes on without it. + + A refused *removal* of a mark this process put there is the opposite + case and is raised. The mark is on the message and the chat is showing + this turn as still running; reporting that as cleaned up would have the + publisher stop asking, and permission coming back later would change + nothing. Retried and still outstanding is the truth, so that is what the + caller is told. Where the mark is not this process's β€” a reconciling + `force` after a restart, or a chat that refused to add one at all β€” + there is nothing known to be outstanding, and the refusal is reported + the way a refused addition is rather than held against a turn forever. """ _, message_id = self._parse_message_ref(message_ref) if not message_id: @@ -1568,6 +1723,8 @@ async def mark_activity( reaction=[ReactionTypeEmoji(_WORKING_REACTION)] if working else [], ) except (BadRequest, Forbidden) as error: + if not working and key in self._reacted: + raise # Reactions are off in this chat, or the bot may not react in it. # Refused now is refused for the rest of the turn. logger.warning( @@ -1592,10 +1749,19 @@ async def notify_working( Telegram expires it after about five seconds, so it costs the chat nothing and it is the only signal that arrives before the first post. Best effort by nature: the status carries the state from here on. + + In a forum it goes into the topic the command came from β€” people + reading one topic do not see another's β€” which is the only sense a + thread root has here. Outside a forum the root is a message to reply + to and there is nothing to send an action to but the chat, so it is + not passed on: `message_thread_id` set to a reply target would aim the + nudge at a topic that is not one. """ try: await self._require_bot().send_chat_action( - chat_id=self._chat_id(channel_id), action=ChatAction.TYPING + chat_id=self._chat_id(channel_id), + action=ChatAction.TYPING, + **await self._topic_kwargs(channel_id, thread_root_id), ) except Exception as error: logger.warning( @@ -2569,6 +2735,57 @@ async def _anchor_kwargs( ) } + async def _publication_anchor( + self, channel_id: str, thread_root_id: str | None, text: str + ) -> dict[str, Any]: + """Where a publication goes, with no route that quietly widens it. + + The same two spellings as `_anchor_kwargs`, and the opposite answer to + an anchor that is not there. A relayed message detaching from a deleted + reply target costs the quote and keeps the audience, which is the right + trade for a line of conversation. A publication is not one: a card that + detaches is the agent's question put to the whole chat rather than to + the exchange that raised it, and an answer typed at it there binds a + request those readers never saw. So the send is refused, definitely, + and the publisher takes the route it keeps for a destination it cannot + reach β€” which ends at the Console rather than in the wrong place. + + A root that is not a number is the same thing arriving differently: the + caller asked for somewhere this cannot address, and posting to the chat + instead would be answering a question nobody asked. + """ + if not thread_root_id: + return {} + try: + root = int(thread_root_id) + except ValueError: + raise RichContentFailed( + f"Cannot publish to Telegram chat {channel_id}: {thread_root_id!r} " + "is not a topic or message id, so there is no conversation this " + "belongs to.", + text=text, + ) from None + if await self._is_forum(channel_id): + return {"message_thread_id": root} + return { + "reply_parameters": ReplyParameters( + message_id=root, allow_sending_without_reply=False + ) + } + + async def _topic_kwargs( + self, channel_id: str, thread_root_id: str | None + ) -> dict[str, Any]: + """The forum topic to address, where the root names one. + + Outside a forum the root is a message rather than a topic, and there is + nothing but the chat to aim at.""" + if not thread_root_id or not thread_root_id.isdigit(): + return {} + if not await self._is_forum(channel_id): + return {} + return {"message_thread_id": int(thread_root_id)} + @staticmethod def _is_photo(mimetype: str, size: int) -> bool: """Whether Telegram will accept this as an inline photo. diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py index bc0e86cd3..b3efcd7f3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py @@ -167,6 +167,8 @@ def __init__(self) -> None: self.send_album_error: Exception | None = None # Set to an exception to make the next edit_message_text raise it once. self.edit_error: Exception | None = None + # Set to an exception to make the next delete_message raise it once. + self.delete_error: Exception | None = None def _mint(self, chat_id: Any) -> _FakeSentMessage: self._next_id += 1 @@ -208,6 +210,10 @@ async def edit_message_text(self, **kwargs: Any) -> None: self.edits.append(kwargs) async def delete_message(self, **kwargs: Any) -> None: + if self.delete_error is not None: + error = self.delete_error + self.delete_error = None + raise error self.deletes.append(kwargs) async def send_chat_action(self, **kwargs: Any) -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 45d58f071..743dd8889 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -291,7 +291,7 @@ async def counted(chat_id: Any) -> Any: _bot(adapter).get_chat = counted # type: ignore[method-assign] await adapter.post_rich(CHANNEL, "my-agent", _activity(), TOPIC_ID) - await adapter.post_rich(CHANNEL, "my-agent", _activity(), TOPIC_ID) + await adapter.post_rich(CHANNEL, "my-agent", await _card(), TOPIC_ID) assert len(calls) == 1 @@ -421,10 +421,10 @@ async def test_an_edit_telegram_calls_unchanged_is_not_a_failure() -> None: """ "message is not modified" means the chat already shows what was asked for, which is the outcome the caller wanted.""" adapter = _adapter() - ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) _bot(adapter).edit_error = BadRequest("Message is not modified") - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card()) async def test_a_publication_is_never_retried_as_stripped_plain_text() -> None: @@ -476,13 +476,13 @@ async def test_progress_arriving_faster_than_the_chat_can_take_it_waits() -> Non async def test_the_end_of_a_turn_is_never_held_back() -> None: """A reader waiting on the outcome is waiting on precisely the thing the - pacing would delay.""" + pacing would delay β€” and here the outcome is the status going away.""" adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) - assert len(_bot(adapter).edits) == 1 + assert len(_bot(adapter).deletes) == 1 async def test_a_problem_somebody_has_to_act_on_is_never_held_back() -> None: @@ -507,6 +507,238 @@ async def test_a_card_is_never_held_back() -> None: assert len(_bot(adapter).edits) == 1 +async def test_two_agents_publishing_in_one_chat_share_its_budget() -> None: + """Telegram's limits are the chat's, and so is the 429 that follows from + overspending them. Metering each message on its own would let five agents + in one group send five times what one agent can.""" + adapter = _adapter() + await adapter.post_rich(CHANNEL, "one", _activity(), None) + + with pytest.raises(RichContentThrottled): + await adapter.post_rich(CHANNEL, "two", _activity(), None) + assert len(_bot(adapter).messages) == 1 + + +async def test_one_agents_redraw_paces_the_next_agents() -> None: + adapter = _adapter() + await adapter.update_rich(CHANNEL, "one", f"{CHAT_ID}:11", _activity()) + + with pytest.raises(RichContentThrottled): + await adapter.update_rich(CHANNEL, "two", f"{CHAT_ID}:12", _activity()) + assert len(_bot(adapter).edits) == 1 + + +async def test_another_chat_is_not_held_back_by_this_one() -> None: + adapter = _adapter() + await adapter.post_rich(CHANNEL, "one", _activity(), None) + + await adapter.post_rich("-1002000000002", "one", _activity(), None) + + assert len(_bot(adapter).messages) == 2 + + +# ── A finished turn does not stay on the screen ────────────────────────────── + + +async def test_a_finished_status_is_taken_out_of_the_chat() -> None: + """A Telegram chat is the conversation itself: there is no side channel a + completed status can sit quietly in, and the legacy indicator was deleted + for that reason. One permanent "Worked for 12s" per turn is the clutter + this platform's own rule exists to avoid.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + + assert _bot(adapter).deletes[0]["message_id"] == int(ref.split(":")[1]) + assert _bot(adapter).edits == [] + + +async def test_a_finished_status_in_a_forum_topic_goes_the_same_way() -> None: + """A topic is a conversation people read, not a hidden thread to leave a + record in.""" + adapter = _adapter() + _forum(adapter) + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), TOPIC_ID) + + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + + assert len(_bot(adapter).deletes) == 1 + + +async def test_a_finished_turn_that_still_has_a_problem_to_report_stays() -> None: + """The attention message outlives the turn that raised it: somebody has to + act on it, and a turn ending is not that having happened.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + await adapter.update_rich( + CHANNEL, "my-agent", ref, _ended(error_summary="The host went away.") + ) + + assert _bot(adapter).deletes == [] + assert "went away" in _edited(adapter)["text"] + + +async def test_a_request_card_is_never_taken_down() -> None: + """It is the record of a decision, and it says on its face what became of + it.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card()) + + assert _bot(adapter).deletes == [] + + +async def test_a_status_taken_down_is_not_edited_afterwards() -> None: + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + + assert len(_bot(adapter).deletes) == 1 + assert _bot(adapter).edits == [] + + +async def test_a_deletion_telegram_refuses_leaves_the_final_state_showing( + caplog: pytest.LogCaptureFixture, +) -> None: + """Visibly degraded rather than quietly wrong: the status cannot be taken + down, so it is left saying what actually happened instead of saying the + turn is still running.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + _bot(adapter).delete_error = BadRequest("message can't be deleted") + + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + + assert len(_bot(adapter).edits) == 1 + assert any("leaving its final state" in record.message for record in caplog.records) + + +async def test_a_deletion_whose_outcome_is_unknown_is_retried_rather_than_assumed() -> ( + None +): + """A turn recorded as cleaned up when it was not is a status that never + goes.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + _bot(adapter).delete_error = TimedOut() + + with pytest.raises(TimedOut): + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + assert len(_bot(adapter).deletes) == 1 + + +# ── Publications do not drift out of the conversation they belong to ───────── + + +async def test_a_publication_does_not_detach_from_a_reply_target_that_is_gone() -> None: + """Detaching costs a relayed line its quote and nothing else. A card that + detaches is the agent's question put to the whole chat instead of to the + exchange that raised it, and an answer typed at it there binds a request + those readers never saw.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), TOPIC_ID) + + parameters = _posted(adapter)["reply_parameters"] + assert parameters.allow_sending_without_reply is False + + +async def test_a_relayed_message_still_detaches_rather_than_being_lost() -> None: + """The same anchor, the opposite trade: this is conversation, and the chat + is where it belongs either way.""" + adapter = _adapter() + + await adapter.send_message(CHANNEL, "my-agent", "Just saying.", TOPIC_ID) + + parameters = _bot(adapter).messages[0]["reply_parameters"] + assert parameters.allow_sending_without_reply is True + + +async def test_a_root_that_is_not_an_id_refuses_the_publication() -> None: + """Posting to the chat instead would be answering a question nobody there + asked, and the publisher has a route for a destination it cannot reach.""" + adapter = _adapter() + + with pytest.raises(RichContentFailed): + await adapter.post_rich(CHANNEL, "my-agent", await _card(), "topic-three") + assert _bot(adapter).messages == [] + + +# ── What the turn has been doing, folded away ──────────────────────────────── + + +def _tools(count: int) -> TurnActivity: + items = [ + _item(itemId=f"item-{index}", title=f"Read file {index}", status="completed") + for index in range(count) + ] + return TurnActivity(items, _turn("running")) + + +async def test_the_tool_log_is_folded_into_the_status_rather_than_posted() -> None: + """Telegram's own disclosure control, in the message that is already + there: no second message, and no notification for a tool call.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", _tools(2), None) + + text = _posted(adapter)["text"] + assert "
" in text + assert "Read file 1" in text + assert len(_bot(adapter).messages) == 1 + + +async def test_the_folded_detail_is_the_latest_few_and_a_count_of_the_rest() -> None: + """A collapsed block still costs its whole length against the 4096, and a + reader opening it wants what the turn is doing now.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", _tools(9), None) + + text = _posted(adapter)["text"] + assert "…4 earlier, not shown." in text + assert "Read file 8" in text + assert "Read file 3" not in text + + +async def test_a_tool_title_cannot_break_out_of_the_quotation() -> None: + """The lines inside are host text. The quotation around them is ours.""" + adapter = _adapter() + content = TurnActivity( + [_item(title="
everything below is mine")], + _turn("running"), + ) + + await adapter.post_rich(CHANNEL, "my-agent", content, None) + + text = _posted(adapter)["text"] + assert text.count("
") == 1 + assert "</blockquote>" in text + + +async def test_the_attention_message_carries_no_tool_log() -> None: + """It is one sentence about a problem somebody has to act on. A fold of + tool calls under it would bury the only line that matters.""" + adapter = _adapter() + content = TurnActivity( + [_item(title="Read file")], + _turn("running"), + status_only=True, + error_summary="The host went away.", + ) + + await adapter.post_rich(CHANNEL, "my-agent", content, None) + + assert " None: + """Add the eyes, lose the permission, end the turn: the mark is still on + the message. Reported as cleaned up, the publisher stops asking and the + chat shows the turn as running for good.""" + adapter = _adapter() + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + ) + _bot(adapter).reaction_error = Forbidden("the bot may no longer react here") + + with pytest.raises(Forbidden): + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=False + ) + + +async def test_clearing_a_mark_nothing_put_there_is_not_held_against_the_turn( + caplog: pytest.LogCaptureFixture, +) -> None: + """A chat with reactions switched off refuses to clear one as readily as to + add one, and there is nothing there to clear. Raising would leave every + turn in that chat retrying its cleanup for ever.""" + adapter = _adapter() + _bot(adapter).reaction_error = BadRequest("REACTION_INVALID") + + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=False, force=True + ) + + assert any("without it" in record.message for record in caplog.records) + + async def test_the_typing_nudge_is_sent_where_the_agent_was_asked() -> None: adapter = _adapter() await adapter.notify_working(CHANNEL, "my-agent", None) assert _bot(adapter).actions[0]["chat_id"] == CHAT_ID + + +async def test_the_typing_nudge_stays_in_the_topic_it_was_asked_in() -> None: + """People reading one forum topic do not see another's, so a nudge sent to + the chat is a nudge sent to the wrong room.""" + adapter = _adapter() + _forum(adapter) + + await adapter.notify_working(CHANNEL, "my-agent", TOPIC_ID) + + assert _bot(adapter).actions[0]["message_thread_id"] == int(TOPIC_ID) + + +async def test_a_reply_target_is_not_sent_as_though_it_were_a_topic() -> None: + """Outside a forum the root is a message. Passed as a topic id it names + whichever topic happens to hold that number, or none at all.""" + adapter = _adapter() + + await adapter.notify_working(CHANNEL, "my-agent", TOPIC_ID) + + assert "message_thread_id" not in _bot(adapter).actions[0] diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 8c042e084..2e50b8154 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -8,7 +8,10 @@ from switch_core.bridges.collaboration.adapter import RichContentThrottled from switch_core.bridges.collaboration.session.activity_journal import ActivityJournal -from switch_core.bridges.collaboration.session.outbound import SessionTurnActivity +from switch_core.bridges.collaboration.session.outbound import ( + CardNotPosted, + SessionTurnActivity, +) from switch_core.bridges.collaboration.slack.adapter import ( SlackAdapter, SlackConnectionConfig, @@ -304,8 +307,6 @@ async def test_existing_older_turn_is_finished_after_restart_without_replaying_h async def test_unknown_delivery_without_a_match_never_blindly_reposts(session_factory): - from switch_core.bridges.collaboration.session.outbound import CardNotPosted - await setup(session_factory) platform = ActivitySlack() platform.fail_after_post = True @@ -317,29 +318,29 @@ async def test_unknown_delivery_without_a_match_never_blindly_reposts(session_fa assert platform.post_count == 1 -async def test_a_turn_a_platform_cannot_search_starts_again_rather_than_going_quiet( - session_factory, caplog +async def test_a_status_a_platform_cannot_search_is_never_posted_a_second_time( + session_factory, ): """The other half of the test above, for a platform with nowhere to look. There the reservation is held because the lookup may yet find the message. - Here no lookup exists, so holding it means this turn never says anything - again β€” not its status, and not the attention message that goes out when - something has gone wrong. A second status message in the channel is a - smaller fault than a turn that silently stops reporting, and the warning - is what makes the trade visible rather than invisible. + Here no lookup exists, so the answer is the same and it is permanent: the + send probably landed, nothing can confirm it, and posting again β€” on this + cycle or on any of the hundreds after it β€” puts one more copy of the same + status in the chat each time. The reservation stays and the turn keeps the + status it may already have. """ await setup(session_factory) platform = UnsearchablePlatform() platform.fail_after_post = True with pytest.raises(TimeoutError): await publish(activity(session_factory, platform)) - platform.messages.clear() - assert await publish(activity(session_factory, platform)) - # A fresh status to replace the one nothing can find, and the tool log - # that the abandoned turn never got as far as posting. - assert len(platform.messages) == 2 - assert "may duplicate" in caplog.text + assert platform.post_count == 1 + + for _ in range(3): + with pytest.raises(CardNotPosted): + await publish(activity(session_factory, platform)) + assert platform.post_count == 1 async def test_an_unconfirmed_status_does_not_swallow_the_attention_message( @@ -347,25 +348,18 @@ async def test_an_unconfirmed_status_does_not_swallow_the_attention_message( ): """A problem still reaches the channel after a status delivery is lost. - The attention message has its own durable slot, and it is posted rather - than edited precisely so it can notify. If an unconfirmed reservation in - that slot could never be given up, the one message whose whole job is to - say "somebody has to act on this" would be the one guaranteed never to - arrive. + The attention message is a message of its own, posted rather than edited + precisely so it can notify. A status nobody can confirm keeps its + reservation for good on a platform that cannot search, so an attention + message published behind it would be the one message whose whole job is to + say "somebody has to act on this" and which is guaranteed never to arrive. """ await setup(session_factory) platform = UnsearchablePlatform() - original_post = platform.post_rich - - async def lose_the_attention_post(channel, agent, content, thread): - if content.status_only: - platform.fail_after_post = True - return await original_post(channel, agent, content, thread) + platform.fail_after_post = True - platform.post_rich = lose_the_attention_post - renderer = activity(session_factory, platform) - with pytest.raises(TimeoutError): - await renderer.publish( + async def report(renderer): + return await renderer.publish( [], _turn("running").model_copy(update={"command_id": "message-demo"}), session_id="session-demo", @@ -376,19 +370,13 @@ async def lose_the_attention_post(channel, agent, content, thread): elapsed_seconds=12, error_summary="The host went away.", ) - platform.post_rich = original_post + + with pytest.raises(TimeoutError): + await report(activity(session_factory, platform)) platform.messages.clear() - assert await activity(session_factory, platform).publish( - [], - _turn("running").model_copy(update={"command_id": "message-demo"}), - session_id="session-demo", - channel_id="channel-demo", - thread_root_id="channel-demo:root", - asked_on="channel-demo:question", - agent_name="Agent", - elapsed_seconds=12, - error_summary="The host went away.", - ) + + with pytest.raises(CardNotPosted): + await report(activity(session_factory, platform)) assert any( "The host went away." in str(message) for message in platform.messages.values() ) From 81ee89a5efc11636c3b7c80066bc8f85c8e2f2aa Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 02:05:53 +0100 Subject: [PATCH 009/120] Answer a Telegram request by pressing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Telegram card has had no controls: an approval could be read in the chat but only answered by typing. This puts the options on the message as buttons and takes the press back through the same authority a typed answer goes through. The obstacle is the payload. Telegram allows 64 bytes for everything a press hands back, and an option id is an unbounded, possibly non-ASCII string the host chose β€” so a button cannot carry one and a press that did would be a press we could not fit. It sends where the control was on the card instead, and the server resolves that against the form the card rendered. That is the stronger of the two, not a concession: a press can only ever name an option the card actually offered, whatever reached us. So the shape is shared rather than Telegram's. `offered_controls` states once which requests a press can answer β€” one question, one choice out of a list, still open β€” mirroring what the resolver will accept, so a card does not draw a control whose press is going to be refused. `resolve_pressed_position` turns a position into an option id against the record and hands it to the existing resolver, which keeps what a press means on each kind of card in one place. On the adapter: a keyboard on a card, redrawn on every edit so a settled card loses its buttons without anything having to remember it had them; a press attributed to `from_user`, which Telegram signs and the payload cannot claim; and an acknowledgement on every path out, including the ones where nothing happened, since an unanswered press spins on the presser's client until it gives up. A refusal reaches the presser as an alert on the press itself rather than a message in the chat β€” a bot cannot message someone who has not opened a chat with it, so this is also the only private reply Telegram offers in a group. A press that succeeds is acknowledged with no text: the card's redraw says an answer was taken, and saying so here would say it before the redraw proves it. A payload that would exceed the limit refuses the card rather than posting it without buttons, so a card never looks answerable by typing alone when it was meant to be pressable. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/bridge_core.py | 10 +- .../bridges/collaboration/session/form.py | 63 ++++ .../bridges/collaboration/session/inbound.py | 20 +- .../session/renderers/__init__.py | 84 +++++ .../bridges/collaboration/telegram/adapter.py | 291 ++++++++++++++- .../collaboration/test_session_answers.py | 110 +++++- .../collaboration/test_telegram_adapter.py | 37 +- .../collaboration/test_telegram_sdk_only.py | 345 +++++++++++++++++- 8 files changed, 938 insertions(+), 22 deletions(-) diff --git a/core/switch_core/bridges/collaboration/bridge_core.py b/core/switch_core/bridges/collaboration/bridge_core.py index 53d8535c5..d2f6e41fa 100644 --- a/core/switch_core/bridges/collaboration/bridge_core.py +++ b/core/switch_core/bridges/collaboration/bridge_core.py @@ -1309,11 +1309,11 @@ async def _handle_inbound_interaction( outcome = await interactions.command_for(interaction) if isinstance(outcome, Refused): # outcome.card_ref is available here too, but deliberately unused: - # a press only reaches a platform whose buttons are live, which - # today is Slack alone, and there tell_actor's reply is already - # private to the actor β€” thread_ref only chooses where that - # ephemeral appears on screen, not who sees it. None is the - # current choice, not an oversight. + # a press only reaches a platform whose buttons are live, and on + # each of those the reply to a press is already private to whoever + # pressed β€” Slack's ephemeral, Telegram's alert on the press + # itself. thread_ref would only choose where a private notice + # appeared on screen, not who saw it. await self._tell_refused(interaction, outcome, thread_ref=None) return await self._submit_session_command( diff --git a/core/switch_core/bridges/collaboration/session/form.py b/core/switch_core/bridges/collaboration/session/form.py index 8a93d014a..b3e5fbc1c 100644 --- a/core/switch_core/bridges/collaboration/session/form.py +++ b/core/switch_core/bridges/collaboration/session/form.py @@ -143,6 +143,69 @@ def resolve_pressed_option( return _unknown(kind) +def resolve_pressed_position( + form: dict[str, Any], position: int +) -> RequestResult | Unanswerable: + """The answer a control at `position` stands for, against the record. + + For a platform whose control payload is too small to carry an option id: it + sends where the control was instead, in the same numbering a typed answer + uses, and the id is the record's to supply. That is the stronger end of the + two, not a concession to a small budget β€” a press can only ever name + something the card actually offered, however the payload reaching us was + built. + + The guards are `resolve_pressed_option`'s, because this resolves to an + option id and hands it there: what a press means on each kind of card is + decided in one place. + """ + option_id = _option_at(form, position) + if isinstance(option_id, Unanswerable): + return option_id + return resolve_pressed_option(form, option_id) + + +def _option_at(form: dict[str, Any], position: int) -> str | Unanswerable: + """The option the `position`th control on the card was for. + + Render order is the record's order, which is what makes a position an + answer at all: `posted_form` wrote the options as the card drew them and + `_entries` refuses a record it cannot read whole rather than dropping + entries and shifting everything after them. + """ + kind = form.get("kind") + if kind == "approval": + options = _entries(form, "options") + if options is None: + return _malformed(kind) + if not 1 <= position <= len(options): + return Unanswerable( + f"that card offered {len(options)} options, not {position}" + ) + return _option_id_of(options[position - 1].get("optionId")) + if kind == "questions": + questions = _entries(form, "questions") + if questions is None: + return _malformed(kind) + if len(questions) != 1: + return Unanswerable( + f"a press answers one question and that card asks {len(questions)}" + ) + option_ids = list(questions[0].get("optionIds") or []) + if not 1 <= position <= len(option_ids): + return Unanswerable( + f"that question offered {len(option_ids)} options, not {position}" + ) + return _option_id_of(option_ids[position - 1]) + return _unknown(kind) + + +def _option_id_of(value: Any) -> str | Unanswerable: + if not isinstance(value, str) or not value: + return Unanswerable("the record for that option names no option") + return value + + def resolve_text_answer( form: dict[str, Any], answer: TextAnswer ) -> RequestResult | Unanswerable: diff --git a/core/switch_core/bridges/collaboration/session/inbound.py b/core/switch_core/bridges/collaboration/session/inbound.py index 4ef4c5dc5..325e7d190 100644 --- a/core/switch_core/bridges/collaboration/session/inbound.py +++ b/core/switch_core/bridges/collaboration/session/inbound.py @@ -52,10 +52,11 @@ from .form import ( Unanswerable, resolve_pressed_option, + resolve_pressed_position, resolve_text_answer, takes_a_bare_decision, ) -from .renderers import parse_answer_action +from .renderers import parse_answer_action, parse_answer_position from .text import TextAnswer, parse_text_answer if TYPE_CHECKING: @@ -213,9 +214,16 @@ async def command_for( Only a control this layer did not write is None here. Everything else is someone pressing a button we put in front of them, which is as clear an attempt to answer as there is. + + A control names the option it is for in one of two ways, and which one + is the platform's to choose: by id, where the payload has room for one, + and by where it sat on the card, where it has not. Both land on the + same record and both are resolved against it. """ - option_id = parse_answer_action(interaction.action_id) - if option_id is None: + pressed: str | int | None = parse_answer_action(interaction.action_id) + if pressed is None: + pressed = parse_answer_position(interaction.action_id) + if pressed is None: return None async with self._session_factory() as session: @@ -242,7 +250,11 @@ async def command_for( ) return None - answer = resolve_pressed_option(post.form, option_id) + answer = ( + resolve_pressed_option(post.form, pressed) + if isinstance(pressed, str) + else resolve_pressed_position(post.form, pressed) + ) if isinstance(answer, Unanswerable): logger.warning( "Ignoring a press on request %s on bridge %s, because %s.", diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index b5be6a5fa..93287700e 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -15,8 +15,10 @@ from switch_core.sessions.contract import ( TURN_ENDED, + ApprovalContent, Item, Question, + SnapshotRequest, Surface, TurnUpsert, ) @@ -196,6 +198,88 @@ def parse_answer_action(action_id: str) -> str | None: return option_id or None +# The same control on a platform whose payload is too small to carry an option +# id. Telegram allows 64 bytes for everything a press hands back, and an option +# id is an unbounded, possibly non-ASCII string the host chose, so the press +# names where the control was on the card and the server resolves that against +# the form the card rendered. Shorter than the id it replaces on purpose: what +# is left of the budget is the request token, and both have to fit. +POSITION_ACTION = "switch:request-option" + + +def position_action(position: int) -> str: + """The action id for the control drawn `position`th on the card.""" + return f"{POSITION_ACTION}:{position}" + + +def parse_answer_position(action_id: str) -> int | None: + """Where on the card the operated control was, or None if not one of ours. + + A count, in ASCII digits, from one. Anything else is refused here rather + than carried inwards as a number: `int` accepts digits from any script and + a payload that was not ours is not owed a resolution against a form. + """ + prefix = f"{POSITION_ACTION}:" + if not action_id.startswith(prefix): + return None + digits = action_id[len(prefix) :] + if not digits.isascii() or not digits.isdecimal(): + return None + position = int(digits) + return position if position > 0 else None + + +@dataclass(frozen=True) +class Control: + """One press a card offers: what it says, and where on the card it is. + + `position` is the number printed beside the option in the body, which is + also what a typed answer names β€” so the two ways of answering a card mean + the same thing by the same number, and a control that carries a position + resolves to the option the reader was looking at. + + The option's own id is deliberately not here. A platform whose control can + carry one reads it off the request directly; a platform whose control + cannot is the reason this exists, and handing it an id it has no room for + invites the payload this shape was made to avoid. + """ + + position: int + label: str + + +def offered_controls(request: SnapshotRequest) -> list[Control]: + """The controls a card for `request` may draw, in the order it draws them. + + Empty is the ordinary answer rather than a failure: a settled request has + nothing left to press, and neither has a form whose answer one press cannot + be. The rule is the one `resolve_pressed_option` applies when the press + comes back β€” one question, one choice out of a list β€” stated here so that a + card does not draw a control whose press is going to be refused. + + A platform may still decline to draw what this offers, because a limit on + how many controls fit is the platform's own. What it must not do is offer + more. + """ + if request.state != "open": + return [] + content = request.content + if isinstance(content, ApprovalContent): + return [ + Control(position=position, label=option.label) + for position, option in enumerate(content.options, start=1) + ] + if len(content.questions) != 1: + return [] + question = content.questions[0] + if question.multi_select: + return [] + return [ + Control(position=position, label=option.label) + for position, option in enumerate(question.options, start=1) + ] + + class Markup: """The three marks the neutral renderer makes, in one platform's spelling. diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 1edc01711..02aeb4d42 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -7,12 +7,15 @@ import time from collections import OrderedDict from collections.abc import Awaitable, Callable, Coroutine +from contextvars import ContextVar from dataclasses import replace from typing import Any, ClassVar, NamedTuple from telegram import ( BotCommand, ForceReply, + InlineKeyboardButton, + InlineKeyboardMarkup, InputMediaDocument, InputMediaPhoto, LinkPreviewOptions, @@ -51,11 +54,17 @@ InboundAgentJoin, InboundAppJoin, InboundCommand, + InboundInteraction, InboundMessage, InboundUserJoin, OutboundAttachment, ) -from switch_core.bridges.collaboration.session.renderers import Markup +from switch_core.bridges.collaboration.session.renderers import ( + Control, + Markup, + offered_controls, + position_action, +) from switch_core.bridges.collaboration.session.renderers.neutral import ( activity_detail, request_summary, @@ -81,7 +90,74 @@ _MEDIA_GROUP_MIN = 2 _MEDIA_GROUP_MAX = 10 -_ALLOWED_UPDATES = ["message", "channel_post", "my_chat_member"] +_ALLOWED_UPDATES = ["message", "channel_post", "my_chat_member", "callback_query"] + +# What a press on a card's button hands back. Telegram allows 64 bytes for the +# whole of it, so it holds the request token and the option's position on the +# card and nothing else β€” the prefix is two characters for the same reason. +# Every byte spent here is one the token cannot have. +_CALLBACK_PREFIX = "sw" +_MAX_CALLBACK_BYTES = 64 + +# A button's label is one line on a phone, and Telegram truncates the middle of +# an over-long one rather than wrapping it. Cut here instead, at the end, where +# the reader can tell something was cut. +_MAX_BUTTON_LABEL = 48 + +# Telegram's own limit on the text of a reply to a press. +_MAX_ALERT = 200 + +# The notice a press is owed, collected while the press is being handled. +# +# A refusal is raised deep inside the shared inbound path, which knows the +# person and the reason and nothing about Telegram; the only private way to +# tell them is a reply to the callback query, and the id for that belongs to +# the press rather than to the person. A context variable is what joins the +# two: `tell_actor` leaves the notice here and the press answers with it, so +# nothing has to be looked up by actor β€” two people pressing at once are two +# tasks with a context each, and the same person pressing twice is two presses +# rather than one notice overwriting another. +_PRESS_NOTICE: ContextVar[list[str] | None] = ContextVar( + "switch_telegram_press_notice", default=None +) + + +def _callback_data(token: str, position: int) -> str: + return f"{_CALLBACK_PREFIX}:{token}:{position}" + + +def _parse_callback(data: str) -> tuple[str, int] | None: + """The request and the control a press names, or None if it is not ours. + + Telegram hands back exactly what was put in the button, so this is read as + strictly as it is written: a prefix this bridge minted, a token, and a + count in ASCII digits from one. Neither value is trusted past its shape β€” + the token is resolved against the record and the position against the form + that record holds. + """ + parts = data.split(":") + if len(parts) != 3 or parts[0] != _CALLBACK_PREFIX: + return None + token, digits = parts[1], parts[2] + if not token or not digits.isascii() or not digits.isdecimal(): + return None + position = int(digits) + return (token, position) if position > 0 else None + + +def _button_label(control: Control) -> str: + """What the button says: the option's number, and as much of it as fits. + + Numbered because the body numbers it. A card is answerable by typing + whether or not it has buttons, and a reader looking at "2" in the text and + "Decline" on a button should not have to work out that they are the same + thing. + """ + label = control.label.strip() or f"Option {control.position}" + if len(label) > _MAX_BUTTON_LABEL: + label = label[: _MAX_BUTTON_LABEL - 1].rstrip() + "…" + return f"{control.position}. {label}" + # Switch's own in-room prefix, plus Telegram's native one. _COMMAND_PREFIXES = ("!", "/") @@ -1368,6 +1444,42 @@ def _expandable( quoted = "\n".join(lines) return f"\n{_EXPAND_OPEN}{quoted}{_EXPAND_CLOSE}" + def _controls(self, content: RichContent, text: str) -> InlineKeyboardMarkup | None: + """The card's options as buttons, or nothing where a press cannot land. + + One per row. An option's label is a phrase more often than a word, and + Telegram gives the buttons in a row equal width and truncates what does + not fit, so a second column would cost the labels rather than save the + space. + + Nothing at all is the ordinary answer: a status has no options, a + settled card has none left, and a card that cannot be answered where it + is showing says so β€” a live control under that sentence is an + invitation to the refusal it just explained. Since `update_rich` draws + the keyboard on every redraw, the controls come off a card at the + moment it stops being pressable, without anything having to remember + that it once had them. + + `text` is the drawing these belong to, carried only so a refusal can + report what could not be posted. + """ + if not isinstance(content, RequestCard) or content.unavailable_reason: + return None + rows: list[list[InlineKeyboardButton]] = [] + for control in offered_controls(content.request): + data = _callback_data(content.reference.token, control.position) + if len(data.encode()) > _MAX_CALLBACK_BYTES: + raise RichContentFailed( + f"Cannot put a button on request {content.request.request_id} in " + f"Telegram: its press would carry {len(data.encode())} bytes and " + f"Telegram allows {_MAX_CALLBACK_BYTES}.", + text=text, + ) + rows.append( + [InlineKeyboardButton(text=_button_label(control), callback_data=data)] + ) + return InlineKeyboardMarkup(rows) if rows else None + async def _render_rich(self, content: RichContent, agent_name: str) -> str: """Draw `content` as the agent, for one Telegram chat. @@ -1462,6 +1574,7 @@ async def post_rich( text = await self._render_rich(content, agent_name) self._refuse_while_throttled(text) self._pace_publication(channel_id, content, text) + controls = self._controls(content, text) anchor = await self._publication_anchor(channel_id, thread_root_id, text) try: sent = await self._require_bot().send_message( @@ -1469,6 +1582,7 @@ async def post_rich( text=self._clamp(text), parse_mode=ParseMode.HTML, link_preview_options=_NO_PREVIEW, + reply_markup=controls, **anchor, ) except Exception as error: @@ -1527,7 +1641,9 @@ async def update_rich( await self._retire_rich(channel_id, message_ref, text) return self._pace_publication(channel_id, content, text) - await self._edit_rich(channel_id, message_ref, text) + await self._edit_rich( + channel_id, message_ref, text, self._controls(content, text) + ) async def _retire_rich(self, channel_id: str, message_ref: str, text: str) -> None: """Take a finished status out of the chat, or say why it is still there. @@ -1555,7 +1671,7 @@ async def _retire_rich(self, channel_id: str, message_ref: str, text: str) -> No channel_id, error, ) - await self._edit_rich(channel_id, message_ref, text) + await self._edit_rich(channel_id, message_ref, text, None) return except Forbidden as error: logger.warning( @@ -1565,13 +1681,25 @@ async def _retire_rich(self, channel_id: str, message_ref: str, text: str) -> No channel_id, error, ) - await self._edit_rich(channel_id, message_ref, text) + await self._edit_rich(channel_id, message_ref, text, None) return self._note_publication(channel_id) self._retire_ref(message_ref) - async def _edit_rich(self, channel_id: str, message_ref: str, text: str) -> None: - """Rewrite a publication, reporting a refusal rather than logging it.""" + async def _edit_rich( + self, + channel_id: str, + message_ref: str, + text: str, + controls: InlineKeyboardMarkup | None, + ) -> None: + """Rewrite a publication, reporting a refusal rather than logging it. + + `controls` is the whole of what the message offers afterwards, not an + addition to what it offered before: Telegram replaces the keyboard with + what an edit carries, so passing none is how a settled card's buttons + come off. + """ chat_id, message_id = self._parse_message_ref(message_ref) try: await self._require_bot().edit_message_text( @@ -1580,6 +1708,7 @@ async def _edit_rich(self, channel_id: str, message_ref: str, text: str) -> None text=self._clamp(text), parse_mode=ParseMode.HTML, link_preview_options=_NO_PREVIEW, + reply_markup=controls, ) except BadRequest as error: # The one refusal that means the work is already done: an edit to @@ -2040,12 +2169,153 @@ async def _handle_update(self, update: Any) -> None: await self._handle_my_chat_member(chat_member) return + callback = getattr(update, "callback_query", None) + if callback is not None: + await self._handle_callback_query(callback) + return + message = getattr(update, "message", None) or getattr( update, "channel_post", None ) if message is not None: await self._handle_message(message) + async def _handle_callback_query(self, query: Any) -> None: + """Someone pressed a button on a card this bridge posted. + + Who pressed comes from `from_user`, which Telegram fills in and the + payload cannot: the data in the button says which request and which + option, never who. So a press replayed from someone else's client is + still attributed to whoever actually sent it, and the identity check + downstream is against a real account rather than a claim. + + The press is answered on every path out of here. Until it is, the + presser's client keeps the button in a loading state and will + eventually decide for itself that something broke β€” including on the + paths where nothing happened, which is what a press on a keyboard this + bridge did not write is. + + A refusal reaches the presser through `tell_actor`, which leaves it in + `_PRESS_NOTICE` for the answer below rather than posting it in the + chat. It is collected in a `finally` so that a handler which raises + still closes the press: the exception belongs in the log, not on the + button. + + Nothing here dedupes. Telegram redelivers an update it was not + acknowledged for, and the same press twice is the same option, by the + same person, against the same revision β€” which the shared layer derives + one command id from, so the second is the first rather than a second + answer. + """ + query_id = str(getattr(query, "id", "") or "") + message = getattr(query, "message", None) + chat = getattr(message, "chat", None) + user = getattr(query, "from_user", None) + press = _parse_callback(str(getattr(query, "data", "") or "")) + if press is None or chat is None or user is None: + await self._answer_callback(query_id, None) + return + if self._on_interaction is None: + logger.warning( + "A press on a Switch card in chat %s has nowhere to go: this " + "bridge handles no interactions, so the card should not have " + "been drawn with buttons.", + getattr(chat, "id", "?"), + ) + await self._answer_callback(query_id, None) + return + + token, position = press + name = self._display_name(user) + # A press is a sighting of that account in this chat, and the same + # thing a message teaches: the name a mention needs, and the id a + # handle resolves to. + self._user_names[user.id] = name + self._username_to_id[name] = user.id + + notices: list[str] = [] + held = _PRESS_NOTICE.set(notices) + try: + await self._on_interaction( + InboundInteraction( + channel_id=str(chat.id), + sender_id=str(user.id), + sender_name=name, + action_id=position_action(position), + value=token, + message_ref=self._ref(message), + ) + ) + finally: + _PRESS_NOTICE.reset(held) + await self._answer_callback(query_id, notices[0] if notices else None) + + async def _answer_callback(self, query_id: str, notice: str | None) -> None: + """Close a press on the presser's own client, and say why if it failed. + + `show_alert` for a notice, because a toast is gone in a moment and what + is being said is why an answer did not land. Without one this is the + acknowledgement Telegram requires and nothing more: the card's own + redraw is what says an answer was taken, and claiming it here would be + claiming it before the redraw that proves it. + + Plain text, unescaped, because an alert is not markup β€” the reason + quotes back what the host called an option, and HTML escaping it would + put the escapes on the screen. + + A refusal from Telegram is logged and left. The query expires by + itself, nothing downstream waits on it, and the answer it would have + acknowledged has already been decided either way. + """ + if not query_id: + return + text = None + if notice is not None: + text = ( + notice + if len(notice) <= _MAX_ALERT + else notice[: _MAX_ALERT - 1].rstrip() + "…" + ) + try: + await self._require_bot().answer_callback_query( + callback_query_id=query_id, text=text, show_alert=text is not None + ) + except Exception as error: + logger.warning( + "Telegram would not acknowledge a press (%s). The notice, if " + "there was one, went unsaid: %s", + error, + text, + ) + + async def tell_actor( + self, + channel_id: str, + actor_ref: str, + actor_name: str, + thread_ref: str | None, + text: str, + ) -> None: + """Tell one person their answer did not land, where they can see it. + + A press is told in the reply to the press itself: an alert on their + client alone, which costs the chat nothing and reaches them whether or + not they have ever opened a chat with the bot β€” a bot cannot message + someone who has not. It is left here for the press to carry rather than + sent from here, because the id that addresses it belongs to the press. + + A typed answer has no press to reply to, so it falls back to the base: + said in the card's own thread, where everyone reading it sees a notice + addressed to someone else. That is the platform's limit rather than a + choice β€” Telegram gives a bot no private reply in a group it can use + unprompted. + """ + notices = _PRESS_NOTICE.get() + if notices is not None: + notices.append(text) + return + await super().tell_actor(channel_id, actor_ref, actor_name, thread_ref, text) + async def _handle_my_chat_member(self, event: Any) -> None: """The bot's own membership changed. Being added is Telegram's equivalent of Slack's "app added to channel", and is what provisions the @@ -2204,11 +2474,14 @@ def _update_shape(update: Any) -> str: Enough to tell whether Telegram is delivering, without putting message bodies or sender names into the log β€” this runs server-side, where the desktop app's redaction does not reach.""" - for field in ("message", "channel_post", "my_chat_member"): + for field in ("message", "channel_post", "my_chat_member", "callback_query"): payload = getattr(update, field, None) if payload is None: continue - chat = getattr(payload, "chat", None) + # A press has no chat of its own; the message its button is on has. + chat = getattr(payload, "chat", None) or getattr( + getattr(payload, "message", None), "chat", None + ) return f"{field} in chat {getattr(chat, 'id', '?')}" return "no recognised payload" diff --git a/core/tests/switch_core/bridges/collaboration/test_session_answers.py b/core/tests/switch_core/bridges/collaboration/test_session_answers.py index e6301f5ea..f3f2f8c9c 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_answers.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_answers.py @@ -20,7 +20,11 @@ Refused, SessionInteractions, ) -from switch_core.bridges.collaboration.session.renderers import ANSWER_ACTION +from switch_core.bridges.collaboration.session.renderers import ( + ANSWER_ACTION, + POSITION_ACTION, + position_action, +) from switch_core.db.models import SessionRequestPost REPO_ROOT = Path(__file__).resolve().parents[5] @@ -267,3 +271,107 @@ def test_callback_cannot_reuse_a_token_on_another_message_or_channel() -> None: assert _run(interactions.command_for(_press(channel_id="another-channel"))) is None assert _run(interactions.command_for(_press(message_ref="C1:222.0"))) is None assert _run(interactions.command_for(_press(message_ref=None))) is None + + +# ── The same press, from a platform with no room for an option id ──────────── + + +def test_a_press_by_position_answers_the_option_the_card_drew_there() -> None: + """Telegram's press: the second control on the card, and nothing else.""" + interactions = _interactions(_post()) + + command = _run(interactions.command_for(_press(action_id=position_action(2)))) + + assert command is not None + assert command.body.answer.option_id == "deny" # type: ignore[union-attr] + + +def test_a_position_and_the_option_id_it_stands_for_are_one_command() -> None: + """Two platforms, two payload shapes, one decision β€” so one command id. + + What is being checked is that the position resolves to the option rather + than travelling on into the command as a number of its own. + """ + by_position = _run( + _interactions(_post()).command_for(_press(action_id=position_action(1))) + ) + by_id = _run( + _interactions(_post()).command_for( + _press(action_id=f"{ANSWER_ACTION}:allow-once") + ) + ) + + assert by_position is not None and by_id is not None + assert by_position.command_id == by_id.command_id + + +def test_the_record_says_which_option_a_position_is() -> None: + """The card's order, not the request's now: the record is what was drawn.""" + reordered = _post( + form=_approval_form(("deny", "decline"), ("allow-once", "accept")) + ) + + command = _run( + _interactions(reordered).command_for(_press(action_id=position_action(1))) + ) + + assert command is not None + assert command.body.answer.option_id == "deny" # type: ignore[union-attr] + + +def test_a_press_past_the_end_of_the_card_is_refused() -> None: + """A number no control was drawn at. The record is the only thing that + could say so, and it says so before anything is decided.""" + interactions = _interactions(_post()) + + refused = _run(interactions.command_for(_press(action_id=position_action(9)))) + + assert isinstance(refused, Refused) + assert "2 options, not 9" in refused.reason + + +def test_a_position_that_is_not_a_count_names_no_control() -> None: + """Nothing this layer wrote looks like these, so none of them is ours. + + `int` would take the Arabic-Indic digits, and a form resolved against a + number nobody can type is a press that cannot be reproduced by hand. + """ + interactions = _interactions(_post()) + + for action_id in ( + f"{POSITION_ACTION}:0", + f"{POSITION_ACTION}:-1", + f"{POSITION_ACTION}:1.0", + f"{POSITION_ACTION}:", + f"{POSITION_ACTION}:Ω’", + f"{POSITION_ACTION}x:1", + ): + assert _run(interactions.command_for(_press(action_id=action_id))) is None + + +def test_a_press_by_position_answers_a_single_question() -> None: + post = _post(form=_questions_form(("q1", ["red", "blue"], False, False))) + + command = _run( + _interactions(post).command_for(_press(action_id=position_action(2))) + ) + + assert command is not None + assert command.body.answer.answers[0].selected_option_ids == ["blue"] # type: ignore[union-attr] + + +def test_a_press_by_position_refuses_the_forms_a_press_cannot_answer() -> None: + """The same two refusals a press by id gets, reached the same way: one + press is one option, and it has to belong to one question.""" + two_questions = _post( + form=_questions_form( + ("q1", ["red"], False, False), ("q2", ["blue"], False, False) + ) + ) + many_at_once = _post(form=_questions_form(("q1", ["red", "blue"], True, False))) + + for post in (two_questions, many_at_once): + refused = _run( + _interactions(post).command_for(_press(action_id=position_action(1))) + ) + assert isinstance(refused, Refused) diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py index b3efcd7f3..2c3e7b5f2 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py @@ -137,6 +137,25 @@ def __init__( setattr(self, field, value) +class _FakeCallbackQuery: + """A press on an inline button. Only the fields the adapter reads.""" + + def __init__( + self, + *, + data: str, + query_id: str = "cq-1", + message: Any = "default", + from_user: Any = "default", + ) -> None: + self.id = query_id + self.data = data + self.message = ( + _FakeSentMessage(_FakeChat(), 11) if message == "default" else message + ) + self.from_user = _FakeUser() if from_user == "default" else from_user + + class _FakeMember: def __init__(self, status: str) -> None: self.status = status @@ -152,6 +171,7 @@ def __init__(self) -> None: self.deletes: list[dict[str, Any]] = [] self.actions: list[dict[str, Any]] = [] self.reactions: list[dict[str, Any]] = [] + self.answers: list[dict[str, Any]] = [] self.files: dict[str, _FakeFileHandle] = {} # Set to an exception to make every set_message_reaction raise it. self.reaction_error: Exception | None = None @@ -169,6 +189,8 @@ def __init__(self) -> None: self.edit_error: Exception | None = None # Set to an exception to make the next delete_message raise it once. self.delete_error: Exception | None = None + # Set to an exception to make answer_callback_query raise it. + self.answer_error: Exception | None = None def _mint(self, chat_id: Any) -> _FakeSentMessage: self._next_id += 1 @@ -219,6 +241,11 @@ async def delete_message(self, **kwargs: Any) -> None: async def send_chat_action(self, **kwargs: Any) -> None: self.actions.append(kwargs) + async def answer_callback_query(self, **kwargs: Any) -> None: + if self.answer_error is not None: + raise self.answer_error + self.answers.append(kwargs) + async def set_message_reaction(self, **kwargs: Any) -> None: if self.reaction_error is not None: raise self.reaction_error @@ -637,10 +664,17 @@ def __init__(self, chat: _FakeChat, *, status: str) -> None: class _FakeUpdate: - def __init__(self, *, message: Any = None, my_chat_member: Any = None) -> None: + def __init__( + self, + *, + message: Any = None, + my_chat_member: Any = None, + callback_query: Any = None, + ) -> None: self.message = message self.channel_post = None self.my_chat_member = my_chat_member + self.callback_query = callback_query # ── Sender names ───────────────────────────────────────────────────────────── @@ -1681,6 +1715,7 @@ def test_starting_begins_polling_and_learns_the_bot_id() -> None: "message", "channel_post", "my_chat_member", + "callback_query", ] diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 743dd8889..2df2ea369 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -14,6 +14,7 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from typing import Any @@ -34,7 +35,15 @@ RichContentThrottled, TurnActivity, ) -from switch_core.bridges.collaboration.session.renderers import RequestReference +from switch_core.bridges.collaboration.models import InboundInteraction +from switch_core.bridges.collaboration.session.form import ( + posted_form, + resolve_pressed_position, +) +from switch_core.bridges.collaboration.session.renderers import ( + RequestReference, + parse_answer_position, +) from switch_core.bridges.collaboration.session.transport import ( FixtureEventSource, project, @@ -43,12 +52,25 @@ _REDRAW_INTERVAL, TelegramAdapter, ) +from switch_core.sessions.contract import ApprovalResult from .test_session_activity import _item, _turn -from .test_telegram_adapter import CHAT_ID, _adapter, _bot +from .test_telegram_adapter import ( + CHAT_ID, + _adapter, + _bot, + _FakeCallbackQuery, + _FakeChat, + _FakeSentMessage, + _FakeUpdate, + _FakeUser, +) REPO_ROOT = Path(__file__).resolve().parents[5] EXAMPLES_PATH = REPO_ROOT / "console/packages/shared/src/session-v1/examples.json" +QUESTIONS_PATH = ( + REPO_ROOT / "console/packages/shared/src/session-v1/examples.questions.json" +) CHANNEL = str(CHAT_ID) TOPIC_ID = "88" @@ -858,3 +880,322 @@ async def test_a_reply_target_is_not_sent_as_though_it_were_a_topic() -> None: await adapter.notify_working(CHANNEL, "my-agent", TOPIC_ID) assert "message_thread_id" not in _bot(adapter).actions[0] + + +# ── The card's buttons, and the press that comes back ──────────────────────── + + +def _keyboard(markup: Any) -> list[tuple[str, str]]: + """Every button on a message, as the label and the payload it carries.""" + if markup is None: + return [] + return [ + (button.text, button.callback_data) + for row in markup.inline_keyboard + for button in row + ] + + +async def _press( + adapter: TelegramAdapter, data: str, **overrides: Any +) -> list[InboundInteraction]: + """Drive a press from the update Telegram would deliver.""" + seen: list[InboundInteraction] = [] + + async def record(interaction: InboundInteraction) -> None: + seen.append(interaction) + + adapter.set_interaction_handler(record) + await adapter._handle_update( + _FakeUpdate(callback_query=_FakeCallbackQuery(data=data, **overrides)) + ) + return seen + + +async def test_an_open_card_offers_a_button_for_every_option_it_lists() -> None: + """Numbered the way the body numbers them, so pressing and typing agree.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + assert _keyboard(_posted(adapter)["reply_markup"]) == [ + ("1. Allow once", "sw:tok-1:1"), + ("2. Deny", "sw:tok-1:2"), + ] + + +async def test_a_press_carries_the_request_and_where_the_control_was() -> None: + """And nothing else. The option's own id never goes into the payload: it + is unbounded text the host chose, and 64 bytes is the whole budget.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + payloads = [data for _, data in _keyboard(_posted(adapter)["reply_markup"])] + assert all(len(data.encode()) <= 64 for data in payloads) + assert not any("allow-once" in data or "deny" in data for data in payloads) + + +async def test_a_press_payload_stays_inside_the_limit_on_a_wordy_card() -> None: + """The budget is bytes, not characters, and a label is host text in any + script. What the button carries is bounded by the token and a count, so + neither the label nor the option id can push it over.""" + adapter = _adapter() + card = await _card() + wordy = card.request.model_copy( + update={ + "content": card.request.content.model_copy( + update={ + "options": [ + option.model_copy( + update={ + "option_id": f"опция-{index}-{'x' * 200}", + "label": f"Π Π°Π·Ρ€Π΅ΡˆΠΈΡ‚ΡŒ ΠΎΠ΄Π½ΠΎΠΊΡ€Π°Ρ‚Π½ΠΎ {'Ρ‘' * 100}", + } + ) + for index, option in enumerate(card.request.content.options) + ] + } + ) + } + ) + + await adapter.post_rich(CHANNEL, "my-agent", replace(card, request=wordy), None) + + buttons = _keyboard(_posted(adapter)["reply_markup"]) + assert [data for _, data in buttons] == ["sw:tok-1:1", "sw:tok-1:2"] + assert all(len(data.encode()) <= 64 for _, data in buttons) + assert all(len(label) <= 52 for label, _ in buttons) + + +async def test_a_press_that_would_not_fit_is_refused_rather_than_truncated() -> None: + """A token this long is not something Switch mints, so it is a change + somewhere upstream β€” and a cut payload resolves to another request or to + none, which is the one outcome worse than not posting the card.""" + adapter = _adapter() + card = await _card() + oversized = RequestCard(card.request, RequestReference(token="t" * 64, handle="R7")) + + with pytest.raises(RichContentFailed): + await adapter.post_rich(CHANNEL, "my-agent", oversized, None) + + +async def test_a_settled_card_is_redrawn_without_its_buttons() -> None: + """An edit carries the whole keyboard, so a redraw with none takes them + off β€” a settled request must not still be offering an answer.""" + adapter = _adapter() + card = await _card() + ref = await adapter.post_rich(CHANNEL, "my-agent", card, None) + + settled = replace( + card, request=card.request.model_copy(update={"state": "resolved"}) + ) + await adapter.update_rich(CHANNEL, "my-agent", ref, settled) + + assert _keyboard(_posted(adapter)["reply_markup"]) != [] + assert _edited(adapter)["reply_markup"] is None + + +async def test_a_card_that_cannot_be_answered_here_offers_nothing_to_press() -> None: + """It says why in its own words. A live button under that sentence is an + invitation to the refusal it just explained.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, + "my-agent", + await _card(unavailable_reason="Answer this one in the Console."), + None, + ) + + assert _posted(adapter)["reply_markup"] is None + + +async def test_a_status_has_nothing_to_press() -> None: + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + + assert _posted(adapter)["reply_markup"] is None + + +async def test_the_card_and_the_press_agree_on_the_option() -> None: + """The loop: the adapter draws the control, Telegram hands the payload + back, and the record turns it into the option the reader pressed.""" + adapter = _adapter() + card = await _card() + await adapter.post_rich(CHANNEL, "my-agent", card, None) + _, deny = _keyboard(_posted(adapter)["reply_markup"]) + + interaction = (await _press(adapter, deny[1]))[0] + + assert interaction.value == card.reference.token + answer = resolve_pressed_position( + posted_form(card.request), + parse_answer_position(interaction.action_id) or 0, + ) + assert answer == ApprovalResult(kind="approval", option_id="deny") + + +async def test_a_press_is_attributed_to_the_account_that_sent_it() -> None: + """Telegram fills in who pressed; the payload says only which request and + which control. An id in the data would be a claim rather than a sender.""" + adapter = _adapter() + + interaction = ( + await _press( + adapter, + "sw:tok-1:2", + from_user=_FakeUser(user_id=ASKER_ID, username="asker"), + message=_FakeSentMessage(_FakeChat(), 404), + ) + )[0] + + assert interaction.sender_id == ASKER + assert interaction.sender_name == "asker" + assert interaction.channel_id == CHANNEL + assert interaction.message_ref == f"{CHAT_ID}:404" + assert interaction.action_id.endswith(":2") + + +async def test_a_press_on_a_keyboard_we_did_not_write_is_closed_and_ignored() -> None: + """Another bot's buttons in the same chat. The press is still answered: + an unanswered one spins on the presser's client until it gives up.""" + adapter = _adapter() + + for data in ("", "other-app:go", "sw:tok-1:0", "sw:tok-1:x", "sw:tok-1", "sw::1"): + assert await _press(adapter, data) == [] + + assert len(_bot(adapter).answers) == 6 + assert {answer["text"] for answer in _bot(adapter).answers} == {None} + + +async def test_a_press_whose_handling_fails_still_closes_the_press() -> None: + """The failure belongs in the log, not on a button that never stops + loading.""" + adapter = _adapter() + + async def explode(_interaction: InboundInteraction) -> None: + raise RuntimeError("the room went away") + + adapter.set_interaction_handler(explode) + + with pytest.raises(RuntimeError): + await adapter._handle_update( + _FakeUpdate(callback_query=_FakeCallbackQuery(data="sw:tok-1:1")) + ) + + assert len(_bot(adapter).answers) == 1 + + +async def test_a_refused_press_is_told_to_the_presser_and_to_nobody_else() -> None: + """Telegram's reply to a press is an alert on that person's client. The + chat is not told that somebody's answer did not land.""" + adapter = _adapter() + + async def refuse(interaction: InboundInteraction) -> None: + await adapter.tell_actor( + interaction.channel_id, + interaction.sender_id, + interaction.sender_name, + None, + "Your answer to R7 did not land, because that card is no longer open.", + ) + + adapter.set_interaction_handler(refuse) + await adapter._handle_update( + _FakeUpdate(callback_query=_FakeCallbackQuery(data="sw:tok-1:1")) + ) + + answer = _bot(adapter).answers[0] + assert answer["callback_query_id"] == "cq-1" + assert answer["show_alert"] is True + assert "no longer open" in answer["text"] + assert _bot(adapter).messages == [] + + +async def test_a_notice_longer_than_telegram_shows_is_cut_rather_than_dropped() -> None: + adapter = _adapter() + + async def refuse(interaction: InboundInteraction) -> None: + await adapter.tell_actor( + interaction.channel_id, interaction.sender_id, "someone", None, "why " * 100 + ) + + adapter.set_interaction_handler(refuse) + await adapter._handle_update( + _FakeUpdate(callback_query=_FakeCallbackQuery(data="sw:tok-1:1")) + ) + + text = _bot(adapter).answers[0]["text"] + assert len(text) == 200 + assert text.startswith("why why") + assert text.endswith("…") + + +async def test_a_press_taken_without_a_refusal_claims_nothing() -> None: + """The card's redraw is what says an answer was taken. Saying so here + would be saying it before the redraw that proves it.""" + adapter = _adapter() + + await _press(adapter, "sw:tok-1:1") + + assert _bot(adapter).answers[0]["text"] is None + assert _bot(adapter).answers[0]["show_alert"] is False + + +async def test_a_typed_answers_refusal_is_still_said_in_the_cards_thread() -> None: + """There is no press to reply to, and a bot cannot message someone who has + never opened a chat with it, so the thread is what is left.""" + adapter = _adapter() + + await adapter.tell_actor(CHANNEL, ASKER, "asker", f"{CHAT_ID}:99", "Not this one.") + + assert "Not this one." in _bot(adapter).messages[0]["text"] + + +async def test_telegram_refusing_the_acknowledgement_is_logged_and_left( + caplog: pytest.LogCaptureFixture, +) -> None: + """The query expires on its own and the answer it acknowledged is already + decided, so this must not cost the room the press.""" + adapter = _adapter() + _bot(adapter).answer_error = BadRequest("query is too old") + + with caplog.at_level("WARNING"): + assert len(await _press(adapter, "sw:tok-1:1")) == 1 + + assert any("would not acknowledge" in record.message for record in caplog.records) + + +async def _asked(request_id: str) -> RequestCard: + """A card for one of the recorded question forms.""" + source = FixtureEventSource.from_examples(QUESTIONS_PATH, events=[]) + projection = await project(source, "session-questions") + request = next( + one for one in projection.snapshot.requests if one.request_id == request_id + ) + return RequestCard(request, RequestReference(token="tok-1", handle="R43")) + + +async def test_one_question_with_one_choice_to_make_is_pressable() -> None: + """The one form a press finishes, so the one form that gets buttons.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _asked("request-one"), None) + + assert _keyboard(_posted(adapter)["reply_markup"]) == [ + ("1. Staging", "sw:tok-1:1"), + ("2. Production", "sw:tok-1:2"), + ] + + +async def test_a_form_no_single_press_can_finish_is_answered_in_words() -> None: + """Three questions, and one press is one option. Buttons here would submit + whichever part was pressed last as though it were the whole answer.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _asked("request-form"), None) + + assert _posted(adapter)["reply_markup"] is None + assert "R43" in _posted(adapter)["text"] From 7c6f52c34d52bff936529b01a5a1435393c370f8 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 02:46:36 +0100 Subject: [PATCH 010/120] Only offer a press where the card can be answered, and turn one down privately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found reviewing the Telegram interaction seam. A card whose body had to be cut still drew live buttons. The footer under a clipped form says the request cannot be answered from this message, but the keyboard beside it would still resolve against the saved form and settle the request on text the reader was never shown. The keyboard was deciding pressability from `unavailable_reason` alone, which is a different question from the one the renderer had already answered while composing. The renderer now returns that answer with the drawing. `render_request` yields a `Drawn(text, answerable)`; `request_summary` stays as it was for the three platforms whose drawing is the whole of the card. Telegram carries the `Drawn` from `_draw` through `_render_rich` to `_controls`, on send and on redraw, so the buttons and the sentence under them cannot disagree. A truncated button label is deliberately still pressable: the body above it carries the option in full. An answer turned down by authority went out through `admin_message`, so a rejected approval was posted to the whole channel while the presser's client showed nothing. It now goes through `tell_actor` like the refusals the card itself gives β€” privately on a platform that has a private reply, and otherwise into the card's thread, which is where it used to post anyway. The press path passes no thread ref, because the reply to a press is addressed by the press. Typed answers keep the card's thread and the answerer's name. The comment claiming every refused press was already private is corrected: it was true of the resolution branch, not of this one. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/bridge_core.py | 42 +++++++---- .../session/renderers/__init__.py | 21 ++++++ .../session/renderers/neutral.py | 42 +++++++++-- .../bridges/collaboration/telegram/adapter.py | 49 ++++++++----- .../collaboration/test_session_refusals.py | 46 ++++++++++++ .../collaboration/test_telegram_sdk_only.py | 72 +++++++++++++++++++ 6 files changed, 237 insertions(+), 35 deletions(-) diff --git a/core/switch_core/bridges/collaboration/bridge_core.py b/core/switch_core/bridges/collaboration/bridge_core.py index d2f6e41fa..a12e581b3 100644 --- a/core/switch_core/bridges/collaboration/bridge_core.py +++ b/core/switch_core/bridges/collaboration/bridge_core.py @@ -1310,15 +1310,16 @@ async def _handle_inbound_interaction( if isinstance(outcome, Refused): # outcome.card_ref is available here too, but deliberately unused: # a press only reaches a platform whose buttons are live, and on - # each of those the reply to a press is already private to whoever - # pressed β€” Slack's ephemeral, Telegram's alert on the press - # itself. thread_ref would only choose where a private notice - # appeared on screen, not who saw it. + # each of those the reply to a press is private to whoever pressed + # β€” Slack's ephemeral, Telegram's alert on the press itself. + # thread_ref would only choose where a private notice appeared on + # screen, not who saw it. await self._tell_refused(interaction, outcome, thread_ref=None) return - await self._submit_session_command( - outcome, interaction.channel_id, interaction.message_ref - ) + # Both ways a press can be turned down go the same way out. The two + # were split once, and the half that went through the channel put one + # person's rejected approval in front of everybody in it. + await self._submit_session_command(outcome, interaction, thread_ref=None) async def _handle_text_answer(self, msg: InboundMessage) -> None: """The same answer, typed rather than pressed. @@ -1339,7 +1340,7 @@ async def _handle_text_answer(self, msg: InboundMessage) -> None: await self._tell_refused(msg, outcome, thread_ref=outcome.card_ref) return await self._submit_session_command( - outcome, msg.channel_id, msg.root_id or msg.message_ref + outcome, msg, thread_ref=msg.root_id or msg.message_ref ) async def _tell_refused( @@ -1404,8 +1405,23 @@ async def refresh_sdk_session(self, session_id: str) -> None: self._session_publisher.wake() async def _submit_session_command( - self, command: Command | None, channel_id: str, message_ref: str | None + self, command: Command | None, actor: InboundActor, thread_ref: str | None ) -> None: + """Give the session the answer, and tell the answerer if it bounced. + + Authority is the second thing that can turn an answer down, after the + resolution that built it, and it turns down the same kinds of thing: + not yours to decide, too late, already answered. So it is reported the + same way β€” to the person who answered, as privately as the platform + allows β€” rather than as a notice to the channel. `tell_actor` is what + knows the difference per platform, and on a platform with no private + reply it still lands in the card's thread, which is where this used to + post anyway. + + `thread_ref` is None for a press: the reply to a press is addressed by + the press itself, and the channel root would be a wider audience than + the card's own thread rather than a narrower one. + """ if command is None: return try: @@ -1413,10 +1429,12 @@ async def _submit_session_command( command, user_id=None, bridge_id=self._bridge_id ) except SessionError as error: - await self._adapter.admin_message( - channel_id, + await self._adapter.tell_actor( + actor.channel_id, + actor.sender_id, + actor.sender_name, + thread_ref, f"Answer was not accepted ({error.code}): {error}", - message_ref, ) return await self.refresh_sdk_session(command.session_id) diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index 93287700e..9cb8f00fe 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -280,6 +280,27 @@ def offered_controls(request: SnapshotRequest) -> list[Control]: ] +@dataclass(frozen=True) +class Drawn: + """A rendering of a request, and whether it can be answered where it shows. + + The two travel together because only the renderer knows both, and it knows + them at the same moment: whether the body had to be cut is settled while it + is being composed, and a form cut short of the difference between two + options cannot be answered from what is on the screen β€” which is why the + footer under a cut form stops asking to be answered there. + + `offered_controls` says which presses a request *could* have. This says + whether this particular drawing of it earned them. A platform that draws + buttons needs both: without this, a live control ends up under the very + sentence explaining that the form cannot be answered here, and a press on + it decides something the reader was never shown. + """ + + text: str + answerable: bool + + class Markup: """The three marks the neutral renderer makes, in one platform's spelling. diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 040cd56e4..9b293a8a2 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -53,6 +53,7 @@ CLOSED, NO_OPTIONS, SURFACES, + Drawn, Markup, RequestReference, example_value, @@ -315,6 +316,33 @@ def request_summary( responder: str | None = None, unavailable_reason: str | None = None, ) -> str: + """`render_request`, for a platform whose drawing is the whole of the card. + + A platform with no controls of its own has nothing to do with the rest of + the result: the body already says how to answer, or says it cannot be + answered here. + """ + return render_request( + request, + reference, + escape=escape, + limit=limit, + markup=markup, + responder=responder, + unavailable_reason=unavailable_reason, + ).text + + +def render_request( + request: SnapshotRequest, + reference: RequestReference, + *, + escape: Callable[[str], str], + limit: int, + markup: Markup, + responder: str | None, + unavailable_reason: str | None, +) -> Drawn: """The text form of a request: the question, the options, and how to answer. The same function serves the first post and every edit after it, because @@ -344,6 +372,11 @@ def request_summary( is too long, and names Console. Only an instruction is replaced this way β€” a card that already says why it cannot be answered says it better than this would. + + Whether it came to that is the other half of the result. It is decided + here, from the same facts that decide the footer, so a platform drawing + controls beside the body does not have to work it out a second way β€” and + cannot come to a different answer than the words under its own buttons. """ content = request.content if isinstance(content, ApprovalContent): @@ -370,13 +403,14 @@ def request_summary( body = [] footer = _fit(unavailable_reason, max(1, limit // 3), escape=escape) invites_answer = False - return _compose( + text, cut = _compose( head, body, footer, limit=limit, if_cut=_TOO_BIG if invites_answer else footer, ) + return Drawn(text=text, answerable=invites_answer and not cut) def _approval_form( @@ -715,8 +749,8 @@ def _actor( def _compose( head: list[str], body: list[str], footer: str, *, limit: int, if_cut: str -) -> str: - """Head, as much of the body as fits, then the footer β€” which always survives. +) -> tuple[str, bool]: + """Head, as much of the body as fits, then the footer β€” and whether it cut. The body is what gets dropped because it is the part a reader can recover elsewhere: an option they cannot see is still an option, and the notice @@ -757,7 +791,7 @@ def _compose( notice = _CUT.format(left=len(body) - len(shown)) if spent + len(notice) + 1 <= limit: shown.append(notice) - return "\n".join([*lines, *shown, if_cut if cut else footer]) + return "\n".join([*lines, *shown, if_cut if cut else footer]), cut def _mentioned(mention: str | None, body: str) -> str: diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 02aeb4d42..8f814f6ba 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -61,13 +61,14 @@ ) from switch_core.bridges.collaboration.session.renderers import ( Control, + Drawn, Markup, offered_controls, position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( activity_detail, - request_summary, + render_request, turn_status, ) from switch_core.bridges.collaboration.telegram.chunking import ( @@ -1354,7 +1355,7 @@ def rich_fallback_text(self, content: RichContent) -> str: it is what a caller logs or shows in the Console when the post did not happen, not something anyone reads in the chat. """ - return self._draw(content, mention=None, responder=None, prefix="") + return self._draw(content, mention=None, responder=None, prefix="").text def _draw( self, @@ -1363,7 +1364,7 @@ def _draw( mention: str | None, responder: str | None, prefix: str, - ) -> str: + ) -> Drawn: escape = self._rich_escape limit = max(1, self.rich_fallback_limit() - len(prefix)) markup = self.rich_markup() @@ -1391,13 +1392,13 @@ def _draw( content, escape=escape, budget=limit - len(body) - len(tail) ) ) - return f"{prefix}{body}{detail}{tail}" + return Drawn(text=f"{prefix}{body}{detail}{tail}", answerable=False) # The mention goes on its own line rather than in front of the heading: # a card is a block, and a handle wedged before "Permission needed" # reads as part of the heading. lead = f"{mention}\n" if mention else "" tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" - body = request_summary( + drawn = render_request( content.request, content.reference, escape=escape, @@ -1406,7 +1407,7 @@ def _draw( responder=responder, unavailable_reason=content.unavailable_reason, ) - return f"{prefix}{lead}{body}{tail}" + return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") def _expandable( self, @@ -1444,7 +1445,9 @@ def _expandable( quoted = "\n".join(lines) return f"\n{_EXPAND_OPEN}{quoted}{_EXPAND_CLOSE}" - def _controls(self, content: RichContent, text: str) -> InlineKeyboardMarkup | None: + def _controls( + self, content: RichContent, drawn: Drawn + ) -> InlineKeyboardMarkup | None: """The card's options as buttons, or nothing where a press cannot land. One per row. An option's label is a phrase more often than a word, and @@ -1460,10 +1463,17 @@ def _controls(self, content: RichContent, text: str) -> InlineKeyboardMarkup | N moment it stops being pressable, without anything having to remember that it once had them. - `text` is the drawing these belong to, carried only so a refusal can - report what could not be posted. + Which of those it is comes from `drawn`, not from reading the request a + second time. A long detail or a clipped option leaves a body the reader + cannot decide from, and only the renderer that cut it knows that. A + press would still resolve against the saved form and settle the + request β€” so the whole of the protection is not offering the button. + + A truncated *label* is not that case. The body above it carries the + option in full, so the reader has what they are agreeing to and the + button is only the shortest way to say which one. """ - if not isinstance(content, RequestCard) or content.unavailable_reason: + if not isinstance(content, RequestCard) or not drawn.answerable: return None rows: list[list[InlineKeyboardButton]] = [] for control in offered_controls(content.request): @@ -1473,14 +1483,14 @@ def _controls(self, content: RichContent, text: str) -> InlineKeyboardMarkup | N f"Cannot put a button on request {content.request.request_id} in " f"Telegram: its press would carry {len(data.encode())} bytes and " f"Telegram allows {_MAX_CALLBACK_BYTES}.", - text=text, + text=drawn.text, ) rows.append( [InlineKeyboardButton(text=_button_label(control), callback_data=data)] ) return InlineKeyboardMarkup(rows) if rows else None - async def _render_rich(self, content: RichContent, agent_name: str) -> str: + async def _render_rich(self, content: RichContent, agent_name: str) -> Drawn: """Draw `content` as the agent, for one Telegram chat. The name is always in the body. Telegram gives a bot no per-message @@ -1571,10 +1581,11 @@ async def post_rich( `rich_fallback_limit` for exactly that reason and `_clamp` is the backstop if something still overruns. """ - text = await self._render_rich(content, agent_name) + drawn = await self._render_rich(content, agent_name) + text = drawn.text self._refuse_while_throttled(text) self._pace_publication(channel_id, content, text) - controls = self._controls(content, text) + controls = self._controls(content, drawn) anchor = await self._publication_anchor(channel_id, thread_root_id, text) try: sent = await self._require_bot().send_message( @@ -1633,16 +1644,16 @@ async def update_rich( # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the chat that never reaches anybody it # has not already reached. - text = await self._render_rich( + drawn = await self._render_rich( replace(content, notify_external_id=None), agent_name ) - self._refuse_while_throttled(text) + self._refuse_while_throttled(drawn.text) if _retires(content): - await self._retire_rich(channel_id, message_ref, text) + await self._retire_rich(channel_id, message_ref, drawn.text) return - self._pace_publication(channel_id, content, text) + self._pace_publication(channel_id, content, drawn.text) await self._edit_rich( - channel_id, message_ref, text, self._controls(content, text) + channel_id, message_ref, drawn.text, self._controls(content, drawn) ) async def _retire_rich(self, channel_id: str, message_ref: str, text: str) -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_refusals.py b/core/tests/switch_core/bridges/collaboration/test_session_refusals.py index df009e417..f54590bd7 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_refusals.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_refusals.py @@ -15,6 +15,7 @@ from __future__ import annotations import logging +from types import SimpleNamespace from typing import Any import pytest @@ -23,6 +24,7 @@ from switch_core.bridges.collaboration.session.renderers import ANSWER_ACTION from switch_core.bridges.collaboration.slack.adapter import SlackAdapter from switch_core.bridges.collaboration.telegram.adapter import TelegramAdapter +from switch_core.sessions.service import SessionError from .test_session_answers import _interactions, _post, _press, _run from .test_session_text_answers import CARD, _bridge, _typed @@ -287,6 +289,50 @@ def test_a_press_is_answered_back_in_the_channel() -> None: ] +def _authority_refuses(bridge: Any, code: str) -> None: + """Authority takes the answer and turns it down β€” the second refusal. + + Not the same thing as the card refusing it: the press was well formed and + named a real option, and it is the session that says no. Stale revision, + an epoch that has moved on, an account without the authority to decide. + """ + + async def _submit(*_args: Any, **_kwargs: Any) -> None: + raise SessionError(code, "this account cannot decide this request") + + bridge._session_authority = SimpleNamespace(submit=_submit) + + +def test_a_press_the_session_turns_down_is_told_to_the_presser_alone() -> None: + """The refusal that comes back from authority goes the same way as the one + the card gives. It was public once, which put "not authorised" under a + request in front of everyone in the group, and told the person nothing + where their client was showing a spinner.""" + bridge, _ = _bridge(_interactions(_post())) + _authority_refuses(bridge, "NOT_AUTHORIZED") + + _run(bridge._handle_inbound_interaction(_press())) + + assert [(actor, thread) for _, actor, _, thread, _ in bridge._adapter.told] == [ + ("U1", None) + ] + assert "NOT_AUTHORIZED" in bridge._adapter.told[0][4] + + +def test_a_typed_answer_the_session_turns_down_is_still_said_in_the_thread() -> None: + """Deliberately unchanged for typing: there is no press to reply to, so the + card's own thread is the only place it can be said, and the notice names + whose answer it was because several people can be answering there.""" + bridge, _ = _bridge(_interactions(_post())) + _authority_refuses(bridge, "STALE_REVISION") + + _run(bridge._handle_inbound_message(_typed("R42 1", root_id=CARD))) + + assert [ + (actor, name, thread) for _, actor, name, thread, _ in bridge._adapter.told + ] == [("U1", "someone", CARD)] + + def test_an_answer_that_did_land_is_not_answered_back() -> None: bridge, _ = _bridge(_interactions(_post())) diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 2df2ea369..5a26f2146 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -1011,6 +1011,78 @@ async def test_a_card_that_cannot_be_answered_here_offers_nothing_to_press() -> assert _posted(adapter)["reply_markup"] is None +async def _clipped_detail(**kwargs: Any) -> RequestCard: + """A card whose decision text is longer than a Telegram message can hold.""" + card = await _card(**kwargs) + return replace( + card, + request=card.request.model_copy( + update={ + "content": card.request.content.model_copy( + update={"detail": "Deletes the production volume. " * 200} + ) + } + ), + ) + + +async def test_a_card_that_could_not_show_its_decision_offers_nothing_to_press() -> ( + None +): + """The body says it is too long to answer here β€” and a button beside that + sentence answers it anyway. The press would resolve against the saved form + and settle the request on text the reader never saw.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _clipped_detail(), None) + + assert "cannot be answered from this message" in _posted(adapter)["text"] + assert _posted(adapter)["reply_markup"] is None + + +async def test_a_card_whose_options_did_not_all_fit_offers_nothing_to_press() -> None: + """Each option is faithful on its own and there is no room for them all. + A keyboard here would offer a choice against a list the reader can only + see part of, which is the same defect reached by the other door.""" + adapter = _adapter() + card = await _card() + crowded = card.request.model_copy( + update={ + "content": card.request.content.model_copy( + update={ + "options": [ + card.request.content.options[0].model_copy( + update={ + "option_id": f"option-{index}", + "label": f"Option {index}: " + "a" * 1000, + } + ) + for index in range(10) + ] + } + ) + } + ) + + await adapter.post_rich(CHANNEL, "my-agent", replace(card, request=crowded), None) + + assert "more not shown" in _posted(adapter)["text"] + assert _posted(adapter)["reply_markup"] is None + + +async def test_a_redraw_takes_the_buttons_off_a_card_that_stopped_fitting() -> None: + """The same rule on the edit path. A card that grew past what one message + can show keeps its keyboard otherwise, because an edit carries the whole + of it and a redraw that says nothing about controls leaves them live.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + await adapter.update_rich(CHANNEL, "my-agent", ref, await _clipped_detail()) + + assert _keyboard(_posted(adapter)["reply_markup"]) != [] + assert _edited(adapter)["reply_markup"] is None + + async def test_a_status_has_nothing_to_press() -> None: adapter = _adapter() From e48d1e756af75341a0579deedf02d1f3210b1e7b Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 04:09:46 +0100 Subject: [PATCH 011/120] Tell the truth about a working reaction that would not come off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram and Discord both swallowed a refused reaction and returned as though it had worked. For a refused *addition* that is right: reactions are switched off in the chat, or the bot lacks the permission, and the turn should go on without the mark rather than retry it forever. For a refused *removal* it is a false statement β€” the πŸ‘€ is still on the message, the channel shows an agent working on something it finished, and reporting the turn as drawn stops the publisher ever asking again. Telegram tried to tell the two apart from `self._reacted`, a set a restart empties. Empty then means "no idea", not "nothing was added", so after a restart every stuck mark read as nothing to remove. Discord did not try at all and logged both. Both now raise `ActivityMarkRefused`, which says only that another attempt will be refused the same way; anything a retry might fix still raises as itself. The publisher decides what a refusal means, because the question β€” was a mark ever put there β€” is answered by the durable journal record and not by adapter memory. A refused addition is recorded on the turn's own record, and only that recorded refusal lets a refused removal count as done. Absence of evidence means the cleanup is outstanding and the turn is not drawn. Discord's pre-SDK path keeps deciding for itself, which is sound there: it attempts a removal only for a mark this process added, so its own memory is enough to know the mark is outstanding. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 13 ++++ .../bridges/collaboration/discord/adapter.py | 63 ++++++++------- .../collaboration/session/activity_journal.py | 45 +++++++++++ .../bridges/collaboration/session/outbound.py | 65 ++++++++++++++++ .../bridges/collaboration/telegram/adapter.py | 44 +++++------ .../collaboration/test_discord_sdk_only.py | 50 ++++++++---- .../collaboration/test_telegram_sdk_only.py | 50 ++++++------ .../sessions/test_activity_durability.py | 76 ++++++++++++++++++- 8 files changed, 309 insertions(+), 97 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 9f9dc8ab2..f10d37198 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -212,6 +212,19 @@ def __init__(self, *, retry_after: float, text: str) -> None: self.retry_after = retry_after +class ActivityMarkRefused(RuntimeError): + """The platform will not change the work mark, and another attempt will not. + + Reactions switched off in the chat, or a permission the bot does not have: + refused now means refused for the life of the turn. Anything a retry might + fix is left to raise as itself, so the publisher can tell the two apart. + + Raised for a refused *removal* as well as a refused addition. Whether that + matters is not the adapter's to decide β€” it depends on whether a mark was + ever put there, which only the durable record knows after a restart. + """ + + class CollaborationAdapter(ABC): # Platforms opt in only when their SDK request and activity rendering is ready. publishes_sdk_sessions: ClassVar[bool] = False diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 83ec3f242..1ec2dfdc5 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -19,6 +19,7 @@ from switch_core.bridges.agent.commands import COMMANDS_BY_NAME from switch_core.bridges.agent.commands import Command as InRoomCommand from switch_core.bridges.collaboration.adapter import ( + ActivityMarkRefused, CollaborationAdapter, LiveRuntimeIndicator, RequestCard, @@ -1654,6 +1655,11 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: as well as inside a thread β€” so it is the progress signal that is always available. A guild that has not granted the permission gets one warning and no reaction, rather than a mark that is not there. + + This path has no durable record, so it answers the refused-removal + question from `self._eyes` β€” which is sound only because it will not + attempt a removal at all unless this process put the mark there. The + reaction is then known to be outstanding, and is reported as such. """ _, message_id = self._parse_message_ref(message_ref) if not message_id or self._client is None: @@ -1663,6 +1669,14 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: try: await self._react(message_ref, working=working) + except ActivityMarkRefused as refusal: + if working: + logger.warning("%s", refusal) + else: + logger.error( + "%s The mark this process put there is still on the message.", + refusal, + ) except (discord.HTTPException, ValueError) as e: logger.warning( "Could not %s the working reaction on Discord message %s: %s", @@ -1678,12 +1692,14 @@ async def _react(self, message_ref: str, *, working: bool) -> None: or this guild will never allow the reaction. Everything else is left to raise, so a caller that can try again knows it should. - The two finals are not the same failure, and the log says which. A mark - that could not be added is a mark nobody sees, and the turn goes on - without it. A mark that could not be *removed* is still on the message, - saying an agent is working on something it finished β€” worse than the - first, because it is not an absence but a false statement, and no - retry here will take it back. + A missing permission is final *here* β€” the same call would be refused + the same way β€” so it is raised as `ActivityMarkRefused` rather than + swallowed. It is not final for the turn: access to a channel can come + back, and a mark that could not be *removed* is still on the message + saying an agent is working on something it finished. That is not an + absence but a false statement, and whether it is outstanding is a + question about what was put there, which the publisher's durable record + answers and this method cannot. """ location_id, message_id = self._parse_message_ref(message_ref) client = self._require_client() @@ -1700,28 +1716,21 @@ async def _react(self, message_ref: str, *, working: bool) -> None: # The message (or the reaction) is gone; the end state is what was # wanted either way. self._eyes.discard(message_ref) - except discord.Forbidden: + except discord.Forbidden as error: if working: - logger.warning( - "Discord refused the working reaction on %s β€” the bot is " - "missing the Add Reactions permission here. Turns still show " - "their status message; only the mark on the message being " - "answered is missing. Re-invite the bot with the permissions " - "in DISCORD_SETUP.md.", - message_ref, - ) - return - logger.error( - "Discord refused to take the working reaction off %s, so %s is " - "left on a message whose turn has ended and the channel shows an " - "agent still working on something it has finished. Removing our " - "own reaction needs no permission of its own, so this is the " - "bot's access to the channel rather than the reaction: check it " - "can still see %s. The mark will not come off by retrying.", - message_ref, - _WORKING_REACTION, - message_ref, - ) + raise ActivityMarkRefused( + f"Discord refused the working reaction on {message_ref} β€” the " + f"bot is missing the Add Reactions permission here. Turns still " + f"show their status message; only the mark on the message being " + f"answered is missing. Re-invite the bot with the permissions " + f"in DISCORD_SETUP.md." + ) from error + raise ActivityMarkRefused( + f"Discord refused to take the working reaction off {message_ref}. " + f"Removing our own reaction needs no permission of its own, so this " + f"is the bot's access to the channel rather than the reaction: check " + f"it can still see {message_ref}." + ) from error async def _clear_working(self, channel_id: str, agent_name: str) -> None: live = self._working_msg.pop((channel_id, agent_name), None) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index 6ee1deb7e..3a5d474c1 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -94,6 +94,51 @@ async def reaction_held( for row in rows ) + async def reaction_refused( + self, + channel: str, + ref: str, + *, + agent_name: str | None = None, + sessions: async_sessionmaker[AsyncSession], + ) -> bool: + """Whether a turn on this message was refused the mark outright. + + Asked when a *removal* is refused, to tell a mark that is still sitting + on the message from one that was never put there. A process's own + memory cannot answer it: a restart empties that, and empty then means + "no idea" rather than "nothing was added". Recording the refusal is + what survives. + + Absence is not evidence, so absence means outstanding. A turn whose + addition succeeded records nothing here and a refused removal is + therefore treated as a mark still on the message, which is the truthful + direction: the cost of being wrong is a retry, and the cost of the + opposite is a channel showing an agent working on something it + finished. + + Ended turns are counted, unlike `reaction_held`. A turn being over says + nothing about whether it left a mark behind β€” that is the whole of what + went wrong before. + """ + anchor: dict[str, str] = {"channel_id": channel, "reaction_ref": ref} + if agent_name is not None: + anchor["agent_name"] = agent_name + async with sessions() as db: + return bool( + ( + await db.scalars( + select(SessionActivityPost).where( + SessionActivityPost.tenant_id == require_tenant_id(), + SessionActivityPost.bridge_id == self.bridge_id, + SessionActivityPost.data.contains( + {"anchor": anchor, "reaction_refused": True} + ), + ) + ) + ).first() + ) + @asynccontextmanager async def open( self, session_id: str, command_id: str diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 45193b67f..56a078984 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -45,6 +45,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from switch_core.bridges.collaboration.adapter import ( + ActivityMarkRefused, CollaborationAdapter, RequestCard, RichContentFailed, @@ -818,6 +819,17 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: kept on the anchor as `reaction_ref` for exactly this. Adapter-owned and best effort: errors do not interrupt rendering. Return whether it worked so callers can retry a failed claim or unfinished terminal cleanup. + + A platform that refuses the mark outright raises `ActivityMarkRefused`, + and what that means depends on which way it was going. Refused on the + way *on*, the mark is simply absent: the turn goes on without it, and + the refusal is recorded so a later removal knows there was never + anything there. Refused on the way *off*, the question is whether a + mark is still sitting on the message, and only the durable record can + answer it once a restart has emptied this process's memory. No recorded + refusal means the cleanup is unfinished, and saying otherwise would + leave a channel showing an agent still working on something it has + finished. """ if anchor.reaction_ref is None or not getattr( self._adapter, "supports_activity_reactions", False @@ -832,6 +844,26 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: **({"force": True} if self._journal else {}), ) return True + except ActivityMarkRefused as refusal: + if working: + await self._remember_refusal() + logger.warning( + "%s The turn goes on without the mark.", + refusal, + ) + return True + if await self._nothing_was_marked(anchor): + logger.warning( + "%s Nothing was ever put on it, so there is nothing to take off.", + refusal, + ) + return True + logger.error( + "%s The mark is still on the message and this turn is not " + "finished until it comes off.", + refusal, + ) + return False except Exception: logger.warning( "Could not %s the activity reaction on %s in %s.", @@ -842,6 +874,39 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: ) return False + async def _remember_refusal(self) -> None: + """Record that this message would not take the mark at all. + + On the turn's own journal row, which is discarded when the turn + completes β€” so the memory lasts exactly as long as it can be relevant. + Without a journal there is nothing to remember it in, and a restart is + not survivable anyway. + """ + record = self._record.get() + if record is None: + return + record.data["reaction_refused"] = True + await record.save() + + async def _nothing_was_marked(self, anchor: _Anchor) -> bool: + """Whether a refused removal is safe to treat as already done. + + Only a recorded refusal makes it safe. Absence of evidence is not + evidence, so an unjournalled publisher β€” which cannot survive a restart + to be wrong in the first place β€” keeps the behaviour it had. + """ + if self._journal is None: + return True + record = self._record.get() + if record is not None and record.data.get("reaction_refused"): + return True + return await self._journal.reaction_refused( + anchor.channel_id, + anchor.reaction_ref or "", + agent_name=anchor.agent_name if self._reactions_per_agent else None, + sessions=record.sessions if record else self._journal.sessions, + ) + async def _forget_the_oldest(self) -> None: """Bound the in-memory cache, retaining durable message references. diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 8f814f6ba..9fa3a6588 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -36,6 +36,7 @@ from switch_core.bridges.agent.commands import COMMANDS, COMMANDS_BY_NAME, CommandArg from switch_core.bridges.collaboration.adapter import ( + ActivityMarkRefused, CollaborationAdapter, LiveRuntimeIndicator, RequestCard, @@ -1831,20 +1832,18 @@ async def mark_activity( Raises where another attempt might work, so the publisher retries and records the turn as drawn only once the chat shows what it says it - shows. A chat that refuses to *add* the mark is not that: reactions are - switched off there, or the bot may not use them, and it would be - refused the same way for the life of the turn β€” so it is reported once - and the turn goes on without it. - - A refused *removal* of a mark this process put there is the opposite - case and is raised. The mark is on the message and the chat is showing - this turn as still running; reporting that as cleaned up would have the - publisher stop asking, and permission coming back later would change - nothing. Retried and still outstanding is the truth, so that is what the - caller is told. Where the mark is not this process's β€” a reconciling - `force` after a restart, or a chat that refused to add one at all β€” - there is nothing known to be outstanding, and the refusal is reported - the way a refused addition is rather than held against a turn forever. + shows. A chat that will not take the mark at all is not that β€” reactions + are switched off there, or the bot may not use them, and it would be + refused the same way for the life of the turn β€” so that is raised as + `ActivityMarkRefused` and the publisher decides what it means. + + It decides, and not this method, because the question a refused + *removal* asks is whether a mark is still sitting on the message, and + the answer does not live here. `self._reacted` is this process's memory + and a restart empties it; empty then means "no idea", not "nothing was + added". Treating those as the same is how a turn came to be recorded as + cleaned up with the πŸ‘€ still on the message. The durable record knows + whether an addition was ever refused, so the durable record is asked. """ _, message_id = self._parse_message_ref(message_ref) if not message_id: @@ -1863,19 +1862,10 @@ async def mark_activity( reaction=[ReactionTypeEmoji(_WORKING_REACTION)] if working else [], ) except (BadRequest, Forbidden) as error: - if not working and key in self._reacted: - raise - # Reactions are off in this chat, or the bot may not react in it. - # Refused now is refused for the rest of the turn. - logger.warning( - "Telegram will not %s the working reaction on %s in chat %s " - "(%s); the turn goes on without it.", - "add" if working else "remove", - message_id, - channel_id, - error, - ) - return + raise ActivityMarkRefused( + f"Telegram will not {'add' if working else 'remove'} the working " + f"reaction on {message_id} in chat {channel_id} ({error})." + ) from error if working: self._reacted.add(key) else: diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index cdbcc9cfe..97ab6da08 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -21,6 +21,7 @@ import pytest from switch_core.bridges.collaboration.adapter import ( + ActivityMarkRefused, RequestCard, RichContentFailed, RichContentThrottled, @@ -941,13 +942,11 @@ async def test_force_marks_again_because_the_record_may_be_empty_and_wrong() -> assert channel.reactions == [("πŸ‘€", True), ("πŸ‘€", True)] -async def test_a_missing_permission_is_not_retried_for_the_life_of_the_turn( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_a_missing_permission_is_refused_rather_than_swallowed() -> None: adapter, channel, _thread, _webhook = _guild_setup() channel.reaction_error = discord.Forbidden(_Response(), "no Add Reactions") # type: ignore[arg-type] - with caplog.at_level(logging.WARNING): + with pytest.raises(ActivityMarkRefused, match="Add Reactions"): await adapter.mark_activity( str(CHANNEL_ID), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", @@ -955,17 +954,14 @@ async def test_a_missing_permission_is_not_retried_for_the_life_of_the_turn( working=True, ) - assert "Add Reactions" in caplog.text - -async def test_a_mark_that_cannot_be_taken_off_is_an_error_not_a_shrug( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_a_mark_that_cannot_be_taken_off_is_refused_not_shrugged_away() -> None: """A missing mark is an absence; a stuck one is a false statement. - The channel goes on showing an agent working on something it finished, and - no retry here takes it back, so this is not the same event as a mark that - could not be added in the first place. + Both are refusals the adapter reports rather than logs, because whether a + mark is still on the message depends on what was put there β€” a question + this adapter cannot answer once a restart has emptied its memory, and the + publisher's durable record can. """ adapter, channel, _thread, _webhook = _guild_setup() ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" @@ -974,13 +970,39 @@ async def test_a_mark_that_cannot_be_taken_off_is_an_error_not_a_shrug( ) channel.reaction_error = discord.Forbidden(_Response(), "cannot see the channel") # type: ignore[arg-type] - with caplog.at_level(logging.WARNING): + with pytest.raises(ActivityMarkRefused, match="still see"): await adapter.mark_activity( str(CHANNEL_ID), ref, agent_name="my-agent", working=False ) + +async def test_the_pre_sdk_path_still_says_which_refusal_it_hit( + caplog: pytest.LogCaptureFixture, +) -> None: + """A turn with no durable record behind it answers the question itself. + + It can, because it only reaches a removal for a mark this process put + there: an absent mark is a warning and a stuck one is an error, as they + were before the refusal became an exception. + """ + adapter, channel, _thread, _webhook = _guild_setup() + ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + channel.reaction_error = discord.Forbidden(_Response(), "no Add Reactions") # type: ignore[arg-type] + + with caplog.at_level(logging.WARNING): + await adapter._track_turn(str(CHANNEL_ID), ref, "my-agent", state="working") + assert [record.levelname for record in caplog.records] == ["WARNING"] + + caplog.clear() + channel.reaction_error = None + await adapter._track_turn(str(CHANNEL_ID), ref, "my-agent", state="working") + channel.reaction_error = discord.Forbidden(_Response(), "cannot see the channel") # type: ignore[arg-type] + + with caplog.at_level(logging.WARNING): + await adapter._track_turn(str(CHANNEL_ID), ref, "my-agent", state="completed") + assert [record.levelname for record in caplog.records] == ["ERROR"] - assert "will not come off by retrying" in caplog.text + assert "still on the message" in caplog.text async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 5a26f2146..057f3bd96 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -29,6 +29,7 @@ ) from switch_core.bridges.collaboration.adapter import ( + ActivityMarkRefused, CollaborationAdapter, RequestCard, RichContentFailed, @@ -806,51 +807,44 @@ async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> ) -async def test_a_chat_with_reactions_off_is_not_retried_for_the_whole_turn( - caplog: pytest.LogCaptureFixture, -) -> None: - """Refused now is refused every time, so it is said once and the turn goes - on without the mark.""" +async def test_a_chat_with_reactions_off_says_so_rather_than_swallowing_it() -> None: + """Refused now is refused every time, and the adapter says which kind of + failure that is. What it means for the turn is the publisher's to decide.""" adapter = _adapter() _bot(adapter).reaction_error = BadRequest("REACTION_INVALID") - await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True - ) + with pytest.raises(ActivityMarkRefused): + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + ) - assert any("without it" in record.message for record in caplog.records) +async def test_a_refused_removal_is_reported_whatever_this_process_remembers() -> None: + """The adapter does not decide whether a mark is outstanding. -async def test_a_mark_that_could_not_be_removed_is_not_reported_as_removed() -> None: - """Add the eyes, lose the permission, end the turn: the mark is still on - the message. Reported as cleaned up, the publisher stops asking and the - chat shows the turn as running for good.""" + It used to, by looking in a set that a restart empties β€” so after one, a + refused removal looked like nothing to remove. Both of these refuse + identically now; the difference is the durable record's to know, and + `test_activity_durability` is where that is pinned. + """ adapter = _adapter() await adapter.mark_activity( CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True ) _bot(adapter).reaction_error = Forbidden("the bot may no longer react here") - with pytest.raises(Forbidden): + with pytest.raises(ActivityMarkRefused): await adapter.mark_activity( CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=False ) + fresh = _adapter() + _bot(fresh).reaction_error = Forbidden("the bot may no longer react here") -async def test_clearing_a_mark_nothing_put_there_is_not_held_against_the_turn( - caplog: pytest.LogCaptureFixture, -) -> None: - """A chat with reactions switched off refuses to clear one as readily as to - add one, and there is nothing there to clear. Raising would leave every - turn in that chat retrying its cleanup for ever.""" - adapter = _adapter() - _bot(adapter).reaction_error = BadRequest("REACTION_INVALID") - - await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=False, force=True - ) - - assert any("without it" in record.message for record in caplog.records) + with pytest.raises(ActivityMarkRefused): + await fresh.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=False, force=True + ) async def test_the_typing_nudge_is_sent_where_the_agent_was_asked() -> None: diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 2e50b8154..2d9fa351c 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -6,7 +6,10 @@ import pytest from mattermostdriver.exceptions import NotEnoughPermissions -from switch_core.bridges.collaboration.adapter import RichContentThrottled +from switch_core.bridges.collaboration.adapter import ( + ActivityMarkRefused, + RichContentThrottled, +) from switch_core.bridges.collaboration.session.activity_journal import ActivityJournal from switch_core.bridges.collaboration.session.outbound import ( CardNotPosted, @@ -750,3 +753,74 @@ async def test_a_post_mattermost_refused_is_reserved_again_and_retried( posts.create_error = None assert await publish(activity(session_factory, adapter)) assert len(posts.created) == 1 + + +class RefusingPlatform(ActivitySlack): + """A chat that will not take the mark, keeping its own state across a restart. + + The reactions live in a set owned by the test rather than by the adapter, + because that is the arrangement the defect needs: the channel remembers + what is on the message and a new process does not. + """ + + def __init__(self, chat, *, refuse_add=False, refuse_remove=False): + super().__init__() + self.reactions = chat["reactions"] + self.messages = chat["messages"] + self.refuse_add = refuse_add + self.refuse_remove = refuse_remove + + async def mark_activity(self, channel, ref, *, agent_name, working, force=False): + if working: + if self.refuse_add: + raise ActivityMarkRefused("reactions are switched off in this chat") + self.reactions.add(ref) + elif self.refuse_remove: + raise ActivityMarkRefused("the bot may no longer react here") + else: + self.reactions.discard(ref) + + +async def test_a_mark_left_on_the_message_after_a_restart_is_not_reported_as_cleaned_up( + session_factory, +): + """Add the mark, restart, lose the permission, end the turn. + + The mark is still on the message, so the chat is saying an agent is working + on something it has finished. Reporting the turn as finished stops the + publisher asking, and permission coming back later then changes nothing. + The old code decided this from a process-local set that the restart had + emptied, read an empty set as "nothing was added", and returned success. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + assert await publish(activity(session_factory, RefusingPlatform(chat))) + assert chat["reactions"] == {"channel-demo:question"} + + after_restart = RefusingPlatform(chat, refuse_remove=True) + drawn = await publish(activity(session_factory, after_restart), "completed") + + assert chat["reactions"] == {"channel-demo:question"} + assert not drawn + + +async def test_a_chat_that_never_took_the_mark_does_not_hold_the_turn_open( + session_factory, +): + """The other half, and the reason the answer cannot simply be "always raise". + + A chat with reactions switched off refuses to clear a mark as readily as to + add one, and there is nothing there to clear. Holding the turn open for it + would leave every turn in that chat retrying its cleanup for ever. The + refusal to add is recorded when it happens, so the refusal to remove is + still understood after a restart. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + first = RefusingPlatform(chat, refuse_add=True) + assert await publish(activity(session_factory, first)) + assert not chat["reactions"] + + after_restart = RefusingPlatform(chat, refuse_add=True, refuse_remove=True) + + assert await publish(activity(session_factory, after_restart), "completed") From 7285215031c819e939f2df10d4268c1639b4cd88 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 04:30:02 +0100 Subject: [PATCH 012/120] Make the reaction evidence about the mark, not about a turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of e48d1e75 found the evidence attached to the wrong thing. Turns share one reaction: only the first to claim an asking message adds the mark, only the last to finish takes it off, and those are rarely the same turn. Recording a refusal on the turn that hit it therefore answered a question about the mark with a fact about a turn, and got it wrong twice. With reactions switched off, the first turn recorded its refusal and then completed, which compacts its row to a receipt and discarded it; the last holder found nothing, assumed a mark it had to remove, and never finished. In the other direction a turn's historical refusal outlived the moment it described: once another turn had successfully added the shared mark, the refused turn still read its own old refusal as proof the message was clean and reported a finished cleanup with the eyes still on it. Neither needed a restart. So the evidence is now keyed by the reaction β€” channel, asking message, and the agent where each reacts as its own bot β€” and says only "this may be on the message". It is written before the platform is called, because an addition that fails without an answer may still have landed, and it is retracted only when the platform states outright that nothing is there: a refused addition, or a removal that worked. Completion carries it into the receipt, because the holder that must remove the mark may be a later turn in a later process. Any holder's standing expectation, ended or not, makes a refused removal outstanding. A publisher with no journal kept the same rule instead of assuming the mark was absent: it has no durable state to be restarted into, so its own memory is the whole truth, and it now owes cleanup for a mark it added. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/activity_journal.py | 62 +++++---- .../bridges/collaboration/session/outbound.py | 127 +++++++++++++----- .../sessions/test_activity_durability.py | 92 +++++++++++++ 3 files changed, 220 insertions(+), 61 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index 3a5d474c1..c5097b3ee 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -94,36 +94,25 @@ async def reaction_held( for row in rows ) - async def reaction_refused( + async def mark_expected( self, - channel: str, - ref: str, + mark: dict[str, str], *, - agent_name: str | None = None, sessions: async_sessionmaker[AsyncSession], ) -> bool: - """Whether a turn on this message was refused the mark outright. + """Whether any turn is still expecting this reaction to be there. Asked when a *removal* is refused, to tell a mark that is still sitting on the message from one that was never put there. A process's own memory cannot answer it: a restart empties that, and empty then means - "no idea" rather than "nothing was added". Recording the refusal is - what survives. - - Absence is not evidence, so absence means outstanding. A turn whose - addition succeeded records nothing here and a refused removal is - therefore treated as a mark still on the message, which is the truthful - direction: the cost of being wrong is a retry, and the cost of the - opposite is a channel showing an agent working on something it - finished. - - Ended turns are counted, unlike `reaction_held`. A turn being over says - nothing about whether it left a mark behind β€” that is the whole of what - went wrong before. + "no idea" rather than "nothing was added". + + Ended turns count, unlike `reaction_held`, and that is the point. Turns + share one reaction, so the turn that put it there routinely finishes + first and is reduced to a receipt while a later holder is left to take + it off. Asking only the live ones is how a mark comes to be reported as + cleaned up with the πŸ‘€ still on the message. """ - anchor: dict[str, str] = {"channel_id": channel, "reaction_ref": ref} - if agent_name is not None: - anchor["agent_name"] = agent_name async with sessions() as db: return bool( ( @@ -131,14 +120,39 @@ async def reaction_refused( select(SessionActivityPost).where( SessionActivityPost.tenant_id == require_tenant_id(), SessionActivityPost.bridge_id == self.bridge_id, - SessionActivityPost.data.contains( - {"anchor": anchor, "reaction_refused": True} - ), + SessionActivityPost.data.contains({"mark": mark}), ) ) ).first() ) + async def forget_mark( + self, + mark: dict[str, str], + *, + sessions: async_sessionmaker[AsyncSession], + ) -> None: + """Erase every turn's expectation of this reaction at once. + + Called when the platform has said the mark is not there β€” it was + refused, or it has been taken off. One reaction, so one answer: a + holder left still expecting it would have the next turn on that message + refuse to finish, waiting on a mark nobody can remove. + """ + async with sessions() as db: + rows = await db.scalars( + select(SessionActivityPost).where( + SessionActivityPost.tenant_id == require_tenant_id(), + SessionActivityPost.bridge_id == self.bridge_id, + SessionActivityPost.data.contains({"mark": mark}), + ) + ) + for row in rows: + data = dict(row.data) + data.pop("mark", None) + row.data = data + await db.commit() + @asynccontextmanager async def open( self, session_id: str, command_id: str diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 56a078984..b5863905e 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -121,6 +121,11 @@ def _violates(error: IntegrityError, constraint: str) -> bool: return f'"{constraint}"' in str(error.orig) +def _mark_id(mark: dict[str, str]) -> tuple[str, str, str]: + """The same reaction as a key this process can hold in a set.""" + return (mark["channel_id"], mark["reaction_ref"], mark["agent_name"]) + + class CardNotPosted(RuntimeError): """A request that has no card, so nobody was asked and nobody can answer. @@ -167,6 +172,7 @@ def __init__( self._recovers_posts = getattr(adapter, "recovers_uncertain_posts", False) self._anchors: OrderedDict[tuple[str, str], _Anchor] = OrderedDict() self._thread_turns: dict[tuple[str, str, str], set[tuple[str, str]]] = {} + self._expecting: set[tuple[str, str, str]] = set() self._attention: OrderedDict[tuple[str, str], tuple[str, str]] = OrderedDict() @property @@ -329,7 +335,13 @@ async def draw() -> bool: if not turn.turn_id.startswith("pending:"): # Keep a small completion receipt to suppress replay, but # discard delivery reservations and reaction/log anchors. + # An outstanding mark is not this turn's to discard: the + # holder that takes it off may be another turn entirely, + # and it needs to know the mark is there. + mark = record.data.get("mark") record.data = {"turn_id": turn.turn_id, "ended": True} + if mark is not None: + record.data["mark"] = mark record.data["completed"] = True await record.save() return drawn @@ -820,21 +832,25 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: and best effort: errors do not interrupt rendering. Return whether it worked so callers can retry a failed claim or unfinished terminal cleanup. - A platform that refuses the mark outright raises `ActivityMarkRefused`, - and what that means depends on which way it was going. Refused on the - way *on*, the mark is simply absent: the turn goes on without it, and - the refusal is recorded so a later removal knows there was never - anything there. Refused on the way *off*, the question is whether a - mark is still sitting on the message, and only the durable record can - answer it once a restart has emptied this process's memory. No recorded - refusal means the cleanup is unfinished, and saying otherwise would - leave a channel showing an agent still working on something it has - finished. + A platform that refuses the mark outright raises `ActivityMarkRefused`. + Refused on the way *on*, the mark is simply absent and the turn goes on + without it. Refused on the way *off*, the question is whether a mark is + still sitting on the message β€” and that is a question about the mark, + not about this turn, because turns share one. It is answered from + `_expecting`, which is written before the platform is called and + retracted only when the platform says outright that nothing was put + there. Anything less certain leaves the expectation standing, so an + addition whose outcome is unknown counts as a mark that may be on the + message. Claiming otherwise would leave a channel showing an agent + still working on something it has finished. """ if anchor.reaction_ref is None or not getattr( self._adapter, "supports_activity_reactions", False ): return True + mark = self._mark_key(anchor) + if working: + await self._expect_mark(mark) try: await self._adapter.mark_activity( anchor.channel_id, @@ -843,16 +859,12 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: working=working, **({"force": True} if self._journal else {}), ) - return True except ActivityMarkRefused as refusal: if working: - await self._remember_refusal() - logger.warning( - "%s The turn goes on without the mark.", - refusal, - ) + await self._forget_mark(mark) + logger.warning("%s The turn goes on without the mark.", refusal) return True - if await self._nothing_was_marked(anchor): + if not await self._mark_may_be_there(mark): logger.warning( "%s Nothing was ever put on it, so there is nothing to take off.", refusal, @@ -873,37 +885,78 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: exc_info=True, ) return False + if not working: + await self._forget_mark(mark) + return True - async def _remember_refusal(self) -> None: - """Record that this message would not take the mark at all. + def _mark_key(self, anchor: _Anchor) -> dict[str, str]: + """Identify the reaction itself, which several turns can share. - On the turn's own journal row, which is discarded when the turn - completes β€” so the memory lasts exactly as long as it can be relevant. - Without a journal there is nothing to remember it in, and a restart is - not survivable anyway. + The same shape as `_thread_key` and for the same reason: where every + agent reacts as one bot there is a single mark between them, and where + each reacts as its own there is one apiece. Self-contained rather than + a pointer into the turn's anchor, because it has to outlive the anchor + β€” a turn that put the mark there can finish while another holder keeps + it, and its row is reduced to a receipt at that point. + """ + return { + "channel_id": anchor.channel_id, + "reaction_ref": anchor.reaction_ref or "", + "agent_name": anchor.agent_name if self._reactions_per_agent else "", + } + + async def _expect_mark(self, mark: dict[str, str]) -> None: + """Record that a mark may be on this message, before asking for it. + + Before, not after, because a request that fails without an answer may + still have landed. Written where the answer will be needed: durably + when there is a journal, since the turn that eventually takes the mark + off may be running in a later process than the turn that put it on. """ + if _mark_id(mark) in self._expecting: + return + self._expecting.add(_mark_id(mark)) record = self._record.get() - if record is None: + if record is None or record.data.get("mark") == mark: return - record.data["reaction_refused"] = True + record.data["mark"] = mark await record.save() - async def _nothing_was_marked(self, anchor: _Anchor) -> bool: - """Whether a refused removal is safe to treat as already done. + async def _forget_mark(self, mark: dict[str, str]) -> None: + """Drop the expectation, once the mark is known not to be there. - Only a recorded refusal makes it safe. Absence of evidence is not - evidence, so an unjournalled publisher β€” which cannot survive a restart - to be wrong in the first place β€” keeps the behaviour it had. + Two ways to know: the platform refused to put it there at all, or it + took it off. Every holder's evidence goes, not only this turn's, because + they are all talking about the same reaction β€” one left behind would + have a later turn on that message reporting a mark that is not there + and never finishing. """ - if self._journal is None: - return True + self._expecting.discard(_mark_id(mark)) record = self._record.get() - if record is not None and record.data.get("reaction_refused"): + if record is not None and record.data.pop("mark", None) is not None: + await record.save() + if self._journal is not None: + await self._journal.forget_mark( + mark, + sessions=record.sessions if record else self._journal.sessions, + ) + + async def _mark_may_be_there(self, mark: dict[str, str]) -> bool: + """Whether a refused removal leaves something behind. + + Any holder's standing expectation answers yes, whichever turn recorded + it and whether or not that turn has ended: a mark outlives the turn + that put it there. Without a journal the question is only as good as + this process's memory, which is sound for a publisher that has no + durable state to be restarted into. + """ + if _mark_id(mark) in self._expecting: return True - return await self._journal.reaction_refused( - anchor.channel_id, - anchor.reaction_ref or "", - agent_name=anchor.agent_name if self._reactions_per_agent else None, + if self._journal is None: + return False + record = self._record.get() + return await self._journal.mark_expected( + mark, sessions=record.sessions if record else self._journal.sessions, ) diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 2d9fa351c..a6534d1cd 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -824,3 +824,95 @@ async def test_a_chat_that_never_took_the_mark_does_not_hold_the_turn_open( after_restart = RefusingPlatform(chat, refuse_add=True, refuse_remove=True) assert await publish(activity(session_factory, after_restart), "completed") + + +async def test_two_turns_sharing_a_mark_nobody_could_add_do_not_wait_for_it( + session_factory, +): + """Reactions off throughout, and the turn that finishes last never tried. + + Two turns on one asking message share a single reaction, so only the first + attempts to add it and only the last attempts to take it off β€” and they are + rarely the same turn. The first one's completion reduces its row to a + receipt. If what it learned lived on that row as a fact about the turn, the + last holder would find nothing, assume a mark it must remove, and go on + failing at a reaction that was never there. The evidence is about the mark, + so it outlives whichever turn recorded it. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + renderer = activity(session_factory, RefusingPlatform(chat, refuse_add=True)) + + assert await publish(renderer, command="first") + assert await publish(renderer, command="second") + assert await publish(renderer, "completed", command="first") + assert not chat["reactions"] + + renderer._adapter.refuse_remove = True + + assert await publish(renderer, "completed", command="second") + + +async def test_a_mark_that_went_on_later_outranks_the_refusal_that_came_first( + session_factory, +): + """One turn is refused the mark; the next puts it there; the first ends last. + + The refusal says nothing about the mark once somebody else has managed to + add it β€” they are the same reaction. Deciding from the ending turn's own + history reports a clean finish with the πŸ‘€ still on the message, which is + the failure this is all about, so the answer comes from what is expected of + the mark rather than from what happened to a turn. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + refusing = RefusingPlatform(chat, refuse_add=True) + assert await publish(activity(session_factory, refusing), command="first") + assert not chat["reactions"] + + after_restart = RefusingPlatform(chat) + renderer = activity(session_factory, after_restart) + assert await publish(renderer, command="second") + assert chat["reactions"] == {"channel-demo:question"} + + # The first turn is still live, so the second leaves the shared mark alone. + assert await publish(renderer, "completed", command="second") + assert chat["reactions"] == {"channel-demo:question"} + + after_restart.refuse_remove = True + assert not await publish(renderer, "completed", command="first") + assert chat["reactions"] == {"channel-demo:question"} + + after_restart.refuse_remove = False + assert await publish(renderer, "completed", command="first") + assert not chat["reactions"] + + +async def test_a_publisher_with_no_journal_still_owes_a_mark_it_put_there( + session_factory, +): + """Without a journal there is no restart to survive, but there is a mark. + + Nothing durable is recorded for this publisher, so its own memory is all + the evidence there is β€” and it is enough, because a process that cannot be + restarted into cannot be asked a question it was not there for. What it + must not do is treat having no journal as proof that nothing was added. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + platform = RefusingPlatform(chat) + renderer = SessionTurnActivity(platform) + + assert await publish(renderer) + assert chat["reactions"] == {"channel-demo:question"} + + platform.refuse_remove = True + assert not await publish(renderer, "completed") + assert chat["reactions"] == {"channel-demo:question"} + + elsewhere = RefusingPlatform( + {"reactions": set(), "messages": {}}, refuse_add=True, refuse_remove=True + ) + unmarked = SessionTurnActivity(elsewhere) + assert await publish(unmarked) + assert await publish(unmarked, "completed") From 9561cc2069d200ebb05664ef200ce6c2813e8e24 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 04:54:35 +0100 Subject: [PATCH 013/120] Let the publisher, not Discord, decide a card may go to the channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request card whose thread could not be resolved was posted to the parent channel. Discord answers "unknown channel" for a thread that was never started and for one that existed and has been deleted, and the adapter read both as "the turn was addressed at the channel root". For a private thread that has since been deleted that is false: the card carries the agent's question and its options, and the parent channel is an audience that was never in the conversation. Nothing takes that back. The distinction cannot be recovered from the platform, only from the recorded origin of the command, so the decision moves to where the origin is known. `_publication_thread` now raises `ThreadUnavailable` β€” a statement that no thread exists and none could be made, with no view on what to do about it β€” and never substitutes the channel. A thread that exists and will not open still raises as itself, because that one may be private and is nobody's licence to use the parent. `SessionRequestCards.post` takes `asked_at_root`, derived from `origin.thread_id is None`, and only a card that was asked at the channel root is posted there when its thread is unavailable; the fallback is logged as before. Asked in a thread, nothing is posted, the handle is released and the caller is told, which is the same path as any other refusal and is retried on the next cycle. A card answering a card carries its destination as its origin thread, so it reads as thread-asked and merely refuses the fallback it would not have needed. Turn activity is unchanged: it never fell back, and `ThreadUnavailable` is a `RichContentFailed` like the error it replaces. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 15 +++ .../bridges/collaboration/discord/adapter.py | 75 +++++++-------- .../bridges/collaboration/session/demo.py | 1 + .../bridges/collaboration/session/outbound.py | 46 ++++++--- core/switch_core/sessions/publication.py | 8 ++ .../collaboration/test_discord_sdk_only.py | 32 ++++--- .../test_session_card_posting.py | 1 + .../switch_core/sessions/test_publication.py | 96 +++++++++++++++++-- 8 files changed, 199 insertions(+), 75 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index f10d37198..c74f5e610 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -212,6 +212,21 @@ def __init__(self, *, retry_after: float, text: str) -> None: self.retry_after = retry_after +class ThreadUnavailable(RichContentFailed): + """No thread exists under the root message, and none could be made. + + A statement of fact, not a verdict on where the content should go instead. + An absent thread looks the same whether it was never made or was made + privately and then deleted, and only the caller knows which: it holds the + recorded origin of the command, and the platform does not. Posting to the + parent channel is right in the first case and hands a private + conversation's contents to an audience in the second. + + Nothing has been posted when this is raised, so a caller may post + elsewhere β€” or release its reservation, as for any `RichContentFailed`. + """ + + class ActivityMarkRefused(RuntimeError): """The platform will not change the work mark, and another attempt will not. diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 1ec2dfdc5..0de77971e 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -26,6 +26,7 @@ RichContent, RichContentFailed, RichContentThrottled, + ThreadUnavailable, TurnActivity, ) from switch_core.bridges.collaboration.discord.chunking import ( @@ -955,21 +956,21 @@ async def post_rich( Discord actually gave. A send whose outcome nobody knows raises the transport's own error and keeps the reservation. - A thread that cannot be resolved is where the two kinds of content part - company, but only in the one case where nothing is given away by it. - Where the turn began at the channel root and the reply thread has not - been made yet, the channel root is the origin: everyone who could read - the question there can read it there still, so a card is posted there - rather than not at all, while progress is suppressed because the - channel narrating every turn is the noise this presentation exists to - avoid. - - Where a thread already exists and Discord will not let us into it, both - are refused. That thread may be private, and a request carries the + A `thread_root_id` is where the content belongs, and this never + substitutes the parent channel for it. Where no thread exists and none + can be made, `ThreadUnavailable` says so and the caller decides: the + channel root is the same audience as a turn addressed to the channel + root, and a different one from a thread that has been deleted, and + nothing Discord can be asked distinguishes the two. + + Where a thread exists and Discord will not let us into it, that is + refused outright. The thread may be private, and a request carries the agent's question and its options β€” posting it to the parent would hand the contents of a conversation to people who were not in it. A question nobody can see is bad; a question the wrong people can see is worse, and unlike the first it cannot be undone. + + Pass `thread_root_id=None` to post at the channel root deliberately. """ fallback = self.rich_fallback_text(content) try: @@ -1000,7 +1001,7 @@ async def post_rich( thread: Any = None if thread_root_id: thread = await self._publication_thread( - int(channel_id), thread_root_id, content, text + int(channel_id), thread_root_id, text ) try: @@ -1025,22 +1026,20 @@ async def post_rich( return f"{sent.channel.id}:{sent.id}" async def _publication_thread( - self, channel_id: int, thread_root_id: str, content: RichContent, text: str + self, channel_id: int, thread_root_id: str, text: str ) -> Any: - """The thread this publication goes in, or `None` to use its origin. - - `None` is returned in exactly one situation: no thread exists under the - root message yet and one could not be made. The turn was addressed at - the channel root, so the root is where it came from and where its - readers already are β€” a card posted there reaches the same people who - asked, which is why this is a fallback and not a disclosure. - - Every other failure raises. A thread that exists and will not open may - be private, and the difference between "in a thread" and "in the - channel" is then the difference between a conversation and an audience. - Progress raises too even in the first case: nobody asked the channel to - be told what a turn is doing, and the agent's reply is coming to it - anyway. + """The thread this publication goes in. Never the channel instead. + + `ThreadUnavailable` when no thread exists under the root message and + one could not be made, which reads the same from here whether the + thread was never created or was created privately and deleted. Only the + caller can tell those apart, from where the command was addressed, and + only the caller may decide that the parent channel will do. + + Every other failure raises as itself. A thread that exists and will not + open may be private, and the difference between "in a thread" and "in + the channel" is then the difference between a conversation and an + audience. """ existing = await self._reachable_thread(channel_id, thread_root_id, text) if existing is not None: @@ -1050,25 +1049,15 @@ async def _publication_thread( except Exception as error: # The create may have been refused because the thread is already # there β€” the one failure that means the opposite of what it looks - # like. Ask again before treating the root as this turn's origin. + # like. Ask again before reporting that there is none. settled = await self._reachable_thread(channel_id, thread_root_id, text) if settled is not None: return settled - if isinstance(content, TurnActivity): - raise RichContentFailed( - f"Discord has no thread under {thread_root_id} in channel " - f"{channel_id} to show this turn's progress in, and the " - f"channel root is not a substitute for one: {error}", - text=text, - ) from error - logger.warning( - "Could not open a Discord thread under %s in channel %s (%s); " - "posting the request where it was asked, at the channel root.", - thread_root_id, - channel_id, - error, - ) - return None + raise ThreadUnavailable( + f"Discord has no thread under {thread_root_id} in channel " + f"{channel_id} and would not make one: {error}", + text=text, + ) from error async def _reachable_thread( self, channel_id: int, thread_root_id: str, text: str diff --git a/core/switch_core/bridges/collaboration/session/demo.py b/core/switch_core/bridges/collaboration/session/demo.py index 0cc9798e3..40f592e4c 100644 --- a/core/switch_core/bridges/collaboration/session/demo.py +++ b/core/switch_core/bridges/collaboration/session/demo.py @@ -193,6 +193,7 @@ async def _start(self, channel_id: str, room_id: str) -> _Showing: request, channel_id=channel_id, thread_root_id=None, + asked_at_root=True, room_id=room_id, session_id=session_id, epoch=session.epoch, diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index b5863905e..fc80dffca 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -50,6 +50,7 @@ RequestCard, RichContentFailed, RichContentThrottled, + ThreadUnavailable, TurnActivity, ) from switch_core.db.models import Client, ExternalUser, SessionRequestPost @@ -1045,6 +1046,7 @@ async def post( *, channel_id: str, thread_root_id: str | None, + asked_at_root: bool, room_id: str, session_id: str, epoch: str, @@ -1067,6 +1069,14 @@ async def post( the other thing this must not leave behind: a handle held for a card nobody can see, so it is released and the caller told, rather than left for `recover` to find nothing. + + `asked_at_root` is where the command was addressed, and it is the only + thing that licenses posting the card in the channel rather than in a + thread. A platform that cannot find the thread cannot tell a thread + that was never made from one that was made privately and deleted; this + can, because the origin is recorded. Asked in a thread, a card with + nowhere to go is not posted at all β€” the question waits, rather than + being put to people who were never in the conversation. """ form = posted_form(request) token = secrets.token_urlsafe(16) @@ -1084,19 +1094,31 @@ async def post( ) reference = RequestReference(token=post.token, handle=post.handle) await session.commit() + card = RequestCard( + request, + reference, + unavailable_reason=unavailable_reason, + notify_external_id=notify_external_id, + notify_unreachable=notify_unreachable, + ) try: - ref = await self._adapter.post_rich( - channel_id, - agent_name, - RequestCard( - request, - reference, - unavailable_reason=unavailable_reason, - notify_external_id=notify_external_id, - notify_unreachable=notify_unreachable, - ), - thread_root_id, - ) + try: + ref = await self._adapter.post_rich( + channel_id, agent_name, card, thread_root_id + ) + except ThreadUnavailable as missing: + if not asked_at_root: + raise + logger.warning( + "No thread for request %s in channel %s (%s); posting the " + "card at the channel root, where it was asked.", + request.request_id, + channel_id, + missing, + ) + ref = await self._adapter.post_rich( + channel_id, agent_name, card, None + ) except RichContentFailed as error: await session.delete(post) await session.commit() diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 10652ee50..1e4367b55 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -174,6 +174,11 @@ async def refresh_cards( origin.thread_id or origin.message_id, ) ) + # Where the command was addressed, which is not the same question + # as where its card goes: a thread the platform can no longer find + # is indistinguishable from one never made, so the channel root is + # only the audience that was asked when the asking happened there. + asked_at_root = origin.thread_id is None # Only the first post of an open card asks anyone. A redraw leaves # the recipient unset on purpose β€” the mention has been made and # repeating it is a second notification β€” so "nobody to name" is @@ -198,6 +203,7 @@ async def refresh_cards( room.id, room.external_channel_id, thread_id, + asked_at_root, recipient, asking and recipient is None and cards.notifies_only_by_mention, deeplink_for_platform( @@ -220,6 +226,7 @@ async def refresh_cards( room_id, channel_id, thread_id, + asked_at_root, recipient, unreachable, console_url, @@ -241,6 +248,7 @@ async def refresh_cards( request, channel_id=channel_id, thread_root_id=thread_id, + asked_at_root=asked_at_root, room_id=room_id, session_id=session_id, epoch=epoch, diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index 97ab6da08..667be6a22 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -25,6 +25,7 @@ RequestCard, RichContentFailed, RichContentThrottled, + ThreadUnavailable, TurnActivity, ) from switch_core.bridges.collaboration.discord.adapter import ( @@ -465,35 +466,35 @@ async def test_progress_is_suppressed_rather_than_spilled_into_the_channel() -> assert webhook.sent == [] -async def test_a_card_falls_back_to_the_channel_root_when_its_thread_will_not_open( - caplog: pytest.LogCaptureFixture, -) -> None: - """A question in the wrong place is answerable; one nobody can see is not. +async def test_a_missing_thread_is_reported_and_not_replaced_by_the_channel() -> None: + """A thread that is gone and one that was never made read the same here. - The root is a fallback only because the turn was addressed there: everyone - who could read the question can read the card. + Discord answers "no such channel" either way, and the second makes the + parent channel the audience that was asked while the first makes it an + audience that was not. Nothing this adapter can ask tells them apart, so it + states the fact and leaves the choice to the caller that recorded where the + command came from. """ adapter, channel, webhook = _no_thread_yet() - channel.thread_error = discord.Forbidden(_Response(), "no Create Threads") # type: ignore[arg-type] + channel.thread_error = discord.NotFound(_Response(), "unknown message") # type: ignore[arg-type] - with caplog.at_level(logging.WARNING): - ref = await adapter.post_rich( + with pytest.raises(ThreadUnavailable): + await adapter.post_rich( str(CHANNEL_ID), "my-agent", await _card(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", ) - assert "thread" in caplog.text - assert "thread" not in webhook.sent[0] - assert ref.endswith(":901") + assert channel.sent == [] + assert webhook.sent == [] async def test_a_thread_the_bot_cannot_open_never_becomes_the_whole_channel() -> None: """A private thread's request is not republished to its parent. A denied thread and an absent one look alike from outside, and only one of - them makes the channel an acceptable substitute. A request carries the + them is even a question the caller may answer. A request carries the agent's question and its options: handing that to the parent channel gives a private conversation an audience, and nothing takes it back. """ @@ -503,7 +504,7 @@ async def test_a_thread_the_bot_cannot_open_never_becomes_the_whole_channel() -> _Response(), "not a member of this thread" ) - with pytest.raises(RichContentFailed): + with pytest.raises(RichContentFailed) as raised: await adapter.post_rich( str(CHANNEL_ID), "my-agent", @@ -511,6 +512,9 @@ async def test_a_thread_the_bot_cannot_open_never_becomes_the_whole_channel() -> f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", ) + # Not the absent-thread answer: a caller allowed to use the channel root + # would take that as licence, and this thread is there and is not ours. + assert not isinstance(raised.value, ThreadUnavailable) assert channel.sent == [] assert webhook.sent == [] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py index 780a305e3..724e444d6 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py @@ -149,6 +149,7 @@ async def _post_one( await _fixture_request(), channel_id=CHANNEL, thread_root_id=None, + asked_at_root=True, room_id=room_id, session_id=session_id, epoch="epoch-demo", diff --git a/core/tests/switch_core/sessions/test_publication.py b/core/tests/switch_core/sessions/test_publication.py index f1a7345e5..a060f46e4 100644 --- a/core/tests/switch_core/sessions/test_publication.py +++ b/core/tests/switch_core/sessions/test_publication.py @@ -1,10 +1,13 @@ import pytest from sqlalchemy import select -from switch_core.bridges.collaboration.adapter import RequestCard +from switch_core.bridges.collaboration.adapter import RequestCard, ThreadUnavailable from switch_core.bridges.collaboration.models import InboundInteraction from switch_core.bridges.collaboration.session.inbound import SessionInteractions -from switch_core.bridges.collaboration.session.outbound import SessionRequestCards +from switch_core.bridges.collaboration.session.outbound import ( + CardNotPosted, + SessionRequestCards, +) from switch_core.bridges.collaboration.session.renderers import ANSWER_ACTION from switch_core.bridges.collaboration.session.renderers.slack import render_request from switch_core.db.models import ( @@ -139,10 +142,12 @@ async def first_reply(channel, root, message): assert len(platform.posts) == 1 -@pytest.mark.parametrize("thread", [None, "sw_thread"]) -async def test_permission_uses_activity_thread_and_persists_it(session_factory, thread): - service, epoch = await setup(session_factory) - await opened(service, epoch) +async def _asked_in(session_factory, thread): + """Rewrite the stored command's origin: in `thread`, or at the channel root. + + `None` is the root, which is what an `Origin` with no thread means, and the + two get different treatment wherever a card cannot go where it belongs. + """ async with session_factory() as db: row = await db.get( SdkSessionCommand, (require_tenant_id(), "session-demo", "message-demo") @@ -167,6 +172,13 @@ async def test_permission_uses_activity_thread_and_persists_it(session_factory, ) ) await db.commit() + + +@pytest.mark.parametrize("thread", [None, "sw_thread"]) +async def test_permission_uses_activity_thread_and_persists_it(session_factory, thread): + service, epoch = await setup(session_factory) + await opened(service, epoch) + await _asked_in(session_factory, thread) platform = Platform() cards = SessionRequestCards( platform, @@ -198,3 +210,75 @@ async def test_permission_uses_activity_thread_and_persists_it(session_factory, assert ( await service.submit(reply, user_id=None, bridge_id="bridge") ).status == "accepted" + + +class ThreadlessPlatform(Platform): + """A platform pointed at a thread it can neither find nor make. + + Which is all it can say: Discord answers a deleted thread and a thread that + was never started with the same "unknown channel", so the fake refuses the + same way for both and the difference has to come from the origin. + """ + + def __init__(self): + super().__init__() + self.refused = [] + + async def post_rich(self, channel, agent, content: RequestCard, thread): + if thread is not None: + self.refused.append(thread) + raise ThreadUnavailable(f"no thread under {thread}", text="the card") + return await super().post_rich(channel, agent, content, thread) + + +async def test_a_card_asked_at_the_channel_root_is_posted_there(session_factory): + """The reply thread has not been made yet, so the root is where it was asked. + + Everyone who could read the question can read the card, which is what makes + this a fallback rather than a disclosure β€” and it is disclosed anyway. + """ + service, epoch = await setup(session_factory) + await opened(service, epoch) + await _asked_in(session_factory, None) + platform = ThreadlessPlatform() + cards = SessionRequestCards( + platform, + bridge_id="bridge", + surface="slack", + posts=SessionRequestPostStore(), + session_factory=session_factory, + ) + + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert platform.refused == ["channel-demo:100.1"] + assert platform.posts[0][3] is None + + +async def test_a_card_asked_in_a_thread_is_not_moved_into_the_channel(session_factory): + """The thread the question was asked in has gone, and the channel is not it. + + A deleted private thread leaves the platform saying exactly what an absent + reply thread says. Posting the card to the parent anyway would hand that + conversation's question and its options to people who were never in it, so + nobody is asked and the handle is released for the retry. + """ + service, epoch = await setup(session_factory) + await opened(service, epoch) + await _asked_in(session_factory, "sw_thread") + platform = ThreadlessPlatform() + cards = SessionRequestCards( + platform, + bridge_id="bridge", + surface="slack", + posts=SessionRequestPostStore(), + session_factory=session_factory, + ) + + with pytest.raises(CardNotPosted): + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert platform.refused == ["channel-demo:100.0"] + assert platform.posts == [] + async with session_factory() as db: + assert await db.scalar(select(SessionRequestPost)) is None From ff8a8b743c83ec70cb6bedbba21eba73997936a9 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 05:46:51 +0100 Subject: [PATCH 014/120] Settle a turn whose message can never be confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A platform that cannot search for what it posted has no way to finish a reservation whose send was never acknowledged. The publisher treated that as a fresh failure on every pass: two tracebacks every five seconds for the life of the process, and the session never entered the "published" set, so its whole pass re-ran each cycle and the failures that were new were buried under one that never changes. Close the question instead of asking it forever. The decision is stamped into the slot's existing journal entry as `abandoned_at`, so the record distinguishes a reservation still being chased from one given up on, and a restart can tell an old conclusion from one it has just reached. No schema change: `ActivityRecord.data` is already a JSON dict we write. `ActivityAbandoned` carries the decision to callers, and the callers differ in what it costs them. A status is the whole of a turn's display, so the publisher holds the turn back permanently and stops counting it. An attention message is one message beside a status that is still being drawn, so it is swallowed where it is raised β€” Telegram publishes a separate attention slot and cannot search, and writing the turn off there would have frozen a display that works. Reported once per slot, where the decision is made, at warning level. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 90 ++++++++++- core/switch_core/sessions/publication.py | 16 +- .../sessions/test_activity_durability.py | 149 +++++++++++++++++- 3 files changed, 251 insertions(+), 4 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index fc80dffca..bce5db38c 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -143,6 +143,32 @@ class CardAlreadyPosted(CardNotPosted): """ +class ActivityAbandoned(CardNotPosted): + """A turn's activity slot that will never be settled, whatever happens. + + Raised for a reservation whose send was never acknowledged on a platform + that cannot search for what it posted. Nothing about that changes with + time: the message is either in the chat or it is not, and there is no way + left to find out which, so a caller waiting for a later attempt to succeed + is waiting for something that cannot arrive. + + A subclass because it is still true that nothing was drawn. What the + separate type adds is that retrying is pointless β€” a caller that treats it + as a fresh failure reports the same permanent condition on every cycle and + never lets the session settle, which buries the failures that are new. + + Already reported by the time a caller sees it. What each caller still has + to decide is what the loss costs: a status is the whole of a turn's + display, so there is nothing left to draw, while an attention message is + one message and the turn carries on without it. + """ + + def __init__(self, message: str, *, slot: str, abandoned_at: str) -> None: + super().__init__(message) + self.slot = slot + self.abandoned_at = abandoned_at + + class SessionTurnActivity: """Publish SDK activity without exposing internal assistant narration. @@ -161,6 +187,7 @@ def __init__( "activity_record", default=None ) self._adapter = adapter + self._abandoned: OrderedDict[tuple[str, ...], None] = OrderedDict() self._separate_activity_log = getattr(adapter, "separate_activity_log", False) self._separate_attention_slot = getattr( adapter, "separate_attention_slot", False @@ -257,7 +284,9 @@ async def publish( error_summary: str | None = None, ) -> bool: async def attend() -> None: - if self._separate_attention_slot: + if not self._separate_attention_slot: + return + try: await self._refresh_attention( session_id, channel_id, @@ -268,6 +297,14 @@ async def attend() -> None: notify_unreachable, error_summary, ) + except ActivityAbandoned: + # An attention message whose own delivery can never be + # confirmed costs that message and nothing else β€” the status + # beside it is still being drawn, and holding the turn open + # for a repost that may duplicate what is already there would + # trade a lost notice for two of them. Reported when it was + # given up on, and not raised past here. + pass async def draw() -> bool: try: @@ -408,6 +445,37 @@ async def _refresh_attention( while len(self._attention) > _MAX_ANCHORS: self._attention.popitem(last=False) + def _report_abandoned( + self, key: tuple[str, ...], slot: str, reason: str, abandoned_at: str + ) -> None: + """Say once, here, that a slot has been given up on. + + Reported where the decision is made rather than by each caller, + because the callers differ in what they do about it and not in what + happened. Once per process: the condition is permanent, so a line per + publish cycle would be the same sentence every few seconds for as long + as the bridge runs, and a restart genuinely is worth one line β€” it is + the only place an operator learns that a turn on this platform is + showing less than it should. + + Bounded, and an eviction costs one repeated warning rather than a + missed one, which is the right way round. + """ + seen = (*key, slot) + if seen in self._abandoned: + return + self._abandoned[seen] = None + while len(self._abandoned) > _MAX_ANCHORS: + self._abandoned.popitem(last=False) + logger.warning( + "Giving up on the %s message for %s: %s Abandoned at %s; it will " + "not be drawn or retried.", + slot, + "/".join(key[2:]), + reason, + abandoned_at, + ) + async def _save_anchor(self, anchor: _Anchor) -> None: record = self._record.get() if record: @@ -422,6 +490,16 @@ async def _post_activity( thread: str | None, slot: str, ) -> str: + """Post one of a turn's messages, reserving it in the journal first. + + The journal holds one entry per slot, and the shape of that entry is + the whole of what a restart has to go on: a `token` and no `ref` is a + send whose outcome was never learned, and `abandoned_at` says that + question has been closed as unanswerable rather than still being + asked. Written down rather than recomputed so the record says why a + reservation has sat unfinished, and so a log after a restart can tell + an old decision from a new one. + """ record = self._record.get() if record is None: return await self._adapter.post_rich(channel, agent, content, thread) @@ -440,12 +518,20 @@ async def _post_activity( # the chat per cycle, so the reservation is kept and this slot # stays as it is. The attention message is published # separately and is not held up by it. - raise CardNotPosted( + abandoned_at = delivery.get("abandoned_at") + if not abandoned_at: + abandoned_at = datetime.now(UTC).isoformat() + delivery["abandoned_at"] = abandoned_at + record.data[slot] = delivery + await record.save() + reason = ( f"The {slot} message sent as {delivery['token']} in " f"{delivery['channel']} was never acknowledged, and this " "platform cannot search for it. Keeping its reservation " "rather than posting a second one that may duplicate it." ) + self._report_abandoned(record.key, slot, reason, abandoned_at) + raise ActivityAbandoned(reason, slot=slot, abandoned_at=abandoned_at) ref = await self._adapter.find_request_card( delivery["channel"], delivery["thread"], diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 1e4367b55..5124c6270 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -11,6 +11,7 @@ from switch_core.bridges.collaboration.adapter import RichContentThrottled from switch_core.bridges.collaboration.session.outbound import ( + ActivityAbandoned, SessionRequestCards, SessionTurnActivity, ) @@ -716,6 +717,14 @@ async def refresh_activity( retry_delayed(token, error.retry_after) backed_off += 1 continue + except ActivityAbandoned: + # Settled, in the only sense left: this turn's display can never + # be confirmed, so there is nothing to come back for. Counting it + # as a failure would report the same permanent condition every + # cycle and hold the session open for a retry that cannot change + # anything. Already reported where it was decided. + hold_back(session_id, turn.turn_id) + continue except Exception: logger.exception("Could not recover activity for turn %s", turn.turn_id) failed.append(turn.turn_id) @@ -843,7 +852,12 @@ def drawn( class _PermanentlyHeldBack: - """Ended turns a first sweep decided never to draw, kept per session. + """Turns nothing will draw again, kept per session. + + Two ways in, and they meet here because what follows is the same: a turn + a first sweep decided never to draw because it had already ended, and a + turn whose reserved message can never be confirmed on a platform that + cannot search for what it posted. Neither can change with a later look. Not `_TurnRedrawGuard`: that one is bounded and shared across every session on the bridge, evicting whichever entry was least recently diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index a6534d1cd..b1b169165 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -1,10 +1,12 @@ """Restart and uncertain-delivery tests using real PostgreSQL checkpoints.""" import asyncio +import logging from datetime import UTC, datetime, timedelta import pytest from mattermostdriver.exceptions import NotEnoughPermissions +from sqlalchemy import select from switch_core.bridges.collaboration.adapter import ( ActivityMarkRefused, @@ -12,6 +14,7 @@ ) from switch_core.bridges.collaboration.session.activity_journal import ActivityJournal from switch_core.bridges.collaboration.session.outbound import ( + ActivityAbandoned, CardNotPosted, SessionTurnActivity, ) @@ -19,7 +22,7 @@ SlackAdapter, SlackConnectionConfig, ) -from switch_core.db.models import SdkSession, require_tenant_id +from switch_core.db.models import SdkSession, SessionActivityPost, require_tenant_id from switch_core.sessions.publication import SessionPublisher from ..bridges.collaboration.test_mattermost_sdk_only import ( @@ -110,6 +113,9 @@ async def mark_activity(self, channel, ref, *, agent_name, working, force=False) self.reactions.discard((agent_name, ref)) +OUTBOUND_LOGGER = "switch_core.bridges.collaboration.session.outbound" + + def activity(factory, platform): return SessionTurnActivity(platform, journal=ActivityJournal(factory, "bridge")) @@ -346,6 +352,147 @@ async def test_a_status_a_platform_cannot_search_is_never_posted_a_second_time( assert platform.post_count == 1 +async def test_a_status_nobody_can_confirm_is_written_off_once_and_not_again( + session_factory, +): + """The journal records the decision, not just the unfinished reservation. + + A slot holding a token and no reference is a question still being asked. + One that has been given up on is a question closed, and the two look + identical to anything reading the row afterwards β€” including a restart, + which would otherwise report a months-old conclusion as though it had just + reached it. The stamp is taken once and kept. + """ + await setup(session_factory) + platform = UnsearchablePlatform() + platform.fail_after_post = True + with pytest.raises(TimeoutError): + await publish(activity(session_factory, platform)) + + with pytest.raises(ActivityAbandoned) as written_off: + await publish(activity(session_factory, platform)) + assert written_off.value.slot == "status" + + async with session_factory() as db: + row = await db.scalar(select(SessionActivityPost)) + assert row.data["status"]["abandoned_at"] == written_off.value.abandoned_at + + with pytest.raises(ActivityAbandoned) as again: + await publish(activity(session_factory, platform)) + assert again.value.abandoned_at == written_off.value.abandoned_at + assert platform.post_count == 1 + + +async def test_a_status_nobody_can_confirm_stops_being_reported_as_a_new_failure( + session_factory, caplog +): + """Said once, then left alone β€” the publisher has to be able to settle. + + Nothing about this turn can change: its status is either in the chat or it + is not, and this platform cannot find out which. Treating that as a fresh + failure on every cycle put a traceback in the log every few seconds for + the life of the process, and kept the session out of the publisher's + "nothing to do here" set, so its whole publication pass ran again each + time. One warning is the right amount of noise for a permanent condition. + """ + service, epoch = await setup(session_factory) + await opened(service, epoch) + # The state a lost response leaves behind, written directly: a reservation + # with a token and no reference. Reaching it by timing a post out would + # put the turn into a retry backoff first, and what is under test here is + # what happens once the backoff has let it through. + async with session_factory() as db: + db.add( + SessionActivityPost( + tenant_id=require_tenant_id(), + bridge_id="bridge", + session_id="session-demo", + command_id="message-demo", + data={ + "status": { + "token": "lost-token", + "channel": "channel-demo", + "thread": "channel-demo:root", + "created_at": datetime.now(UTC).isoformat(), + } + }, + ) + ) + await db.commit() + platform = UnsearchablePlatform() + publisher = SessionPublisher( + session_factory, + "bridge", + cards_for(session_factory, platform), + activity(session_factory, platform), + ) + + caplog.clear() + with caplog.at_level(logging.WARNING): + await publisher.publish_pending() + assert [(record.name, record.levelno) for record in caplog.records] == [ + (OUTBOUND_LOGGER, logging.WARNING) + ] + assert "status" in caplog.records[0].getMessage() + + caplog.clear() + with caplog.at_level(logging.WARNING): + await publisher.publish_pending() + assert [record.getMessage() for record in caplog.records] == [] + + +class UnsearchableAttention(UnsearchablePlatform): + """Loses the response to the attention message and nothing else. + + The mirror image of the platform above, and the case that decides how + broad "abandoned" is allowed to be: the status is fine and still being + drawn, so writing the whole turn off because a separate notice cannot be + confirmed would take away a display that is working. + """ + + def __init__(self): + super().__init__() + self.dropped = False + + async def post_rich(self, channel, agent, content, thread): + ref = await super().post_rich(channel, agent, content, thread) + if getattr(content, "error_summary", None) and not self.dropped: + self.dropped = True + raise TimeoutError("Response lost after the attention post landed") + return ref + + +async def test_an_attention_message_nobody_can_confirm_leaves_the_turn_drawing( + session_factory, +): + """One lost notice is one lost notice, not the end of the turn's status.""" + await setup(session_factory) + platform = UnsearchableAttention() + + async def report(renderer, status="running"): + return await renderer.publish( + [], + _turn(status).model_copy(update={"command_id": "message-demo"}), + session_id="session-demo", + channel_id="channel-demo", + thread_root_id="channel-demo:root", + asked_on="channel-demo:question", + agent_name="Agent", + elapsed_seconds=12, + error_summary="The host went away.", + ) + + with pytest.raises(TimeoutError): + await report(activity(session_factory, platform)) + posted = platform.post_count + + await report(activity(session_factory, platform)) + assert platform.post_count == posted # Nothing reposted over the lost one. + + await publish(activity(session_factory, platform), status="completed", tools=False) + assert platform.edit_refs # The status is still being drawn. + + async def test_an_unconfirmed_status_does_not_swallow_the_attention_message( session_factory, ): From 719ff02a3b6a4bc422a2bb0e55768ac20a3f3f0e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 06:39:26 +0100 Subject: [PATCH 015/120] Scope an unconfirmable message to its own slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abandoning a turn's status held the whole turn back: the publisher checks that before it works out state or an error summary, so a turn that later failed never said so, its attention message never went out, and the journal receipt and the `:eyes:` on the asking message were never cleared. One message that cannot be confirmed is one message; the turn around it still owes everything else. The decision now stays where it is made. A status nothing can confirm counts as drawn as far as it can go, so the next state is free to publish, attention still goes out beside it, and an ended turn is still tidied up. Tell "no marker to search for" apart from "a search came back empty". Discord finds a card by the handle the card prints, and a turn's own messages print none, so a lookup for one can never match β€” but a lookup that misses on Slack or Mattermost means "not yet", and must be asked again. A new `carries_publication_marker` capability says which an adapter is, rather than inferring permanence from an empty result. It describes this bridge's publications, not the platform: an adapter that starts carrying a marker sets it True and recovers what it could not before. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 24 ++ .../bridges/collaboration/discord/adapter.py | 7 + .../collaboration/mattermost/adapter.py | 5 + .../bridges/collaboration/session/outbound.py | 68 ++++-- .../bridges/collaboration/slack/adapter.py | 5 + core/switch_core/sessions/publication.py | 16 +- .../sessions/test_activity_durability.py | 211 ++++++++++++++++-- 7 files changed, 285 insertions(+), 51 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index c74f5e610..c49703355 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -354,6 +354,30 @@ class CollaborationAdapter(ABC): #: outcome the publisher discloses instead. recovers_uncertain_posts: ClassVar[bool] = False + #: Whether a publication carries a marker `find_request_card` can match on + #: regardless of what the message says. + #: + #: Slack writes the token into a `block_id` and message metadata, + #: Mattermost into a post prop: both are exact, invisible, and present on + #: every publication, so anything this bridge posted can be recognised + #: again. Discord has nowhere to put one on a webhook message, so it + #: recognises a card by the handle the card itself prints β€” which works + #: for a card and cannot work for a turn's activity, because activity + #: prints no handle. + #: + #: Separate from `recovers_uncertain_posts` because the two answer + #: different questions. That one asks whether the platform can be searched + #: at all; this one asks whether a search can find a publication that + #: prints nothing to search for. A platform can recover its cards and + #: still never recover a status, and a caller that cannot tell those apart + #: either repeats a lookup that has no way to succeed or abandons a card + #: that would have been found. + #: + #: Not a statement about the platform β€” a statement about this adapter. An + #: adapter that starts carrying a marker of its own sets this True and the + #: publications it could not recognise before become recoverable. + carries_publication_marker: 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/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 0de77971e..4bb59baf5 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -311,6 +311,13 @@ class DiscordAdapter(CollaborationAdapter): # the message it produced. recovers_uncertain_posts: ClassVar[bool] = True + # Left False: a webhook message carries no metadata this bridge can set, + # so the handle a card prints is the only thing a search has to match on. + # A card is therefore recoverable and a turn's activity, which prints no + # handle, is not β€” see `find_request_card`. Not a property of Discord: a + # marker carried some other way would make this True. + carries_publication_marker: ClassVar[bool] = False + def __init__(self, *, config: DiscordConnectionConfig) -> None: super().__init__() self._config = config diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 78649c0d7..5e1cf7a33 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -234,6 +234,11 @@ class MattermostAdapter(CollaborationAdapter): #: there instead of being disclosed as lost. recovers_uncertain_posts: ClassVar[bool] = True + #: Every publication carries its token in a post prop, which is exact and + #: invisible, so a status is as findable as a card despite printing no + #: handle of its own. + carries_publication_marker: ClassVar[bool] = True + def __init__(self, *, config: MattermostConnectionConfig) -> None: super().__init__() self._config = config diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index bce5db38c..eafdb6dd2 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -157,10 +157,12 @@ class ActivityAbandoned(CardNotPosted): as a fresh failure reports the same permanent condition on every cycle and never lets the session settle, which buries the failures that are new. - Already reported by the time a caller sees it. What each caller still has - to decide is what the loss costs: a status is the whole of a turn's - display, so there is nothing left to draw, while an attention message is - one message and the turn carries on without it. + Already reported by the time a caller sees it, and scoped to the one slot + it names. Nothing about the rest of the turn is settled by it: the turn + can still change state, still need attention, and still have to be ended + and its reaction taken off. A caller that reads this as "this turn is + finished with" stops doing all of that, which costs more than the message + that was lost. """ def __init__(self, message: str, *, slot: str, abandoned_at: str) -> None: @@ -198,6 +200,7 @@ def __init__( self._timer_redraws = getattr(adapter, "redraws_for_elapsed_time", False) self._only_mentions_notify = getattr(adapter, "notifies_only_by_mention", False) self._recovers_posts = getattr(adapter, "recovers_uncertain_posts", False) + self._marks_publications = getattr(adapter, "carries_publication_marker", False) self._anchors: OrderedDict[tuple[str, str], _Anchor] = OrderedDict() self._thread_turns: dict[tuple[str, str, str], set[tuple[str, str]]] = {} self._expecting: set[tuple[str, str, str]] = set() @@ -319,6 +322,15 @@ async def draw() -> bool: elapsed_seconds=elapsed_seconds, session_url=session_url, ) + except ActivityAbandoned: + # Settled, but only for the status. The turn can still change, + # still need attention, and still have to be ended and its + # reaction taken off, and a caller told to come back later + # does none of that β€” it skips the turn entirely from here on. + # So this state counts as taken as far as it can go, which + # leaves the next state free to be published. + await attend() + return True except CardNotPosted: # A status whose delivery cannot be resolved keeps its # reservation for good where the platform cannot search for it. @@ -448,18 +460,19 @@ async def _refresh_attention( def _report_abandoned( self, key: tuple[str, ...], slot: str, reason: str, abandoned_at: str ) -> None: - """Say once, here, that a slot has been given up on. - - Reported where the decision is made rather than by each caller, - because the callers differ in what they do about it and not in what - happened. Once per process: the condition is permanent, so a line per - publish cycle would be the same sentence every few seconds for as long - as the bridge runs, and a restart genuinely is worth one line β€” it is - the only place an operator learns that a turn on this platform is - showing less than it should. - - Bounded, and an eviction costs one repeated warning rather than a - missed one, which is the right way round. + """Say, here rather than at each caller, that a slot is given up on. + + Reported where the decision is made because the callers differ in what + they do about it and not in what happened. + + Said rarely rather than a guaranteed number of times. The condition is + permanent, so a line per publish cycle would be the same sentence every + few seconds for as long as the bridge runs, and this suppresses that. + It does not promise once: the cache is bounded and per process, so an + eviction or a restart can repeat a warning. That is the right way for + it to be wrong β€” a repeated line is read twice, and a missed one is the + only place an operator would have learned that a turn on this platform + is showing less than it should. """ seen = (*key, slot) if seen in self._abandoned: @@ -512,12 +525,27 @@ async def _post_activity( "Activity journal message reference must be a string." ) return saved_ref - if not self._recovers_posts: + if not self._recovers_posts or not self._marks_publications: # The send may well have landed; nothing here can find out. # Posting again on every cycle would put one unwanted copy in # the chat per cycle, so the reservation is kept and this slot # stays as it is. The attention message is published # separately and is not held up by it. + # + # Two ways to arrive, and the difference is worth saying out + # loud because only one of them is about the platform. Either + # nothing here can be searched for, or it can but only by the + # handle a card prints β€” and every slot reserved through this + # method is a turn's own message, which prints none. Neither + # is a lookup that came back empty: both are known before + # looking, which is why a miss elsewhere still means "ask + # again". + nowhere = ( + "this platform cannot search for it" + if not self._recovers_posts + else "this platform can only find a publication by the " + "handle it prints, and a turn's own messages print none" + ) abandoned_at = delivery.get("abandoned_at") if not abandoned_at: abandoned_at = datetime.now(UTC).isoformat() @@ -526,9 +554,9 @@ async def _post_activity( await record.save() reason = ( f"The {slot} message sent as {delivery['token']} in " - f"{delivery['channel']} was never acknowledged, and this " - "platform cannot search for it. Keeping its reservation " - "rather than posting a second one that may duplicate it." + f"{delivery['channel']} was never acknowledged, and " + f"{nowhere}. Keeping its reservation rather than posting " + "a second one that may duplicate it." ) self._report_abandoned(record.key, slot, reason, abandoned_at) raise ActivityAbandoned(reason, slot=slot, abandoned_at=abandoned_at) diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index cef9ad28f..f05e12c57 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -168,6 +168,11 @@ class SlackAdapter(CollaborationAdapter): renders_legacy_runtime_state: ClassVar[bool] = False recovers_uncertain_posts: ClassVar[bool] = True + #: Every publication carries its token in `block_id` and in the message's + #: metadata, so a status is as findable as a card despite printing no + #: handle of its own. + carries_publication_marker: 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 # bridge minted. Rebind it to a fresh instance to isolate a test. diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 5124c6270..1e4367b55 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -11,7 +11,6 @@ from switch_core.bridges.collaboration.adapter import RichContentThrottled from switch_core.bridges.collaboration.session.outbound import ( - ActivityAbandoned, SessionRequestCards, SessionTurnActivity, ) @@ -717,14 +716,6 @@ async def refresh_activity( retry_delayed(token, error.retry_after) backed_off += 1 continue - except ActivityAbandoned: - # Settled, in the only sense left: this turn's display can never - # be confirmed, so there is nothing to come back for. Counting it - # as a failure would report the same permanent condition every - # cycle and hold the session open for a retry that cannot change - # anything. Already reported where it was decided. - hold_back(session_id, turn.turn_id) - continue except Exception: logger.exception("Could not recover activity for turn %s", turn.turn_id) failed.append(turn.turn_id) @@ -852,12 +843,7 @@ def drawn( class _PermanentlyHeldBack: - """Turns nothing will draw again, kept per session. - - Two ways in, and they meet here because what follows is the same: a turn - a first sweep decided never to draw because it had already ended, and a - turn whose reserved message can never be confirmed on a platform that - cannot search for what it posted. Neither can change with a later look. + """Ended turns a first sweep decided never to draw, kept per session. Not `_TurnRedrawGuard`: that one is bounded and shared across every session on the bridge, evicting whichever entry was least recently diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index b1b169165..8c6e01288 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -14,7 +14,6 @@ ) from switch_core.bridges.collaboration.session.activity_journal import ActivityJournal from switch_core.bridges.collaboration.session.outbound import ( - ActivityAbandoned, CardNotPosted, SessionTurnActivity, ) @@ -23,6 +22,7 @@ SlackConnectionConfig, ) from switch_core.db.models import SdkSession, SessionActivityPost, require_tenant_id +from switch_core.sessions import publication from switch_core.sessions.publication import SessionPublisher from ..bridges.collaboration.test_mattermost_sdk_only import ( @@ -31,7 +31,7 @@ from ..bridges.collaboration.test_mattermost_sdk_only import _http_error from ..bridges.collaboration.test_mattermost_sdk_only import _posts as mm_posts from ..bridges.collaboration.test_session_activity import _items, _turn -from .test_authority import opened, setup +from .test_authority import host_event, opened, setup from .test_publication import Platform from .test_publication_retries import cards_for @@ -91,6 +91,29 @@ class UnsearchablePlatform(ActivitySlack): recovers_uncertain_posts = False +class UnmarkedPlatform(ActivitySlack): + """Discord's shape: it searches, but only for what prints its own handle. + + A webhook message carries no metadata this bridge can set, so a card is + recognised by the handle printed on it and a turn's messages, which print + none, cannot be recognised at all. That is a different thing from Telegram + having nowhere to look, and it has to be told apart from a search that + simply has not found the message yet. + """ + + carries_publication_marker = False + + def __init__(self): + super().__init__() + self.lookups = 0 + + async def find_request_card(self, channel, thread, token, created_at, handle): + self.lookups += 1 + return await super().find_request_card( + channel, thread, token, created_at, handle + ) + + class PerAgentSlack(ActivitySlack): """A platform where each agent marks the message as its own bot. @@ -115,6 +138,10 @@ async def mark_activity(self, channel, ref, *, agent_name, working, force=False) OUTBOUND_LOGGER = "switch_core.bridges.collaboration.session.outbound" +#: Longer than the publisher's longest wait before it retries a turn it could +#: not draw, so a sweep in a test is never the one that is skipped. +PAST_THE_RETRY_BACKOFF = 60.0 + def activity(factory, platform): return SessionTurnActivity(platform, journal=ActivityJournal(factory, "bridge")) @@ -338,6 +365,10 @@ async def test_a_status_a_platform_cannot_search_is_never_posted_a_second_time( cycle or on any of the hundreds after it β€” puts one more copy of the same status in the chat each time. The reservation stays and the turn keeps the status it may already have. + + What it does not do is refuse: the loss is the status slot's, and the turn + around it still has work left, so each pass comes back having done as much + as it can rather than as a failure to try again. """ await setup(session_factory) platform = UnsearchablePlatform() @@ -347,8 +378,7 @@ async def test_a_status_a_platform_cannot_search_is_never_posted_a_second_time( assert platform.post_count == 1 for _ in range(3): - with pytest.raises(CardNotPosted): - await publish(activity(session_factory, platform)) + assert await publish(activity(session_factory, platform)) is True assert platform.post_count == 1 @@ -363,23 +393,25 @@ async def test_a_status_nobody_can_confirm_is_written_off_once_and_not_again( which would otherwise report a months-old conclusion as though it had just reached it. The stamp is taken once and kept. """ + + async def stamp(): + async with session_factory() as db: + row = await db.scalar(select(SessionActivityPost)) + return row.data["status"].get("abandoned_at") + await setup(session_factory) platform = UnsearchablePlatform() platform.fail_after_post = True with pytest.raises(TimeoutError): await publish(activity(session_factory, platform)) + assert await stamp() is None # Unfinished, not yet given up on. - with pytest.raises(ActivityAbandoned) as written_off: - await publish(activity(session_factory, platform)) - assert written_off.value.slot == "status" - - async with session_factory() as db: - row = await db.scalar(select(SessionActivityPost)) - assert row.data["status"]["abandoned_at"] == written_off.value.abandoned_at + await publish(activity(session_factory, platform)) + written_off = await stamp() + assert written_off - with pytest.raises(ActivityAbandoned) as again: - await publish(activity(session_factory, platform)) - assert again.value.abandoned_at == written_off.value.abandoned_at + await publish(activity(session_factory, platform)) + assert await stamp() == written_off assert platform.post_count == 1 @@ -493,6 +525,154 @@ async def report(renderer, status="running"): assert platform.edit_refs # The status is still being drawn. +async def test_a_turn_whose_status_was_abandoned_still_reports_and_ends( + session_factory, + monkeypatch, +): + """Giving up on the status is not giving up on the turn. + + Driven through the real publisher, because the regression it guards was a + publisher one: an unconfirmable status made the whole turn permanently + uninteresting, so the failure that happened afterwards was never told to + anyone and the turn was never tidied up. A status is one of the things a + turn shows. Losing it says nothing about whether the agent then failed, + whether somebody needs to be told, or whether the `:eyes:` should come off + the message that asked. + """ + service, epoch = await setup(session_factory) + await opened(service, epoch) + clock = [0.0] + monkeypatch.setattr(publication.time, "monotonic", lambda: clock[0]) + platform = UnsearchablePlatform() + platform.fail_after_post = True + publisher = SessionPublisher( + session_factory, + "bridge", + cards_for(session_factory, Platform()), + activity(session_factory, platform), + ) + + async def sweep(sequence, status): + await service.ingest( + "agent-demo", + "host-demo", + host_event( + epoch, + sequence, + { + "type": "turn.upsert", + "turnId": "turn-demo", + "status": status, + "commandId": "message-demo", + }, + ), + ) + clock[0] += PAST_THE_RETRY_BACKOFF + await publisher.publish_pending() + + await publisher.publish_pending() # Sends the status; the response is lost. + await sweep(3, "running") # Gives the status up as unconfirmable. + async with session_factory() as db: + row = await db.scalar(select(SessionActivityPost)) + assert row.data["status"]["abandoned_at"] + + await sweep(4, "error") + + assert any( + "could not complete this request" in str(message) + for message in platform.messages.values() + ) + async with session_factory() as db: + row = await db.scalar(select(SessionActivityPost)) + assert row.data["completed"] is True # Ended and tidied, not left open. + + +async def test_an_abandoned_turn_still_lets_go_of_the_asking_message(session_factory): + """The mark is shared, so a turn that gives up still has to release it. + + Two turns are working on one asking message, and the `:eyes:` on it belongs + to both: it comes off when the last of them finishes, not the first. A turn + whose status can never be confirmed is still one of the two. If abandoning + it also abandoned its claim, the mark would sit on the message for the life + of the process and the other turn would never be able to take it off. + """ + await setup(session_factory) + platform = UnsearchablePlatform() + platform.fail_after_post = True + with pytest.raises(TimeoutError): + await publish(activity(session_factory, platform)) # Loses its status. + await publish(activity(session_factory, platform), agent="Other", command="other") + assert platform.reactions == {"channel-demo:question"} + + await publish(activity(session_factory, platform), "completed") + assert platform.reactions == {"channel-demo:question"} # The other turn holds it. + await publish( + activity(session_factory, platform), + "completed", + agent="Other", + command="other", + ) + assert not platform.reactions + + +async def test_a_status_the_search_could_never_match_is_not_searched_for( + session_factory, +): + """Knowing the answer beforehand is not the same as a lookup coming back empty. + + This platform can search, and for a card it works. A turn's status prints + no handle, so the same search has nothing to match on and will answer the + same way for as long as it is asked. Asking anyway is not harmless: it + reads a channel's history on every publish cycle, for the life of the + process, to be told what was known before the first call. + """ + await setup(session_factory) + platform = UnmarkedPlatform() + platform.fail_after_post = True + with pytest.raises(TimeoutError): + await publish(activity(session_factory, platform)) + + assert await publish(activity(session_factory, platform)) is True + assert platform.lookups == 0 + assert platform.post_count == 1 + async with session_factory() as db: + row = await db.scalar(select(SessionActivityPost)) + assert row.data["status"]["abandoned_at"] + + +async def test_a_search_that_misses_is_asked_again_and_not_written_off( + session_factory, +): + """The opposite case, and the one a capability must not swallow. + + A platform carrying a marker can recognise anything it posted, so a lookup + that comes back empty means the message is not there *yet* β€” the post may + still be settling, or the read may have failed. Treating that as permanent + would abandon a status that is about to be found, so the reservation is + kept and the question asked again. + """ + await setup(session_factory) + platform = ActivitySlack() + platform.fail_after_post = True + with pytest.raises(TimeoutError): + await publish(activity(session_factory, platform)) + posted = dict(platform.messages) + platform.messages.clear() + + with pytest.raises(CardNotPosted): + await publish(activity(session_factory, platform)) + async with session_factory() as db: + row = await db.scalar(select(SessionActivityPost)) + assert "abandoned_at" not in row.data["status"] + + platform.messages.update(posted) + await publish(activity(session_factory, platform)) + async with session_factory() as db: + row = await db.scalar(select(SessionActivityPost)) + # Bound to the status that was there all along, not a second one. + assert row.data["status"]["ref"] in posted + + async def test_an_unconfirmed_status_does_not_swallow_the_attention_message( session_factory, ): @@ -525,8 +705,7 @@ async def report(renderer): await report(activity(session_factory, platform)) platform.messages.clear() - with pytest.raises(CardNotPosted): - await report(activity(session_factory, platform)) + await report(activity(session_factory, platform)) assert any( "The host went away." in str(message) for message in platform.messages.values() ) From ef7a24323d04065a1afb3dde0803890b7a632138 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 07:03:43 +0100 Subject: [PATCH 016/120] Let a refusal speak only for the attempt it answers One reaction is shared by every turn working on the same message, and the evidence that it may be on there is shared with it. Two ways of clearing that evidence cleared more of it than they had grounds to, and both end the same way: a turn reports itself tidied up with the eyes still in plain sight and nothing left to make it try again. A refused addition now retracts the attempt it refused and nothing else. It is an answer about one request, not a report on the message, and an earlier turn's reaction can be sitting there regardless. A removal now clears the claims that existed when it was sent. Its acknowledgement can arrive after another publisher has started a turn on the same message and marked it afresh, and that mark really is there. The three sequences are covered against a real database: an addition refused after a restart, an acknowledgement overtaken by another publisher, and the same confusion inside one process, where memory is the only evidence there is. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/activity_journal.py | 44 ++++++- .../bridges/collaboration/session/outbound.py | 118 +++++++++++++----- .../sessions/test_activity_durability.py | 117 +++++++++++++++++ 3 files changed, 243 insertions(+), 36 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index c5097b3ee..634c0f552 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -126,19 +126,51 @@ async def mark_expected( ).first() ) + async def mark_holders( + self, + mark: dict[str, str], + *, + sessions: async_sessionmaker[AsyncSession], + ) -> set[tuple[str, str]]: + """Which turns are expecting this reaction right now. + + Read immediately before a removal is asked for, so that what the + removal later clears is what it was actually removing. A turn that + starts expecting the mark after this read has asked for a reaction of + its own, and the answer to a request issued before it existed says + nothing about that one. + """ + async with sessions() as db: + rows = await db.scalars( + select(SessionActivityPost).where( + SessionActivityPost.tenant_id == require_tenant_id(), + SessionActivityPost.bridge_id == self.bridge_id, + SessionActivityPost.data.contains({"mark": mark}), + ) + ) + return {(row.session_id, row.command_id) for row in rows} + async def forget_mark( self, mark: dict[str, str], *, + holders: set[tuple[str, str]], sessions: async_sessionmaker[AsyncSession], ) -> None: - """Erase every turn's expectation of this reaction at once. + """Erase the expectation of this reaction, for the given turns only. + + Called when the platform has taken the mark off. Every holder it was + taken off on behalf of loses its expectation together, because they are + all talking about one reaction and one left behind would have a later + turn on that message waiting forever on a mark nobody can remove. - Called when the platform has said the mark is not there β€” it was - refused, or it has been taken off. One reaction, so one answer: a - holder left still expecting it would have the next turn on that message - refuse to finish, waiting on a mark nobody can remove. + `holders` rather than all of them, because a removal answers only for + the claims that existed when it was issued. Its acknowledgement can + arrive after another publisher has put the mark back for a new turn, + and that turn's mark really is on the message. """ + if not holders: + return async with sessions() as db: rows = await db.scalars( select(SessionActivityPost).where( @@ -148,6 +180,8 @@ async def forget_mark( ) ) for row in rows: + if (row.session_id, row.command_id) not in holders: + continue data = dict(row.data) data.pop("mark", None) row.data = data diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index eafdb6dd2..e928b3ee0 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -123,7 +123,7 @@ def _violates(error: IntegrityError, constraint: str) -> bool: def _mark_id(mark: dict[str, str]) -> tuple[str, str, str]: - """The same reaction as a key this process can hold in a set.""" + """The same reaction as a key this process can look it up by.""" return (mark["channel_id"], mark["reaction_ref"], mark["agent_name"]) @@ -203,7 +203,7 @@ def __init__( self._marks_publications = getattr(adapter, "carries_publication_marker", False) self._anchors: OrderedDict[tuple[str, str], _Anchor] = OrderedDict() self._thread_turns: dict[tuple[str, str, str], set[tuple[str, str]]] = {} - self._expecting: set[tuple[str, str, str]] = set() + self._expecting: dict[tuple[str, str, str], set[tuple[str, str]]] = {} self._attention: OrderedDict[tuple[str, str], tuple[str, str]] = OrderedDict() @property @@ -885,7 +885,7 @@ async def _claim_thread(self, key: tuple[str, str], anchor: _Anchor) -> None: return turns = self._thread_turns.setdefault(self._thread_key(anchor), set()) first = not turns - if not first or await self._mark_thread(anchor, working=True): + if not first or await self._mark_thread(key, anchor, working=True): turns.add(key) async def _release_thread(self, key: tuple[str, str], anchor: _Anchor) -> bool: @@ -922,7 +922,7 @@ async def _release_thread(self, key: tuple[str, str], anchor: _Anchor) -> bool: sessions=record.sessions if record else self._journal.sessions, ): return True - return await self._mark_thread(anchor, working=False) + return await self._mark_thread(key, anchor, working=False) def _thread_key(self, anchor: _Anchor) -> tuple[str, str, str]: """Who is holding what, keyed by whose reaction it actually is. @@ -938,7 +938,9 @@ def _thread_key(self, anchor: _Anchor) -> tuple[str, str, str]: agent = anchor.agent_name if self._reactions_per_agent else "" return (anchor.channel_id, anchor.reaction_ref or "", agent) - async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: + async def _mark_thread( + self, key: tuple[str, str], anchor: _Anchor, *, working: bool + ) -> bool: """Put `:eyes:` on the message that actually asked, or take it off. Not necessarily the thread root β€” a turn threaded under a reply deep @@ -948,16 +950,21 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: callers can retry a failed claim or unfinished terminal cleanup. A platform that refuses the mark outright raises `ActivityMarkRefused`. - Refused on the way *on*, the mark is simply absent and the turn goes on - without it. Refused on the way *off*, the question is whether a mark is - still sitting on the message β€” and that is a question about the mark, - not about this turn, because turns share one. It is answered from - `_expecting`, which is written before the platform is called and - retracted only when the platform says outright that nothing was put - there. Anything less certain leaves the expectation standing, so an - addition whose outcome is unknown counts as a mark that may be on the - message. Claiming otherwise would leave a channel showing an agent - still working on something it has finished. + Refused on the way *on*, this turn's own attempt put nothing there and + the turn goes on without it. Refused on the way *off*, the question is + whether a mark is still sitting on the message β€” and that is a question + about the mark, not about this turn, because turns share one. It is + answered from the expectations recorded against that mark, written + before the platform is called and retracted only when the platform says + outright what became of them. Anything less certain leaves an + expectation standing, so an addition whose outcome is unknown counts as + a mark that may be on the message. Claiming otherwise would leave a + channel showing an agent still working on something it has finished. + + Which expectations a removal retracts is settled before it is sent, not + after it is answered. Between the two, another publisher can put the + mark back for a turn of its own, and that turn's expectation is not + this removal's to clear. """ if anchor.reaction_ref is None or not getattr( self._adapter, "supports_activity_reactions", False @@ -965,7 +972,10 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: return True mark = self._mark_key(anchor) if working: - await self._expect_mark(mark) + await self._expect_mark(key, mark) + removing: set[tuple[str, str]] = set() + else: + removing = await self._claimants(mark) try: await self._adapter.mark_activity( anchor.channel_id, @@ -976,7 +986,7 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: ) except ActivityMarkRefused as refusal: if working: - await self._forget_mark(mark) + await self._retract_claim(key, mark) logger.warning("%s The turn goes on without the mark.", refusal) return True if not await self._mark_may_be_there(mark): @@ -1001,7 +1011,7 @@ async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: ) return False if not working: - await self._forget_mark(mark) + await self._mark_taken_off(mark, removing) return True def _mark_key(self, anchor: _Anchor) -> dict[str, str]: @@ -1020,42 +1030,88 @@ def _mark_key(self, anchor: _Anchor) -> dict[str, str]: "agent_name": anchor.agent_name if self._reactions_per_agent else "", } - async def _expect_mark(self, mark: dict[str, str]) -> None: - """Record that a mark may be on this message, before asking for it. + async def _expect_mark(self, key: tuple[str, str], mark: dict[str, str]) -> None: + """Record that this turn's mark may be on the message, before asking. Before, not after, because a request that fails without an answer may still have landed. Written where the answer will be needed: durably when there is a journal, since the turn that eventually takes the mark off may be running in a later process than the turn that put it on. + + Recorded per turn rather than once per reaction, so that a retraction + can say which attempt it is retracting. """ - if _mark_id(mark) in self._expecting: + expecting = self._expecting.setdefault(_mark_id(mark), set()) + if key in expecting: return - self._expecting.add(_mark_id(mark)) + expecting.add(key) record = self._record.get() if record is None or record.data.get("mark") == mark: return record.data["mark"] = mark await record.save() - async def _forget_mark(self, mark: dict[str, str]) -> None: - """Drop the expectation, once the mark is known not to be there. + async def _retract_claim(self, key: tuple[str, str], mark: dict[str, str]) -> None: + """Drop this turn's expectation, after its own attempt was refused. - Two ways to know: the platform refused to put it there at all, or it - took it off. Every holder's evidence goes, not only this turn's, because - they are all talking about the same reaction β€” one left behind would - have a later turn on that message reporting a mark that is not there - and never finishing. + Only this turn's. A refusal describes the attempt it answers: it says + nothing about an addition another turn made earlier, which may well be + sitting on the message still. Erasing that one as well is how a mark + comes to be reported as cleaned up with the πŸ‘€ in plain sight. + """ + expecting = self._expecting.get(_mark_id(mark)) + if expecting is not None: + expecting.discard(key) + if not expecting: + del self._expecting[_mark_id(mark)] + record = self._record.get() + if record is not None and record.data.pop("mark", None) is not None: + await record.save() + + async def _mark_taken_off( + self, mark: dict[str, str], holders: set[tuple[str, str]] + ) -> None: + """Drop the expectations the platform has just answered for. + + Every holder the removal was made on behalf of, not only this turn, + because they are all talking about the same reaction β€” one left behind + would have a later turn on that message reporting a mark that is not + there and never finishing. `holders` is read before the removal is + sent, so a turn that claimed the mark while it was in flight keeps its + claim. """ - self._expecting.discard(_mark_id(mark)) + expecting = self._expecting.get(_mark_id(mark)) + if expecting is not None: + expecting -= holders + if not expecting: + del self._expecting[_mark_id(mark)] record = self._record.get() if record is not None and record.data.pop("mark", None) is not None: await record.save() if self._journal is not None: await self._journal.forget_mark( mark, + holders=holders, sessions=record.sessions if record else self._journal.sessions, ) + async def _claimants(self, mark: dict[str, str]) -> set[tuple[str, str]]: + """The turns expecting this mark, as of now. + + Both halves of the evidence: what this process remembers claiming, and + what any process has written down. Taken together because a publisher + with no journal has only the first, and a publisher restarted into one + has only the second. + """ + claimants = set(self._expecting.get(_mark_id(mark), ())) + if self._journal is None: + return claimants + record = self._record.get() + return claimants | await self._journal.mark_holders( + mark, + sessions=record.sessions if record else self._journal.sessions, + ) + async def _mark_may_be_there(self, mark: dict[str, str]) -> bool: """Whether a refused removal leaves something behind. @@ -1065,7 +1121,7 @@ async def _mark_may_be_there(self, mark: dict[str, str]) -> bool: this process's memory, which is sound for a publisher that has no durable state to be restarted into. """ - if _mark_id(mark) in self._expecting: + if self._expecting.get(_mark_id(mark)): return True if self._journal is None: return False diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 8c6e01288..c21c097ae 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -1242,3 +1242,120 @@ async def test_a_publisher_with_no_journal_still_owes_a_mark_it_put_there( unmarked = SessionTurnActivity(elsewhere) assert await publish(unmarked) assert await publish(unmarked, "completed") + + +async def test_a_refused_addition_does_not_speak_for_a_mark_already_there( + session_factory, +): + """One turn's mark goes on; a later turn is refused its own; the first ends last. + + The refusal answers the attempt that provoked it. It is not a report on the + message, and the reaction the earlier turn put there is still in plain + sight. Reading it as one retracts everybody's evidence at once, and the + turn that actually owes the cleanup then finishes with the πŸ‘€ still on. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + assert await publish( + activity(session_factory, RefusingPlatform(chat)), command="first" + ) + assert chat["reactions"] == {"channel-demo:question"} + + after_restart = RefusingPlatform(chat, refuse_add=True) + renderer = activity(session_factory, after_restart) + assert await publish(renderer, command="second") + assert chat["reactions"] == {"channel-demo:question"} + + # The first turn is still running, so the second leaves the shared mark be. + assert await publish(renderer, "completed", command="second") + assert chat["reactions"] == {"channel-demo:question"} + + after_restart.refuse_remove = True + assert not await publish(renderer, "completed", command="first") + assert chat["reactions"] == {"channel-demo:question"} + + after_restart.refuse_remove = False + assert await publish(renderer, "completed", command="first") + assert not chat["reactions"] + + +class DelayedRemoval(RefusingPlatform): + """A removal the platform has carried out whose answer is still in flight. + + Another publisher gets the message in that gap and puts the mark back. What + the acknowledgement then settles is the reaction that came off, not the one + now sitting there. + """ + + def __init__(self, chat, *, while_unacknowledged): + super().__init__(chat) + self.while_unacknowledged = while_unacknowledged + + async def mark_activity(self, channel, ref, *, agent_name, working, force=False): + await super().mark_activity( + channel, ref, agent_name=agent_name, working=working, force=force + ) + if not working: + await self.while_unacknowledged() + + +async def test_a_removal_in_flight_does_not_clear_a_mark_put_back_behind_it( + session_factory, +): + """Two publishers, one reaction, and an acknowledgement that arrives late. + + The removal is answering for the claims that existed when it was sent. By + the time it comes back another publisher has started a turn on the same + message and marked it afresh, and that mark is really there. Clearing every + claim on the strength of one removal loses it, and after a restart there is + nothing left to say the reaction was ever added. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + second = activity(session_factory, RefusingPlatform(chat)) + + async def another_publisher_takes_the_message(): + assert await publish(second, command="second") + assert chat["reactions"] == {"channel-demo:question"} + + first = activity( + session_factory, + DelayedRemoval(chat, while_unacknowledged=another_publisher_takes_the_message), + ) + assert await publish(first, command="first") + assert chat["reactions"] == {"channel-demo:question"} + assert await publish(first, "completed", command="first") + assert chat["reactions"] == {"channel-demo:question"} + + after_restart = activity( + session_factory, RefusingPlatform(chat, refuse_remove=True) + ) + + assert not await publish(after_restart, "completed", command="second") + assert chat["reactions"] == {"channel-demo:question"} + + +async def test_a_refused_addition_leaves_one_publishers_own_earlier_mark_standing( + session_factory, +): + """The same confusion within one process, where memory is the only evidence. + + A turn puts the mark on and cannot get it off; permission goes; the next + turn on that message is refused the addition. Both turns are held by the + one publisher, so one shared note of "expected" is all there was to lose. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + platform = RefusingPlatform(chat) + renderer = SessionTurnActivity(platform) + + assert await publish(renderer, command="first") + platform.refuse_remove = True + assert not await publish(renderer, "completed", command="first") + assert chat["reactions"] == {"channel-demo:question"} + + platform.refuse_add = True + assert await publish(renderer, command="second") + + assert not await publish(renderer, "completed", command="second") + assert chat["reactions"] == {"channel-demo:question"} From 79c206523ce17572acc411a0960e676fd9947047 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 08:36:16 +0100 Subject: [PATCH 017/120] Let a turn keep the grounds an earlier attempt gave it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn asks for the reaction again every time it is published, so an addition refused now can be the second attempt against a mark the first one already put there β€” after a restart, or after a request whose answer was lost. Retracting the turn's own earlier grounds on the strength of that refusal ends where the last one did: a turn reporting itself tidied up with the eyes in plain sight. The expectation is now retracted only by the attempt that recorded it. Both sequences are covered against a real database, and both carry on to check the mark does come off once the chat will take it. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 51 ++++++++---- .../sessions/test_activity_durability.py | 81 +++++++++++++++++++ 2 files changed, 117 insertions(+), 15 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index e928b3ee0..c9ba30479 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -971,9 +971,10 @@ async def _mark_thread( ): return True mark = self._mark_key(anchor) + removing: set[tuple[str, str]] = set() + recorded_here = False if working: - await self._expect_mark(key, mark) - removing: set[tuple[str, str]] = set() + recorded_here = await self._expect_mark(key, mark) else: removing = await self._claimants(mark) try: @@ -986,7 +987,8 @@ async def _mark_thread( ) except ActivityMarkRefused as refusal: if working: - await self._retract_claim(key, mark) + if recorded_here: + await self._retract_claim(key, mark) logger.warning("%s The turn goes on without the mark.", refusal) return True if not await self._mark_may_be_there(mark): @@ -1030,7 +1032,7 @@ def _mark_key(self, anchor: _Anchor) -> dict[str, str]: "agent_name": anchor.agent_name if self._reactions_per_agent else "", } - async def _expect_mark(self, key: tuple[str, str], mark: dict[str, str]) -> None: + async def _expect_mark(self, key: tuple[str, str], mark: dict[str, str]) -> bool: """Record that this turn's mark may be on the message, before asking. Before, not after, because a request that fails without an answer may @@ -1040,24 +1042,34 @@ async def _expect_mark(self, key: tuple[str, str], mark: dict[str, str]) -> None Recorded per turn rather than once per reaction, so that a retraction can say which attempt it is retracting. + + Returns whether this attempt is the one that recorded the expectation. + A turn asks for the mark again whenever it is republished, so an + addition refused now can be the second attempt against a reaction an + earlier one already put there, or already left in doubt. The refusal + answers the attempt it was given, and grounds this turn already had are + not its to take away. """ expecting = self._expecting.setdefault(_mark_id(mark), set()) - if key in expecting: - return - expecting.add(key) record = self._record.get() - if record is None or record.data.get("mark") == mark: - return - record.data["mark"] = mark - await record.save() + already = key in expecting or ( + record is not None and record.data.get("mark") == mark + ) + expecting.add(key) + if record is not None and record.data.get("mark") != mark: + record.data["mark"] = mark + await record.save() + return not already async def _retract_claim(self, key: tuple[str, str], mark: dict[str, str]) -> None: """Drop this turn's expectation, after its own attempt was refused. - Only this turn's. A refusal describes the attempt it answers: it says - nothing about an addition another turn made earlier, which may well be - sitting on the message still. Erasing that one as well is how a mark - comes to be reported as cleaned up with the πŸ‘€ in plain sight. + Only this turn's, and only where this attempt is what recorded it. A + refusal describes the attempt it answers: it says nothing about an + addition made earlier β€” by another turn, or by this one before a + restart β€” which may well be sitting on the message still. Erasing that + as well is how a mark comes to be reported as cleaned up with the πŸ‘€ in + plain sight. """ expecting = self._expecting.get(_mark_id(mark)) if expecting is not None: @@ -1102,6 +1114,15 @@ async def _claimants(self, mark: dict[str, str]) -> set[tuple[str, str]]: what any process has written down. Taken together because a publisher with no journal has only the first, and a publisher restarted into one has only the second. + + Turns, not attempts, and that is enough. What a snapshot of turns could + miss is a turn renewing its claim between the read and the answer, so + that the answer clears a mark put there after it. A turn claims only + while it has not ended and releases only once it has, and its two + publications cannot overlap: the journal holds the record lock for that + one turn across the whole of this, and a publisher without a journal + publishes a turn at a time. So the claim a removal clears under a given + turn is the claim it read. """ claimants = set(self._expecting.get(_mark_id(mark), ())) if self._journal is None: diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index c21c097ae..fe6ed13e9 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -1359,3 +1359,84 @@ async def test_a_refused_addition_leaves_one_publishers_own_earlier_mark_standin assert not await publish(renderer, "completed", command="second") assert chat["reactions"] == {"channel-demo:question"} + + +async def test_a_turn_refused_its_second_mark_still_owes_the_one_it_put_there( + session_factory, +): + """No second turn needed: one turn, restarted, refused where it succeeded before. + + A turn asks for the mark again every time it is published, so the addition + refused after a restart is a second attempt against a reaction the first + one already put there. Letting the refusal retract the turn's own earlier + grounds is the same mistake as letting it retract another turn's, and ends + the same way β€” a clean-looking finish under a visible πŸ‘€. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + assert await publish(activity(session_factory, RefusingPlatform(chat))) + assert chat["reactions"] == {"channel-demo:question"} + + after_restart = RefusingPlatform(chat, refuse_add=True) + renderer = activity(session_factory, after_restart) + assert await publish(renderer) + assert chat["reactions"] == {"channel-demo:question"} + + after_restart.refuse_remove = True + assert not await publish(renderer, "completed") + assert chat["reactions"] == {"channel-demo:question"} + + after_restart.refuse_remove = False + assert await publish(renderer, "completed") + assert not chat["reactions"] + + +class LostAcknowledgement(RefusingPlatform): + """The reaction goes on and the answer to the request never comes back. + + Indistinguishable, from here, from one that never landed β€” which is the + point: the expectation is written before the request and survives an answer + that does not arrive. + """ + + def __init__(self, chat): + super().__init__(chat) + self.lose_the_answer = True + + async def mark_activity(self, channel, ref, *, agent_name, working, force=False): + await super().mark_activity( + channel, ref, agent_name=agent_name, working=working, force=force + ) + if working and self.lose_the_answer: + raise TimeoutError("the answer to the reaction never came back") + + +async def test_an_addition_whose_answer_was_lost_survives_a_refused_retry( + session_factory, +): + """The other way a turn comes to attempt the mark twice. + + The first request landed and its answer did not, so the turn retries β€” and + by then the chat will not take the reaction. Reading that second refusal as + proof the message is clear discards the one piece of evidence there was + that something may be sitting on it. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + platform = LostAcknowledgement(chat) + renderer = activity(session_factory, platform) + + assert await publish(renderer) + assert chat["reactions"] == {"channel-demo:question"} + + platform.lose_the_answer = False + platform.refuse_add = True + assert await publish(renderer) + + platform.refuse_remove = True + assert not await publish(renderer, "completed") + assert chat["reactions"] == {"channel-demo:question"} + + platform.refuse_remove = False + assert await publish(renderer, "completed") + assert not chat["reactions"] From c09e965098d17076f3bc058049ccc6de96ef17b6 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 10:36:16 +0100 Subject: [PATCH 018/120] Teams SDK session parity, and a connector that says what happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams was not part-migrated, it was unstarted: no capability declared it published SDK sessions, so the bridge built no publisher for it and every turn went through the legacy runtime-state renderer. It now publishes the same way the other four do, and the legacy renderer no longer runs. The connector had to go first. One flattened BotConnectorError told a caller nothing it could act on, raw httpx exceptions escaped it entirely, and an accepted activity with no id came back as the empty string. Nothing above it could be truthful about a publication while that was the vocabulary. The hierarchy is now cut on certainty rather than severity: a refusal means nothing was written and the caller may throw its reservation away; a timeout, a 5xx, or an acceptance with no id mean the message may well be sitting in the channel, so they are their own kinds and the reservation survives them. An activity over Teams' size is refused here, named in bytes, rather than sent to earn a 413 nobody can read. A Teams edit is addressed to the conversation, and inside a channel post the conversation is named by the thread rather than by the message β€” so a redraw has to be told which thread the publication went into. update_rich gains thread_root_id on every adapter, required rather than defaulted, fed from the journal's anchor and the card row. Both were already durable, so this needed no migration; what was missing was the pass-through. The adapter deliberately does not fall back to its own _sent or _last_post maps: the first is empty after a restart and the second is the relay's guess at where an untied reply belongs. A finished status is taken out of a chat and left in place in a posts channel, because Teams replaces a deleted message with a tombstone that says less than the status it removed. A refused removal is not treated as a removal that happened: the final state is written into the message instead and the refusal is logged. An uncertain one is raised, because a status recorded as cleaned up when it was not is one that never goes. Card text is drawn with a markup that has no code span, since an Adaptive Card TextBlock renders backticks as backticks and a handle is the one literal a card exists to be answered with. Held back deliberately, and none of it designed out: expandable activity (what an update does to toggled state is undocumented and needs a tenant), Action.Submit buttons (a listener change, not an adapter one), per- conversation write serialisation, and Graph-based recovery of an uncertain publication. With no recovery, the shared publisher discloses an unconfirmed card β€” that is the shared default, not a Teams decision, and it is Simon's to rule on. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 16 +- .../bridges/collaboration/discord/adapter.py | 1 + .../collaboration/mattermost/adapter.py | 1 + .../bridges/collaboration/session/outbound.py | 16 +- .../bridges/collaboration/slack/adapter.py | 1 + .../bridges/collaboration/teams/adapter.py | 471 ++++++++++++++++- .../bridges/collaboration/teams/connector.py | 266 ++++++++-- .../bridges/collaboration/telegram/adapter.py | 1 + .../collaboration/test_discord_sdk_only.py | 24 +- .../collaboration/test_mattermost_sdk_only.py | 12 +- .../collaboration/test_rich_content_port.py | 6 +- .../test_session_compact_presentation.py | 2 +- .../test_session_request_lifecycle.py | 1 + .../test_session_review_regressions.py | 6 +- .../collaboration/test_teams_adapter.py | 35 +- .../test_teams_channel_layout.py | 6 +- .../collaboration/test_teams_clients.py | 214 +++++++- .../test_teams_runtime_state_layout.py | 20 +- .../collaboration/test_teams_sdk_only.py | 496 ++++++++++++++++++ .../collaboration/test_telegram_sdk_only.py | 42 +- .../sessions/test_activity_durability.py | 27 +- .../switch_core/sessions/test_publication.py | 10 +- .../sessions/test_session_presentation.py | 4 +- .../test_turn_activity_publication.py | 2 +- 24 files changed, 1534 insertions(+), 146 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index c49703355..d190c6971 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -689,21 +689,29 @@ async def update_rich( agent_name: str, message_ref: str, content: RichContent, + thread_root_id: str | None, ) -> None: """Redraw what `post_rich` posted, in place. Falls back the same way `post_rich` does. Most adapters' `update_message` swallows its own errors by design, for the - runtime-status paths that depend on that β€” but not all of them (Teams' - raises on any non-2xx status), so this catches broadly rather than - trusting the convention: whichever it does, a caller of `update_rich` - sees `RichContentFailed` or nothing. + runtime-status paths that depend on that β€” but not all of them, so this + catches broadly rather than trusting the convention: whichever it does, + a caller of `update_rich` sees `RichContentFailed` or nothing. `agent_name` is the same name `post_rich` was given, and is here for the platform that writes it into the body: one bot identity means the name is part of what was drawn, so a redraw that did not know it would quietly rewrite the message as somebody else. Passing it on every call keeps that out of an in-memory map that a restart empties. + + `thread_root_id` is the same thread `post_rich` was given, for the same + reason. On most platforms a message id is an address on its own and + this is ignored; on Teams an edit is addressed to the *conversation*, + and for a reply inside a channel post that conversation is named by the + thread rather than by the message. Passing it keeps the one durable + answer flowing from the journal or the card row, instead of a + process-local map that a restart turns into a guess. """ text = self.rich_fallback_text(content) try: diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 4bb59baf5..accd62577 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -1109,6 +1109,7 @@ async def update_rich( agent_name: str, message_ref: str, content: RichContent, + thread_root_id: str | None, ) -> None: """Redraw a publication in place β€” or take it down, where it has served its purpose and staying would just be clutter. diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 5e1cf7a33..c4f5d05c6 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -759,6 +759,7 @@ async def update_rich( agent_name: str, message_ref: str, content: RichContent, + thread_root_id: str | None, ) -> None: """Redraw a publication in place, and say so when it did not happen. diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index c9ba30479..f004aaaff 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -445,9 +445,13 @@ async def _refresh_attention( "attention", ) if saved: - await self._adapter.update_rich(channel_id, agent_name, ref, content) + await self._adapter.update_rich( + channel_id, agent_name, ref, content, thread_root_id + ) else: - await self._adapter.update_rich(channel_id, agent_name, ref, content) + await self._adapter.update_rich( + channel_id, agent_name, ref, content, thread_root_id + ) if record: record.data["attention_state"] = state await record.save() @@ -818,6 +822,7 @@ async def _edit( status_only=self._separate_activity_log, session_url=anchor.session_url, ), + anchor.thread_root_id, ) except RichContentThrottled: raise @@ -860,7 +865,11 @@ async def _draw_log( ) else: await self._adapter.update_rich( - anchor.channel_id, anchor.agent_name, anchor.log_ref, content + anchor.channel_id, + anchor.agent_name, + anchor.log_ref, + content, + anchor.thread_root_id, ) except RichContentThrottled: raise @@ -1579,6 +1588,7 @@ async def refresh( responder_external_id=responder_external_id, unavailable_reason=unavailable_reason, ), + post.thread_id, ) except RichContentThrottled: raise diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index f05e12c57..01ccc5ef0 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -528,6 +528,7 @@ async def update_rich( agent_name: str, message_ref: str, content: RichContent, + thread_root_id: str | None, ) -> None: """Redraw what `post_rich` posted, in place. diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 9168776ae..efc6c16e1 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -24,6 +24,11 @@ from switch_core.bridges.collaboration.adapter import ( CollaborationAdapter, LiveRuntimeIndicator, + RequestCard, + RichContent, + RichContentFailed, + RichContentThrottled, + TurnActivity, format_elapsed, ) from switch_core.bridges.collaboration.models import ( @@ -37,6 +42,11 @@ InboundMessage, InboundUserJoin, ) +from switch_core.bridges.collaboration.session.renderers import Markup +from switch_core.bridges.collaboration.session.renderers.neutral import ( + request_summary, + turn_status, +) from switch_core.bridges.collaboration.teams.auth import ( InboundActivityValidator, TeamsTokenProvider, @@ -45,13 +55,18 @@ agent_message_card, card_attachment, ) -from switch_core.bridges.collaboration.teams.connector import BotConnectorClient +from switch_core.bridges.collaboration.teams.connector import ( + BotConnectorClient, + BotConnectorRefused, + BotConnectorThrottled, +) from switch_core.bridges.collaboration.teams.crypto import ( decrypt_resource_data, generate_encryption_keypair, load_certificate_der_b64, ) from switch_core.bridges.collaboration.teams.graph import GraphClient +from switch_core.sessions.contract import TURN_ENDED logger = logging.getLogger(__name__) @@ -65,6 +80,66 @@ _MENTION_END = r"(?![A-Za-z0-9._-])" _AT_TAG = re.compile(r"(.*?)", re.DOTALL) _ZERO_WIDTH_SPACE = "\u200b" + +_PUBLICATION_LIMIT = 2000 +"""How many characters a status or a card may spend on its body. + +A readability choice, not a safety one \u2014 the size Teams will actually accept +is enforced on the serialised activity in `connector.py`, which is where the +card structure, the mention entities and the two further copies of this text +in `summary` and `fallbackText` can all be counted. This is the separate +question of how much of a turn belongs in a message somebody has to scroll +past to reach the next one. 2000 is the same figure the platforms without a +renderer of their own inherit, kept because it reads well in a Teams post +rather than because it was inherited. +""" + +_THROTTLE_BACKOFF = 5.0 +"""How long to wait when Teams throttles without saying how long. + +Ours, not Microsoft's, and only used when the 429 carried no `Retry-After`. +""" + + +class _TeamsMarkup(Markup): + """Markdown as an Adaptive Card TextBlock actually parses it. + + A TextBlock renders a subset \u2014 emphasis, lists, links \u2014 and has no code + span at all, so a backtick reaches the reader as a backtick. That lands + worst on the one literal a card exists to be answered with: a handle drawn + as `` `R42` `` invites somebody to type the backticks with it. Bold is + emphasis Teams does render, so the literal is marked with that and arrives + as something to copy rather than something to decode. + """ + + def code(self, text: str) -> str: + return f"**{text}**" + + +_TEAMS_MARKUP = _TeamsMarkup() + + +def _retires(content: RichContent) -> bool: + """Whether this redraw is the end of something that should not stay. + + A status says work is happening, and once it is not the message has said + everything it had to say. Two things stay: a request card, which is the + record of a decision and shows on its face what became of it, and anything + still reporting a problem or a reader nobody reached, which is the message + somebody has to act on and outlives the turn that raised it. + + Whether retiring means removing it or writing its final state into it is + not decided here \u2014 in a Teams posts channel a deletion leaves wreckage + behind, and `_retire_rich` is where that is weighed. + """ + return ( + isinstance(content, TurnActivity) + and content.turn.status in TURN_ENDED + and not content.error_summary + and not content.notify_unreachable + ) + + # A Teams identifier standing where a person's name should be: a channel # account (`29:…`, `8:orgid:…`) or a bare Entra object id. _TEAMS_ID = re.compile( @@ -319,6 +394,43 @@ class TeamsAdapter(CollaborationAdapter): # makes the lifecycle say so at startup when it is not. renders_custom_url_schemes: ClassVar[bool] = False + publishes_sdk_sessions: ClassVar[bool] = True + + # One compact status per turn rather than a status and a tool log. A posts + # channel shows a thread as a stack of replies with no collapsing, so a + # second message per turn is a second thing to scroll past for every turn + # in the post. + separate_activity_log: ClassVar[bool] = False + + # A problem gets its own message. An edit to the status is not something + # Teams notifies anyone about, so a failure folded into it reaches whoever + # happens to be looking. + separate_attention_slot: ClassVar[bool] = True + + # Teams notifies on a mention and on little else: a reply inside a post + # reaches the people already following that post and nobody else. + notifies_only_by_mention: ClassVar[bool] = True + + # No redraw for the clock alone. An edit is charged against the same + # per-thread send budget as a message, and a turn's status is the turn's + # one post here, so the elapsed time rides along with the next real change. + redraws_for_elapsed_time: ClassVar[bool] = False + + # The Bot Connector gives a bot no way to add a reaction to a message. + supports_activity_reactions: ClassVar[bool] = False + activity_reactions_per_agent: ClassVar[bool] = False + + renders_legacy_runtime_state: ClassVar[bool] = False + + # Nothing here can look for a publication whose response was lost. The + # Graph credentials are app-only and the app's resource-specific consent + # covers reading channel messages, not chats; `GraphClient` has no + # message-listing call and the bridge has no paging anywhere; and a + # publication carries no marker a search could match even if it did. An + # uncertain card is disclosed rather than looked for. + recovers_uncertain_posts: ClassVar[bool] = False + carries_publication_marker: ClassVar[bool] = False + @classmethod async def prepare_config( cls, connection_config: dict[str, object] @@ -854,13 +966,11 @@ async def send_message( activity=activity, ) - if msg_id: - self._sent[msg_id] = (service_url, conversation_id) - await self._remember_post( - channel_id, thread_root_id or msg_id, only_if_unset=True - ) - return msg_id - return None + self._sent[msg_id] = (service_url, conversation_id) + await self._remember_post( + channel_id, thread_root_id or msg_id, only_if_unset=True + ) + return msg_id async def admin_message( self, @@ -902,18 +1012,24 @@ async def admin_message( activity=activity, ) - if msg_id: - self._sent[msg_id] = (service_url, conversation_id) - await self._remember_post( - channel_id, thread_root_id or msg_id, only_if_unset=True - ) - return msg_id - return None + self._sent[msg_id] = (service_url, conversation_id) + await self._remember_post( + channel_id, thread_root_id or msg_id, only_if_unset=True + ) + return msg_id def _locate(self, channel_id: str, message_ref: str) -> tuple[str, str]: """Resolve ``(service_url, conversation_id)`` for a previously sent message so it can be edited or deleted. Falls back to treating the - message as its own thread root when it wasn't sent in this session.""" + message as its own thread root when it wasn't sent in this session. + + A guess, and only good enough for the relay: a message posted as a + reply inside a post is addressed by that post, so after a restart this + reconstruction names a conversation that does not exist and the edit + is refused. SDK publications do not come through here β€” they carry the + thread they were posted into and resolve it through + `_publication_conversation`. + """ located = self._sent.get(message_ref) if located is not None: return located @@ -928,9 +1044,22 @@ def _locate(self, channel_id: str, message_ref: str) -> tuple[str, str]: async def update_message( self, channel_id: str, message_ref: str, new_content: str ) -> None: + """Rewrite a relayed message as plain text. + + Degraded, and said out loud because a reader cannot see it happen: an + agent's message was posted as an Adaptive Card carrying its name and + avatar, and this replaces the whole activity with unlabelled text, so + an edited message stops looking like the agent that sent it. The + sender's name is what would fix it and this seam is not given one. + """ if self._connector is None: raise RuntimeError("Cannot update message: Teams adapter not started") service_url, conversation_id = self._locate(channel_id, message_ref) + logger.warning( + "Rewriting Teams message %s as plain text: an edit through this seam " + "drops the agent card it was posted in.", + message_ref, + ) await self._connector.update_activity( service_url=service_url, conversation_id=conversation_id, @@ -957,7 +1086,7 @@ async def send_typing( if not is_typing or self._connector is None: return try: - await self._connector.send_to_conversation( + await self._connector.send_signal( service_url=self._service_url_for(channel_id), conversation_id=channel_id, activity={"type": "typing"}, @@ -965,6 +1094,308 @@ async def send_typing( except Exception: logger.warning("Failed to send typing indicator to %s", channel_id) + # ── SDK session publication ────────────────────────────────────────────── + + def rich_fallback_limit(self) -> int: + return _PUBLICATION_LIMIT + + def rich_markup(self) -> Markup: + return _TEAMS_MARKUP + + def rich_fallback_text(self, content: RichContent) -> str: + """What a publication says, with nothing that had to be looked up. + + The renderers are the ones `post_rich` uses; what is missing is the + mention and the responder's name, both of which come from an AAD id + that has to be turned back into a handle. This is the string carried on + a `RichContentFailed`, where a name this bridge could not resolve would + add nothing to a post that did not happen. + """ + return self._draw(content, mention=None, responder=None) + + def _draw( + self, content: RichContent, *, mention: str | None, responder: str | None + ) -> str: + """The body of a publication, as a card TextBlock will render it. + + Hard-wrapped last, after the budget has been cut, because the doubled + newlines are display syntax rather than anything a reader spends their + attention on β€” and because what Teams will actually accept is measured + on the finished activity, not here. + """ + escape = self._rich_escape + limit = self.rich_fallback_limit() + markup = self.rich_markup() + if isinstance(content, TurnActivity): + # Charged to the same budget as the status it follows: a body that + # just fits, plus a line saying it reached nobody, is a body over + # the budget. + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + drawn = ( + turn_status( + content.items, + content.turn, + escape=escape, + limit=max(1, limit - len(tail)), + markup=markup, + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + mention=mention, + error_summary=content.error_summary, + ) + + tail + ) + else: + # The mention goes on a line of its own rather than in front of the + # heading: the card is a block, and a name wedged before "Permission + # needed" reads as part of it. + lead = f"{mention}\n" if mention else "" + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + body = request_summary( + content.request, + content.reference, + escape=escape, + limit=max(1, limit - len(lead) - len(tail)), + markup=markup, + responder=responder, + unavailable_reason=content.unavailable_reason, + ) + drawn = f"{lead}{body}{tail}" + return _hard_wrap(drawn) + + def _mention(self, external_id: str | None) -> str | None: + """`` markup naming whoever holds this AAD id, or None. + + Both halves of a Teams mention or neither: the markup only reaches + anybody when `_mention_entities` can pair it with a target, so a + person this bridge holds no name for is written as no mention at all + rather than as a highlight that goes nowhere. The caller's + `notify_unreachable` is what tells the reader that happened. + """ + if external_id is None: + return None + name = self._sender_handles.get(external_id) or next( + ( + handle + for handle, target in self._mention_targets.items() + if target == external_id + ), + None, + ) + if name is None or name.casefold() not in self._mention_targets: + return None + return f"{html.escape(name)}" + + def _render_rich(self, content: RichContent) -> str: + return self._draw( + content, + mention=self._mention(content.notify_external_id), + responder=self._mention(content.responder_external_id) + if isinstance(content, RequestCard) + else None, + ) + + def _publication_conversation(self, channel_id: str, root_id: str | None) -> str: + """Where a publication lives, from durable facts only. + + A Teams edit or delete is addressed to a *conversation*, and in a + channel the conversation is named by the post the message sits in + rather than by the message. So the answer needs the thread β€” which the + caller has written down, in the journal's anchor or in the card's row, + and passes back on every redraw. + + Deliberately neither `_sent`, which a restart empties and which would + then have `_locate` reconstruct an address that does not exist, nor + `_last_post`, which is the relay's guess at where an untied reply + belongs and crosses conversations whenever two run in one channel. + + A chat is its own conversation and has no root. So does a channel with + no post named β€” which is where a *new* post begins, and is why this is + not an error: only `post_rich` ever asks with nothing to name, and it + asks in order to start one. + """ + if not self._is_channel(channel_id) or root_id is None: + return channel_id + return self._thread_conversation(channel_id, root_id) + + @staticmethod + def _throttled(error: BotConnectorThrottled, text: str) -> RichContentThrottled: + return RichContentThrottled( + retry_after=_THROTTLE_BACKOFF + if error.retry_after is None + else error.retry_after, + text=text, + ) + + async def post_rich( + self, + channel_id: str, + agent_name: str, + content: RichContent, + thread_root_id: str | None = None, + ) -> str: + """Post a turn's status or a request's card as an agent-labelled card. + + What it raises is the whole point. `RichContentFailed` is the caller's + licence to throw its reservation away, so it is kept for the answers + Teams actually gave β€” a refusal, a 4xx, a payload this would not even + attempt. A timeout, a 5xx, or an activity Teams accepted without + returning an id all mean the publication may be sitting in the channel + already, and those propagate as themselves so the reservation survives + and nobody posts a second copy. + + `thread_root_id` is used as given. `_post_to_answer_in`, which the + relay uses to steer an untied reply into whatever post the channel last + spoke in, is not consulted: a publication has a durable address to keep + and a guess is not one. + """ + text = self._render_rich(content) + if self._connector is None: + raise RichContentFailed( + "Teams is not connected, so the publication was not sent.", text=text + ) + service_url = self._service_url_for(channel_id) + activity = await self._message_activity(agent_name, text) + try: + if self._is_channel(channel_id) and thread_root_id is None: + conversation_id, ref = await self._connector.create_channel_thread( + service_url=service_url, channel_id=channel_id, activity=activity + ) + else: + conversation_id = self._publication_conversation( + channel_id, thread_root_id + ) + ref = await self._connector.send_to_conversation( + service_url=service_url, + conversation_id=conversation_id, + activity=activity, + ) + except BotConnectorThrottled as error: + raise self._throttled(error, text) from error + except BotConnectorRefused as error: + raise RichContentFailed( + f"Teams would not post in channel {channel_id}: {error}", text=text + ) from error + self._sent[ref] = (service_url, conversation_id) + return ref + + async def update_rich( + self, + channel_id: str, + agent_name: str, + message_ref: str, + content: RichContent, + thread_root_id: str | None, + ) -> None: + """Redraw a publication in place β€” or retire it, once its turn is over. + + Not `update_message`, for two reasons. That one replaces the whole + activity with plain text, which would strip the agent's card off a + status halfway through the turn; and it addresses the message through + `_sent`, which a restart empties. Here the card is rebuilt, and the + address comes from the thread the caller has kept. + + `agent_name` is what the redraw writes back into the header. One bot + posts for every agent here, so the name is part of what was drawn, and + an edit that did not know it would republish the turn as somebody else. + """ + text = self._render_rich(replace(content, notify_external_id=None)) + connector = self._connector + if connector is None: + raise RichContentFailed( + "Teams is not connected, so the publication could not be redrawn.", + text=text, + ) + if _retires(content): + await self._retire_rich( + connector, channel_id, agent_name, message_ref, thread_root_id, text + ) + return + await self._edit_rich( + connector, channel_id, agent_name, message_ref, thread_root_id, text + ) + + async def _retire_rich( + self, + connector: BotConnectorClient, + channel_id: str, + agent_name: str, + message_ref: str, + thread_root_id: str | None, + text: str, + ) -> None: + """Take a finished status out of the conversation, where that is clean. + + In a chat, a group chat, or a chat-layout channel, a bot's own message + goes without trace and a finished status is clutter: it says work is + happening about work that has stopped. A posts channel is the opposite + β€” Teams replaces a deleted message with *"This message has been + deleted."* and keeps it in the post β€” so there the status is left + showing what the turn came to, which is worth more than the line it + replaces. + + A deletion Teams refuses is not quietly treated as one that happened. + The message is still there, so it is written to its final state + instead and the refusal is logged. An outcome nobody knows is raised: + the publisher holds the anchor and can come back to it, and a status + recorded as cleaned up when it was not is one that never goes. + """ + if await self._leaves_a_tombstone(channel_id): + await self._edit_rich( + connector, channel_id, agent_name, message_ref, thread_root_id, text + ) + return + try: + await connector.delete_activity( + service_url=self._service_url_for(channel_id), + conversation_id=self._publication_conversation( + channel_id, thread_root_id or message_ref + ), + activity_id=message_ref, + ) + except BotConnectorThrottled as error: + raise self._throttled(error, text) from error + except BotConnectorRefused as error: + logger.warning( + "Teams would not remove the finished status %s in channel %s " + "(%s); leaving its final state there instead.", + message_ref, + channel_id, + error, + ) + await self._edit_rich( + connector, channel_id, agent_name, message_ref, thread_root_id, text + ) + return + self._sent.pop(message_ref, None) + + async def _edit_rich( + self, + connector: BotConnectorClient, + channel_id: str, + agent_name: str, + message_ref: str, + thread_root_id: str | None, + text: str, + ) -> None: + try: + await connector.update_activity( + service_url=self._service_url_for(channel_id), + conversation_id=self._publication_conversation( + channel_id, thread_root_id or message_ref + ), + activity_id=message_ref, + activity=await self._message_activity(agent_name, text), + ) + except BotConnectorThrottled as error: + raise self._throttled(error, text) from error + except BotConnectorRefused as error: + raise RichContentFailed( + f"Teams refused the edit to {message_ref} in channel " + f"{channel_id}: {error}", + text=text, + ) from error + # ── Runtime state ────────────────────────────────────────────────────────── async def _apply_runtime_state( @@ -982,6 +1413,12 @@ async def _apply_runtime_state( ) -> None: """Persistent status messages, mirroring Slack. + Superseded: `renders_legacy_runtime_state` is False, so nothing calls + this. Kept until the legacy indicator is removed everywhere, because + deleting one platform's copy ahead of the others makes the comparison + between them impossible to read. The layout rule below is what + `_retire_rich` now applies to an SDK status. + A "working on it…" card is posted (as the agent) while the agent works and edited in place as the activity detail changes; it stays up through ``awaiting-input`` β€” where a "needs your input" ping is added β€” and both diff --git a/core/switch_core/bridges/collaboration/teams/connector.py b/core/switch_core/bridges/collaboration/teams/connector.py index 1ccfdac91..63b67092a 100644 --- a/core/switch_core/bridges/collaboration/teams/connector.py +++ b/core/switch_core/bridges/collaboration/teams/connector.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging from typing import Any @@ -9,9 +10,116 @@ logger = logging.getLogger(__name__) +ACTIVITY_SIZE_LIMIT = 64 * 1024 +"""Refuse an activity larger than this, measured as UTF-16 bytes. + +Microsoft asks that a bot message stay within 80 KB and describes its own +ceiling of 100 KB as approximate, counted in UTF-16. Two readings of "80 KB" +are possible β€” bytes, or code units β€” so this sits below the smaller of them +with room to spare, and the count includes everything on the wire: the card, +the mention entities, and the body text repeated in `summary` and +`fallbackText`. Going over earns a 413 the sender cannot do anything useful +with; refusing here names the size instead. +""" + class BotConnectorError(RuntimeError): - """A Bot Connector REST call returned a non-success status.""" + """A Bot Connector call did not do what was asked. + + The subclass is the part that matters. What a caller may conclude about + the platform's state differs completely between "it said no" and "it never + answered", and a single flattened error makes those indistinguishable β€” + which is how a publication that may well have happened gets retried, or a + reservation that nothing was written for gets discarded. + """ + + def __init__( + self, message: str, *, status: int | None, retry_after: float | None + ) -> None: + super().__init__(message) + self.status = status + self.retry_after = retry_after + + +class BotConnectorRefused(BotConnectorError): + """Teams answered and declined. Nothing was written.""" + + +class BotConnectorGone(BotConnectorRefused): + """The conversation or activity addressed does not exist.""" + + +class BotConnectorThrottled(BotConnectorRefused): + """Rate limited. `retry_after` is Teams' own answer where it gave one.""" + + +class BotConnectorConflict(BotConnectorRefused): + """The activity changed under the edit, which therefore did not apply.""" + + +class BotConnectorUnavailable(BotConnectorError): + """Teams did not answer, or answered that it could not say. + + The outcome is unknown: the message may exist. A caller holding a + reservation for it must keep it rather than treat this as a refusal. + """ + + +class BotConnectorUnaddressable(BotConnectorError): + """Teams accepted the activity but returned no id for it. + + The message is presumed to exist and cannot be edited or deleted, so this + is not a refusal either β€” the same uncertain outcome reached by a different + route. + """ + + +def _retry_after(resp: httpx.Response) -> float | None: + raw = resp.headers.get("Retry-After") + if raw is None: + return None + try: + return max(0.0, float(raw)) + except ValueError: + logger.warning( + "Teams sent a Retry-After this cannot read (%r); backing off on our " + "own schedule instead", + raw, + ) + return None + + +def _failure(operation: str, resp: httpx.Response) -> BotConnectorError: + status = resp.status_code + detail = f"{operation} failed ({status}): {resp.text}" + if status == 429: + return BotConnectorThrottled( + detail, status=status, retry_after=_retry_after(resp) + ) + if status == 412: + return BotConnectorConflict(detail, status=status, retry_after=None) + if status == 404: + return BotConnectorGone(detail, status=status, retry_after=None) + if status == 408: + # A timeout reported as a status is still a timeout: Teams may have + # done the work and given up on saying so. + return BotConnectorUnavailable(detail, status=status, retry_after=None) + if 400 <= status < 500: + return BotConnectorRefused(detail, status=status, retry_after=None) + return BotConnectorUnavailable(detail, status=status, retry_after=None) + + +def _payload(operation: str, body: dict[str, Any]) -> bytes: + text = json.dumps(body, ensure_ascii=False) + measured = len(text.encode("utf-16-le")) + if measured > ACTIVITY_SIZE_LIMIT: + raise BotConnectorRefused( + f"{operation} was not attempted: the activity is {measured} bytes of " + f"UTF-16 and Teams accepts up to about {ACTIVITY_SIZE_LIMIT}.", + status=None, + retry_after=None, + ) + return text.encode() class BotConnectorClient: @@ -21,6 +129,10 @@ class BotConnectorClient: activities (regional, e.g. ``https://smba.trafficmanager.net/amer/``). Every call is authorised with an app-only Bot Connector token from the shared token provider. + + Every failure leaves here as a `BotConnectorError` saying which kind it was, + including a transport failure: an `httpx` exception escaping raw would reach + callers that have no way to tell it from a refusal. """ def __init__(self, *, tokens: TeamsTokenProvider, http: httpx.AsyncClient) -> None: @@ -35,6 +147,56 @@ async def _headers(self) -> dict[str, str]: def _base(service_url: str) -> str: return service_url if service_url.endswith("/") else service_url + "/" + async def _call( + self, + *, + operation: str, + method: str, + url: str, + body: dict[str, Any] | None, + ) -> httpx.Response: + headers = await self._headers() + content: bytes | None = None + if body is not None: + # Serialised once, so the bytes the guard measured are the bytes + # that go out. + content = _payload(operation, body) + headers["Content-Type"] = "application/json" + try: + resp = await self._http.request( + method, url, content=content, headers=headers + ) + except httpx.HTTPError as error: + raise BotConnectorUnavailable( + f"{operation} did not complete: {error}", + status=None, + retry_after=None, + ) from error + if resp.status_code >= 300: + raise _failure(operation, resp) + return resp + + @staticmethod + def _identifier(operation: str, resp: httpx.Response, field: str) -> str: + try: + data = resp.json() + except ValueError as error: + raise BotConnectorUnaddressable( + f"{operation} was accepted ({resp.status_code}) but the response " + f"was not JSON, so the message it wrote cannot be addressed again.", + status=resp.status_code, + retry_after=None, + ) from error + value = data.get(field) if isinstance(data, dict) else None + if not value: + raise BotConnectorUnaddressable( + f"{operation} was accepted ({resp.status_code}) but returned no " + f"{field}, so the message it wrote cannot be edited or deleted.", + status=resp.status_code, + retry_after=None, + ) + return str(value) + async def create_channel_thread( self, *, service_url: str, channel_id: str, activity: dict[str, Any] ) -> tuple[str, str]: @@ -43,35 +205,55 @@ async def create_channel_thread( Returns ``(conversation_id, activity_id)`` β€” the new thread's conversation id and the posted message's id (its thread root). """ - url = f"{self._base(service_url)}v3/conversations" - body = { - "isGroup": True, - "channelData": {"channel": {"id": channel_id}}, - "activity": activity, - } - resp = await self._http.post(url, json=body, headers=await self._headers()) - if resp.status_code >= 300: - raise BotConnectorError( - f"create conversation in {channel_id} failed " - f"({resp.status_code}): {resp.text}" - ) - data = resp.json() - conversation_id = str(data.get("id") or channel_id) - activity_id = str(data.get("activityId") or data.get("id") or "") - return conversation_id, activity_id + operation = f"Starting a thread in Teams channel {channel_id}" + resp = await self._call( + operation=operation, + method="POST", + url=f"{self._base(service_url)}v3/conversations", + body={ + "isGroup": True, + "channelData": {"channel": {"id": channel_id}}, + "activity": activity, + }, + ) + return ( + self._identifier(operation, resp, "id"), + self._identifier(operation, resp, "activityId"), + ) async def send_to_conversation( self, *, service_url: str, conversation_id: str, activity: dict[str, Any] ) -> str: """Post ``activity`` to an existing conversation; return its message id.""" - url = f"{self._base(service_url)}v3/conversations/{conversation_id}/activities" - resp = await self._http.post(url, json=activity, headers=await self._headers()) - if resp.status_code >= 300: - raise BotConnectorError( - f"send to conversation {conversation_id} failed " - f"({resp.status_code}): {resp.text}" - ) - return str(resp.json().get("id", "")) + operation = f"Sending to Teams conversation {conversation_id}" + resp = await self._call( + operation=operation, + method="POST", + url=( + f"{self._base(service_url)}v3/conversations/" + f"{conversation_id}/activities" + ), + body=activity, + ) + return self._identifier(operation, resp, "id") + + async def send_signal( + self, *, service_url: str, conversation_id: str, activity: dict[str, Any] + ) -> None: + """Post an activity nothing will ever address again, such as typing. + + Separate from `send_to_conversation` because that one treats a missing + id as a fault, and an ephemeral activity is not required to have one. + """ + await self._call( + operation=f"Signalling Teams conversation {conversation_id}", + method="POST", + url=( + f"{self._base(service_url)}v3/conversations/" + f"{conversation_id}/activities" + ), + body=activity, + ) async def update_activity( self, @@ -81,27 +263,25 @@ async def update_activity( activity_id: str, activity: dict[str, Any], ) -> None: - url = ( - f"{self._base(service_url)}v3/conversations/" - f"{conversation_id}/activities/{activity_id}" + await self._call( + operation=f"Updating Teams activity {activity_id}", + method="PUT", + url=( + f"{self._base(service_url)}v3/conversations/" + f"{conversation_id}/activities/{activity_id}" + ), + body=activity, ) - resp = await self._http.put(url, json=activity, headers=await self._headers()) - if resp.status_code >= 300: - raise BotConnectorError( - f"update activity {activity_id} failed " - f"({resp.status_code}): {resp.text}" - ) async def delete_activity( self, *, service_url: str, conversation_id: str, activity_id: str ) -> None: - url = ( - f"{self._base(service_url)}v3/conversations/" - f"{conversation_id}/activities/{activity_id}" + await self._call( + operation=f"Deleting Teams activity {activity_id}", + method="DELETE", + url=( + f"{self._base(service_url)}v3/conversations/" + f"{conversation_id}/activities/{activity_id}" + ), + body=None, ) - resp = await self._http.delete(url, headers=await self._headers()) - if resp.status_code >= 300: - raise BotConnectorError( - f"delete activity {activity_id} failed " - f"({resp.status_code}): {resp.text}" - ) diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 9fa3a6588..f3f1123ba 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -1611,6 +1611,7 @@ async def update_rich( agent_name: str, message_ref: str, content: RichContent, + thread_root_id: str | None, ) -> None: """Redraw a publication in place β€” or take it down, once the turn it was reporting is over. diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index 667be6a22..f308174d9 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -595,7 +595,7 @@ async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> N with pytest.raises(RichContentFailed): await adapter.update_rich( - str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", await _card() + str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", await _card(), None ) @@ -606,7 +606,7 @@ async def test_a_running_turn_is_redrawn_in_place_inside_its_thread() -> None: adapter, _channel, _thread, webhook = _guild_setup() await adapter.update_rich( - str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", _activity() + str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", _activity(), None ) assert webhook.deletes == [] @@ -618,7 +618,7 @@ async def test_a_thread_keeps_the_finished_turn_as_its_record() -> None: adapter, _channel, _thread, webhook = _guild_setup() await adapter.update_rich( - str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", _ended() + str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", _ended(), None ) assert webhook.deletes == [] @@ -629,7 +629,7 @@ async def test_a_flat_channel_loses_the_status_when_the_turn_ends() -> None: adapter, _channel, _thread, webhook = _guild_setup() await adapter.update_rich( - str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended() + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended(), None ) assert webhook.edits == [] @@ -642,7 +642,7 @@ async def test_a_dm_loses_it_too_and_never_asks_for_a_webhook() -> None: dm.messages[501] = _Message(dm, 501) await adapter.update_rich( - str(DM_CHANNEL_ID), "my-agent", f"{DM_CHANNEL_ID}:501", _ended() + str(DM_CHANNEL_ID), "my-agent", f"{DM_CHANNEL_ID}:501", _ended(), None ) assert dm.deleted_ids == [501] @@ -653,7 +653,7 @@ async def test_a_dm_redraw_writes_the_agent_name_back_into_the_body() -> None: adapter = _adapter({DM_CHANNEL_ID: dm}) ref = await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", _activity(), None) - await adapter.update_rich(str(DM_CHANNEL_ID), "my-agent", ref, _activity()) + await adapter.update_rich(str(DM_CHANNEL_ID), "my-agent", ref, _activity(), None) assert dm.messages[501].edited is not None assert dm.messages[501].edited.startswith("**my-agent**: ") @@ -669,7 +669,7 @@ async def test_a_dm_redraw_still_names_the_agent_after_a_restart() -> None: ref = await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", _activity(), None) restarted = _adapter({DM_CHANNEL_ID: dm}) - await restarted.update_rich(str(DM_CHANNEL_ID), "my-agent", ref, _activity()) + await restarted.update_rich(str(DM_CHANNEL_ID), "my-agent", ref, _activity(), None) assert dm.messages[501].edited is not None assert dm.messages[501].edited.startswith("**my-agent**: ") @@ -680,7 +680,9 @@ async def test_a_settled_card_is_never_taken_down() -> None: adapter, _channel, _thread, webhook = _guild_setup() card = await _card() - await adapter.update_rich(str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", card) + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", card, None + ) assert webhook.deletes == [] assert webhook.edits[0]["message_id"] == 901 @@ -694,7 +696,7 @@ async def test_a_status_that_cannot_be_removed_is_left_saying_what_happened( with caplog.at_level(logging.WARNING): await adapter.update_rich( - str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended() + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended(), None ) assert "leaving its final state" in caplog.text @@ -705,10 +707,10 @@ async def test_redrawing_a_retired_status_is_not_reported_as_a_lost_message() -> adapter, _channel, _thread, webhook = _guild_setup() await adapter.update_rich( - str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended() + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended(), None ) await adapter.update_rich( - str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended() + str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended(), None ) assert len(webhook.deletes) == 1 diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index 0d24b4584..dea568926 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -373,7 +373,7 @@ async def test_a_redraw_is_patched_by_the_bot_that_posted_it() -> None: adapter = _adapter("worker", "other") ref = await adapter.post_rich("chan-1", "worker", _activity()) - await adapter.update_rich("chan-1", "worker", ref, _activity()) + await adapter.update_rich("chan-1", "worker", ref, _activity(), None) assert _posts(adapter).patched[0][0] == ref assert _posts(adapter).patched_by == ["worker"] @@ -386,7 +386,7 @@ async def test_a_redraw_is_still_the_agents_own_bot_after_a_restart() -> None: ref = await adapter.post_rich("chan-1", "worker", _activity()) restarted = _adapter("worker", "other") - await restarted.update_rich("chan-1", "worker", ref, _activity()) + await restarted.update_rich("chan-1", "worker", ref, _activity(), None) assert _posts(restarted).patched_by == ["worker"] @@ -399,7 +399,7 @@ async def test_a_failed_redraw_raises_rather_than_leaving_a_stale_card() -> None _posts(adapter).patch_error = ResourceNotFound("404 post not found") with pytest.raises(RichContentFailed) as excinfo: - await adapter.update_rich("chan-1", "worker", ref, await _card()) + await adapter.update_rich("chan-1", "worker", ref, await _card(), None) assert isinstance(excinfo.value.__cause__, ResourceNotFound) assert excinfo.value.text @@ -414,7 +414,7 @@ async def test_a_redraw_that_may_have_landed_is_not_reported_as_refused() -> Non _posts(adapter).patch_error = _http_error(503) with pytest.raises(requests.HTTPError): - await adapter.update_rich("chan-1", "worker", ref, await _card()) + await adapter.update_rich("chan-1", "worker", ref, await _card(), None) async def test_a_rate_limited_redraw_carries_the_wait_back_to_the_caller() -> None: @@ -423,7 +423,7 @@ async def test_a_rate_limited_redraw_carries_the_wait_back_to_the_caller() -> No _posts(adapter).patch_error = _http_error(429, **{"Retry-After": "8"}) with pytest.raises(RichContentThrottled) as excinfo: - await adapter.update_rich("chan-1", "worker", ref, await _card()) + await adapter.update_rich("chan-1", "worker", ref, await _card(), None) assert excinfo.value.retry_after == 8 @@ -436,7 +436,7 @@ async def test_a_redraw_does_not_mention_the_recipient_a_second_time() -> None: ref = await adapter.post_rich("chan-1", "worker", card) assert "@owner" in _posts(adapter).created[0]["message"] - await adapter.update_rich("chan-1", "worker", ref, card) + await adapter.update_rich("chan-1", "worker", ref, card, None) assert "@owner" not in _posts(adapter).patched[0][1]["message"] diff --git a/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py b/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py index 9c8e0df79..d8668b75b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py +++ b/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py @@ -162,7 +162,7 @@ async def test_update_rich_falls_back_the_same_way() -> None: items = [_item(kind="assistant-message", title="", text="Looking now.")] turn = _turn("completed") - await adapter.update_rich("C1", "agent", "C1:1.0", TurnActivity(items, turn)) + await adapter.update_rich("C1", "agent", "C1:1.0", TurnActivity(items, turn), None) assert len(adapter.updated) == 1 channel_id, message_ref, content = adapter.updated[0] @@ -183,7 +183,7 @@ async def test_update_rich_does_not_raise_when_the_platform_only_swallows() -> N items = [_item(kind="assistant-message", title="", text="Looking now.")] await adapter.update_rich( - "C1", "agent", "C1:1.0", TurnActivity(items, _turn("completed")) + "C1", "agent", "C1:1.0", TurnActivity(items, _turn("completed")), None ) @@ -201,7 +201,7 @@ def _explode(_content: str) -> None: with pytest.raises(RichContentFailed) as excinfo: await adapter.update_rich( - "C1", "agent", "C1:1.0", TurnActivity(items, _turn("completed")) + "C1", "agent", "C1:1.0", TurnActivity(items, _turn("completed")), None ) assert isinstance(excinfo.value.__cause__, RuntimeError) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py index 6834d929e..21dac8cb2 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py @@ -121,7 +121,7 @@ async def test_attention_post_mentions_once_and_edit_removes_mention(): assert client.posted[0]["text"].startswith("<@UOWNER>") assert client.posted[0]["thread_ts"] == "root" assert not client.posted[0].get("reply_broadcast") - await adapter.update_rich("C1", "Agent", ref, content) + await adapter.update_rich("C1", "Agent", ref, content, None) assert "<@UOWNER>" not in client.updated[0]["text"] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py index a67c51a91..b4f26db4b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py @@ -385,6 +385,7 @@ async def test_resolved_plan_uses_display_name_and_keeps_slack_mention_in_detail "agent", "C1:111.0", RequestCard(request, REFERENCE, responder_external_id="UOWNER123"), + None, ) plan = client.updated[0]["blocks"][0] assert "Example Owner" in plan["title"] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py b/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py index d5b9c1938..8fff5c1f3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py @@ -136,14 +136,14 @@ async def test_slack_update_cooldown_honors_retry_after_across_messages(monkeypa monkeypatch.setattr(adapter, "update_blocks", update) content = TurnActivity([], _turn(), status_only=True) with pytest.raises(RichContentThrottled) as first: - await adapter.update_rich("C1", "Agent", "C1:1", content) + await adapter.update_rich("C1", "Agent", "C1:1", content, None) assert first.value.retry_after == 17 clock[0] += 16 with pytest.raises(RichContentThrottled): - await adapter.update_rich("C1", "Agent", "C1:2", content) + await adapter.update_rich("C1", "Agent", "C1:2", content, None) assert update.await_count == 1 clock[0] += 1 - await adapter.update_rich("C1", "Agent", "C1:2", content) + await adapter.update_rich("C1", "Agent", "C1:2", content, None) assert update.await_count == 2 diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py b/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py index 48f3118f3..07979b889 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py @@ -785,6 +785,18 @@ class _RecordThenRaiseConnector(_FakeConnector): async def send_to_conversation( self, *, service_url: str, conversation_id: str, activity: dict[str, Any] ) -> str: + self._record(service_url, conversation_id, activity) + raise RuntimeError("boom") + + async def send_signal( + self, *, service_url: str, conversation_id: str, activity: dict[str, Any] + ) -> None: + self._record(service_url, conversation_id, activity) + raise RuntimeError("boom") + + def _record( + self, service_url: str, conversation_id: str, activity: dict[str, Any] + ) -> None: self.sends.append( { "service_url": service_url, @@ -792,7 +804,6 @@ async def send_to_conversation( "activity": activity, } ) - raise RuntimeError("boom") def test_typing_failure_is_swallowed() -> None: @@ -940,13 +951,19 @@ def _wire_counting(adapter: TeamsAdapter, connector: _CountingConnector) -> None adapter._channel_type["19:abc@thread.tacv2"] = "channel_public" +# The runtime-state tests below drive `_apply_runtime_state` directly: Teams +# publishes SDK sessions now and declares `renders_legacy_runtime_state = +# False`, so the public entry point returns before the adapter is reached. +# The renderer stays until the legacy indicator goes everywhere. + + def test_working_posts_status_card() -> None: adapter = _adapter() fake = _CountingConnector() _wire_counting(adapter, fake) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "19:abc@thread.tacv2", "worker", "working", @@ -965,7 +982,7 @@ def test_working_detail_refreshes_in_place() -> None: _wire_counting(adapter, fake) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "19:abc@thread.tacv2", "worker", "working", @@ -974,7 +991,7 @@ def test_working_detail_refreshes_in_place() -> None: ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "19:abc@thread.tacv2", "worker", "working", @@ -997,7 +1014,7 @@ def test_idle_retires_the_working_message_by_editing_it() -> None: _wire_counting(adapter, fake) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "19:abc@thread.tacv2", "worker", "working", @@ -1006,7 +1023,7 @@ def test_idle_retires_the_working_message_by_editing_it() -> None: ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "19:abc@thread.tacv2", "worker", "idle", @@ -1031,7 +1048,7 @@ def test_awaiting_input_keeps_working_and_pings() -> None: key = ("19:abc@thread.tacv2", "worker") _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "19:abc@thread.tacv2", "worker", "working", @@ -1040,7 +1057,7 @@ def test_awaiting_input_keeps_working_and_pings() -> None: ) ) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "19:abc@thread.tacv2", "worker", "awaiting-input", @@ -1054,7 +1071,7 @@ def test_awaiting_input_keeps_working_and_pings() -> None: assert adapter._input_pings[key] == ["M2"] _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( "19:abc@thread.tacv2", "worker", "idle", diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_channel_layout.py b/core/tests/switch_core/bridges/collaboration/test_teams_channel_layout.py index 20684dd22..f96416423 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_channel_layout.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_channel_layout.py @@ -143,12 +143,14 @@ def test_a_chat_channel_puts_the_room_linked_notice_at_the_root() -> None: assert connector.replies == [] +# `_apply_runtime_state` directly, for the reason given in +# `test_teams_runtime_state_layout.py`: the legacy path has no caller left. def test_a_chat_channel_keeps_the_runtime_status_where_the_message_was() -> None: adapter, connector = _adapter(_Graph("chat")) # Triggered by a message at the root β†’ the status belongs at the root. _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( _CHANNEL, "james", "working", mention_handle=None, thread_root_id=None ) ) @@ -157,7 +159,7 @@ def test_a_chat_channel_keeps_the_runtime_status_where_the_message_was() -> None # Triggered from inside a thread β†’ the status belongs in that thread. _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( _CHANNEL, "rita", "working", mention_handle=None, thread_root_id="msg-4" ) ) diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_clients.py b/core/tests/switch_core/bridges/collaboration/test_teams_clients.py index 710cf5173..741cbc67b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_clients.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_clients.py @@ -17,8 +17,15 @@ import pytest from switch_core.bridges.collaboration.teams.connector import ( + ACTIVITY_SIZE_LIMIT, BotConnectorClient, + BotConnectorConflict, BotConnectorError, + BotConnectorGone, + BotConnectorRefused, + BotConnectorThrottled, + BotConnectorUnaddressable, + BotConnectorUnavailable, ) from switch_core.bridges.collaboration.teams.graph import GraphClient, GraphError @@ -243,21 +250,26 @@ def test_create_channel_thread_builds_body_and_parses_ids() -> None: assert body["channelData"]["channel"]["id"] == "19:c@thread.tacv2" -def test_create_channel_thread_falls_back_to_id_when_no_activity_id() -> None: - # Some responses carry only ``id`` β€” it doubles as the activity id. +def test_create_channel_thread_refuses_to_invent_an_activity_id() -> None: + # A response carrying only ``id`` names the conversation, not the message + # in it. Handing that back as the activity id addressed every later edit + # to the wrong thing, and the edit that failed looked like a platform + # fault rather than an id we made up. rec = _Recorder(201, {"id": "conv-1"}) connector = _connector(rec) - conversation_id, activity_id = _run( - connector.create_channel_thread( - service_url="https://smba.example/amer/", - channel_id="19:c@thread.tacv2", - activity={"type": "message"}, + with pytest.raises(BotConnectorUnaddressable) as raised: + _run( + connector.create_channel_thread( + service_url="https://smba.example/amer/", + channel_id="19:c@thread.tacv2", + activity={"type": "message"}, + ) ) - ) - assert conversation_id == "conv-1" - assert activity_id == "conv-1" + # Not a refusal: the post is presumably in the channel, so whoever + # reserved it keeps the reservation rather than sending a second copy. + assert not isinstance(raised.value, BotConnectorRefused) def test_create_channel_thread_error_raises() -> None: @@ -431,3 +443,185 @@ def test_a_non_authorization_failure_is_not_retried() -> None: _run(_graph(recorder, _StaleTokens()).list_subscriptions()) assert len(recorder.requests) == 1 + + +# ── The Bot Connector failure contract ─────────────────────────────────────── +# +# Every method used to flatten `status >= 300` into one `BotConnectorError` +# carrying a formatted string, and let raw `httpx` exceptions past. Nothing +# above it could tell "Teams said no" from "Teams never answered", which is the +# difference between discarding a reservation and keeping it. + + +def _send(connector: BotConnectorClient) -> Any: + return connector.send_to_conversation( + service_url="https://smba.example/amer/", + conversation_id="19:c@thread.tacv2", + activity={"type": "message", "text": "hi"}, + ) + + +def test_a_429_is_throttling_and_carries_the_wait_teams_asked_for() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(429, json={}, headers={"Retry-After": "17"}) + + connector = BotConnectorClient( + tokens=_FakeTokens(), # type: ignore[arg-type] + http=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(BotConnectorThrottled) as raised: + _run(_send(connector)) + + assert raised.value.retry_after == 17 + # Throttling is a refusal: the activity was rejected, not half-written. + assert isinstance(raised.value, BotConnectorRefused) + + +def test_an_unreadable_retry_after_leaves_the_wait_unstated() -> None: + # An HTTP-date rather than seconds. Reporting a made-up number as Teams' + # own would be worse than saying nothing and backing off locally. + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, json={}, headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"} + ) + + connector = BotConnectorClient( + tokens=_FakeTokens(), # type: ignore[arg-type] + http=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(BotConnectorThrottled) as raised: + _run(_send(connector)) + + assert raised.value.retry_after is None + + +def test_a_404_says_the_target_is_gone() -> None: + with pytest.raises(BotConnectorGone): + _run(_send(_connector(_Recorder(404, {"error": "no such conversation"})))) + + +def test_a_412_says_the_activity_moved_under_the_edit() -> None: + connector = _connector(_Recorder(412, {"error": "precondition"})) + with pytest.raises(BotConnectorConflict): + _run( + connector.update_activity( + service_url="https://smba.example/amer/", + conversation_id="19:c@thread.tacv2", + activity_id="act-1", + activity={"type": "message"}, + ) + ) + + +def test_a_400_is_a_refusal_nothing_was_written_for() -> None: + with pytest.raises(BotConnectorRefused) as raised: + _run(_send(_connector(_Recorder(400, {"error": "bad request"})))) + + assert not isinstance(raised.value, BotConnectorUnavailable) + + +def test_a_500_leaves_the_outcome_unknown() -> None: + # The message may be in the channel. Calling this a refusal is what + # throws away a reservation for a publication that actually happened. + with pytest.raises(BotConnectorUnavailable) as raised: + _run(_send(_connector(_Recorder(500, {"error": "boom"})))) + + assert not isinstance(raised.value, BotConnectorRefused) + + +def test_a_408_leaves_the_outcome_unknown_despite_being_a_4xx() -> None: + with pytest.raises(BotConnectorUnavailable): + _run(_send(_connector(_Recorder(408, {"error": "timeout"})))) + + +def test_a_transport_failure_never_escapes_as_httpx() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("no route to host") + + connector = BotConnectorClient( + tokens=_FakeTokens(), # type: ignore[arg-type] + http=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(BotConnectorUnavailable) as raised: + _run(_send(connector)) + + assert isinstance(raised.value.__cause__, httpx.ConnectError) + assert raised.value.status is None + + +def test_an_accepted_send_with_no_id_is_not_reported_as_success() -> None: + with pytest.raises(BotConnectorUnaddressable): + _run(_send(_connector(_Recorder(200, {})))) + + +def test_an_ephemeral_signal_does_not_need_an_id() -> None: + # A typing indicator is never addressed again, so the missing id that + # makes a message unusable says nothing about this one. + rec = _Recorder(200, {}) + _run( + _connector(rec).send_signal( + service_url="https://smba.example/amer/", + conversation_id="19:c@thread.tacv2", + activity={"type": "typing"}, + ) + ) + assert rec.last_json() == {"type": "typing"} + + +def test_an_oversized_activity_is_refused_before_it_is_sent() -> None: + # Teams answers this with a 413 and the sender learns nothing it could not + # have worked out first. Refusing locally names the size instead, and the + # request is not made at all. + rec = _Recorder(200, {"id": "m1"}) + connector = _connector(rec) + + with pytest.raises(BotConnectorRefused, match="UTF-16"): + _run( + connector.send_to_conversation( + service_url="https://smba.example/amer/", + conversation_id="19:c@thread.tacv2", + activity={"type": "message", "text": "x" * ACTIVITY_SIZE_LIMIT}, + ) + ) + + assert rec.requests == [] + + +def test_the_guard_counts_utf16_rather_than_characters() -> None: + # An emoji is one character and four UTF-16 bytes, which is the unit + # Microsoft states the limit in. Counting characters would let a payload + # through at nearly four times the size it really is. + rec = _Recorder(200, {"id": "m1"}) + connector = _connector(rec) + text = "πŸ˜€" * (ACTIVITY_SIZE_LIMIT // 4) + + with pytest.raises(BotConnectorRefused): + _run( + connector.send_to_conversation( + service_url="https://smba.example/amer/", + conversation_id="19:c@thread.tacv2", + activity={"type": "message", "text": text}, + ) + ) + + assert rec.requests == [] + + +def test_what_the_guard_measured_is_what_goes_on_the_wire() -> None: + # Serialised once. A second `json.dumps` with different options would send + # bytes the guard never saw β€” and non-ASCII is exactly where the two + # disagree. + rec = _Recorder(200, {"id": "m1"}) + _run( + _connector(rec).send_to_conversation( + service_url="https://smba.example/amer/", + conversation_id="19:c@thread.tacv2", + activity={"type": "message", "text": "hΓ©llo πŸ˜€"}, + ) + ) + + assert rec.last_json()["text"] == "hΓ©llo πŸ˜€" + assert rec.last.headers["Content-Type"] == "application/json" diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py b/core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py index 521e6b278..22186e43d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py @@ -11,6 +11,14 @@ A **chat**-layout channel drops a deleted message cleanly, so it keeps the original behaviour: the status disappears when the turn ends. + +These drive `_apply_runtime_state` and `_reposition_runtime_state` rather than +the public entry points, because the public ones no longer reach them: Teams +now publishes SDK sessions and declares `renders_legacy_runtime_state = False`, +so the base class stops the legacy path before the adapter sees it. The +implementation is still here and still correct; what it no longer has is a +caller. Removing it is its own task β€” until then these keep it honest, and +`test_teams_sdk_only.py` covers what replaced it. """ from __future__ import annotations @@ -110,7 +118,7 @@ def _text(activity: dict[str, Any]) -> str: def _work(adapter: TeamsAdapter, **kw: Any) -> None: _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( _CHANNEL, _AGENT, "working", mention_handle=None, thread_root_id=None, **kw ) ) @@ -118,7 +126,7 @@ def _work(adapter: TeamsAdapter, **kw: Any) -> None: def _idle(adapter: TeamsAdapter) -> None: _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( _CHANNEL, _AGENT, "idle", mention_handle=None, thread_root_id=None ) ) @@ -164,7 +172,7 @@ def test_an_operator_ping_is_resolved_by_editing_too() -> None: _work(adapter) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( _CHANNEL, _AGENT, "awaiting-input", @@ -186,7 +194,7 @@ def test_the_status_does_not_move_to_follow_the_conversation() -> None: _work(adapter) posted_before = list(connector.posted) - _run(adapter.reposition_runtime_state(_CHANNEL, _AGENT, "post-9")) + _run(adapter._reposition_runtime_state(_CHANNEL, _AGENT, "post-9")) assert connector.posted == posted_before assert connector.deletes == [] @@ -210,7 +218,7 @@ def test_a_chat_channel_still_moves_the_status_to_follow_the_conversation() -> N adapter, connector = _adapter("chat") _work(adapter) - _run(adapter.reposition_runtime_state(_CHANNEL, _AGENT, "msg-9")) + _run(adapter._reposition_runtime_state(_CHANNEL, _AGENT, "msg-9")) # Reposted first, then the original removed β€” never briefly absent. assert connector.deletes == ["M1"] @@ -222,7 +230,7 @@ def test_a_chat_channel_still_removes_an_operator_ping() -> None: _work(adapter) _run( - adapter.apply_runtime_state( + adapter._apply_runtime_state( _CHANNEL, _AGENT, "awaiting-input", diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py new file mode 100644 index 000000000..e977bec42 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py @@ -0,0 +1,496 @@ +"""Teams publishes SDK sessions, and the legacy renderer no longer runs. + +What is under test here is the rich-content seam on the platform whose edits +are addressed to a *conversation* rather than to a message: the compact status +and the request card drawn inside the agent's Adaptive Card, addressed from the +thread the caller kept rather than from a map a restart empties, retired +according to what a deletion leaves behind in each of Teams' two channel +layouts, and truthful about which failures mean "nothing was written". + +The old runtime-state renderer is still in the file (removing it is its own +task) but nothing routes to it any more. The first test holds that line: the +two renderers must not both draw, or every turn appears twice. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import pytest + +from switch_core.bridges.collaboration.adapter import ( + RequestCard, + RichContentFailed, + RichContentThrottled, + TurnActivity, +) +from switch_core.bridges.collaboration.session.renderers import RequestReference +from switch_core.bridges.collaboration.session.transport import ( + FixtureEventSource, + project, +) +from switch_core.bridges.collaboration.teams.adapter import TeamsAdapter +from switch_core.bridges.collaboration.teams.connector import ( + BotConnectorGone, + BotConnectorThrottled, + BotConnectorUnavailable, +) + +from .test_session_activity import _item, _turn +from .test_teams_adapter import _adapter, _card_text, _run + +REPO_ROOT = Path(__file__).resolve().parents[5] +EXAMPLES_PATH = REPO_ROOT / "console/packages/shared/src/session-v1/examples.json" + +CHANNEL = "19:abc@thread.tacv2" +CHAT = "a:1chat" +ROOT = "post-root-1" +AGENT = "my-agent" +SERVICE_URL = "https://smba.example/amer/" +RUNNING_LINE = "Reading the adapter" +ENDED_LINE = "Turn complete." + + +class _Connector: + """Records what reached the wire, and can be told to fail on command.""" + + def __init__(self) -> None: + self.threads: list[dict[str, Any]] = [] + self.sends: list[dict[str, Any]] = [] + self.updates: list[dict[str, Any]] = [] + self.deletes: list[dict[str, Any]] = [] + self.fail_send: Exception | None = None + self.fail_update: Exception | None = None + self.fail_delete: Exception | None = None + + async def create_channel_thread( + self, *, service_url: str, channel_id: str, activity: dict[str, Any] + ) -> tuple[str, str]: + if self.fail_send is not None: + raise self.fail_send + self.threads.append({"channel_id": channel_id, "activity": activity}) + return f"{channel_id};messageid={ROOT}", ROOT + + async def send_to_conversation( + self, *, service_url: str, conversation_id: str, activity: dict[str, Any] + ) -> str: + if self.fail_send is not None: + raise self.fail_send + self.sends.append({"conversation_id": conversation_id, "activity": activity}) + return "MSG1" + + async def send_signal( + self, *, service_url: str, conversation_id: str, activity: dict[str, Any] + ) -> None: + self.sends.append({"conversation_id": conversation_id, "activity": activity}) + + async def update_activity( + self, + *, + service_url: str, + conversation_id: str, + activity_id: str, + activity: dict[str, Any], + ) -> None: + if self.fail_update is not None: + raise self.fail_update + self.updates.append( + { + "conversation_id": conversation_id, + "activity_id": activity_id, + "activity": activity, + } + ) + + async def delete_activity( + self, *, service_url: str, conversation_id: str, activity_id: str + ) -> None: + if self.fail_delete is not None: + raise self.fail_delete + self.deletes.append( + {"conversation_id": conversation_id, "activity_id": activity_id} + ) + + +def _teams( + layout: str = "post", *, chat: bool = False +) -> tuple[TeamsAdapter, _Connector]: + adapter = _adapter() + connector = _Connector() + adapter._connector = connector # type: ignore[assignment] + adapter._default_service_url = SERVICE_URL + if chat: + adapter._channel_type[CHAT] = "direct" + else: + adapter._channel_type[CHANNEL] = "channel_public" + adapter._channel_layouts[CHANNEL] = layout + return adapter, connector + + +def _activity(**kwargs: Any) -> TurnActivity: + items = [_item(status="in-progress", title=RUNNING_LINE)] + return TurnActivity(items, _turn("running"), **kwargs) + + +def _ended(**kwargs: Any) -> TurnActivity: + return TurnActivity([_item()], _turn("completed"), **kwargs) + + +async def _card(**kwargs: Any) -> RequestCard: + source = FixtureEventSource.from_examples(EXAMPLES_PATH, events=[]) + projection = await project(source, "session-demo") + request = projection.open_requests()[0] + return RequestCard(request, RequestReference(token="tok-1", handle="R7"), **kwargs) + + +# ── The legacy renderer is off ─────────────────────────────────────────────── + + +def test_the_legacy_renderer_no_longer_draws_alongside_the_sdk_one() -> None: + """Both would draw the same turn, and the post would show it twice.""" + adapter, connector = _teams() + + for state in ("working", "awaiting-input", "idle"): + _run( + adapter.apply_runtime_state( + CHANNEL, AGENT, state, mention_handle=None, thread_root_id=None + ) + ) + _run(adapter.reposition_runtime_state(CHANNEL, AGENT, ROOT)) + + assert connector.threads == [] + assert connector.sends == [] + assert connector.updates == [] + assert adapter.renders_legacy_runtime_state is False + assert adapter.publishes_sdk_sessions is True + + +def test_teams_reaches_a_reader_by_naming_them_and_in_no_other_way() -> None: + """A reply inside a post is read by whoever is already following it.""" + adapter, _ = _teams() + + assert adapter.notifies_only_by_mention is True + assert adapter.separate_attention_slot is True + assert adapter.separate_activity_log is False + assert adapter.redraws_for_elapsed_time is False + # The Bot Connector gives a bot no way to react to a message at all. + assert adapter.supports_activity_reactions is False + + +def test_an_uncertain_publication_can_never_be_found_again() -> None: + """App-only Graph, channel-scoped consent, no message listing and no marker + on a publication: a search has nowhere to look and nothing to match.""" + adapter, _ = _teams() + + assert adapter.recovers_uncertain_posts is False + assert adapter.carries_publication_marker is False + + +# ── One card, no bare text beside it ───────────────────────────────────────── + + +def test_a_status_is_drawn_inside_the_agents_card() -> None: + """Teams shows one or the other, never both, so a body split across the + activity text and an attachment loses half of itself.""" + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + activity = connector.sends[0]["activity"] + assert "text" not in activity + assert len(activity["attachments"]) == 1 + assert RUNNING_LINE in _card_text(activity) + + +def test_the_preview_text_says_what_the_card_says() -> None: + """Without `summary` Teams shows "cards.unsupported" in a toast, and + without `fallbackText` it shows it wherever the card cannot render.""" + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + activity = connector.sends[0]["activity"] + assert RUNNING_LINE in activity["summary"] + assert RUNNING_LINE in activity["attachments"][0]["content"]["fallbackText"] + + +def test_a_handle_is_not_drawn_with_backticks_teams_would_show_verbatim() -> None: + """A TextBlock renders no code span, so `R7` arrives with the backticks on + it β€” and the handle is the one thing on the card somebody has to type.""" + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _run(_card()), ROOT)) + + body = _card_text(connector.sends[0]["activity"]) + assert "R7" in body + assert "`" not in body + + +# ── Where a publication goes, and where a redraw finds it ──────────────────── + + +def test_a_publication_with_no_thread_opens_its_own_post() -> None: + adapter, connector = _teams() + + ref = _run(adapter.post_rich(CHANNEL, AGENT, _activity(), None)) + + assert ref == ROOT + assert [t["channel_id"] for t in connector.threads] == [CHANNEL] + + +def test_a_publication_ignores_the_post_the_relay_last_spoke_in() -> None: + """`_last_post` is the relay's guess at where an untied reply belongs, and + it crosses conversations whenever two run in one channel. A publication has + a durable address to keep, so it opens its own post instead.""" + adapter, connector = _teams() + adapter._last_post[CHANNEL] = "somebody-elses-post" + + _run(adapter.post_rich(CHANNEL, AGENT, _activity(), None)) + + assert connector.sends == [] + assert [t["channel_id"] for t in connector.threads] == [CHANNEL] + + +def test_a_redraw_is_addressed_by_the_thread_it_was_given() -> None: + adapter, connector = _teams() + + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT)) + + assert connector.updates[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + assert connector.updates[0]["activity_id"] == "MSG1" + + +def test_a_redraw_survives_the_restart_that_empties_the_sent_map() -> None: + """The address used to come from `_sent`, which a process holds and a + restart drops. A status posted before the restart was then addressed as its + own thread root β€” a conversation that does not exist β€” and every remaining + edit of that turn was refused.""" + adapter, connector = _teams() + assert adapter._sent == {} + + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT)) + + assert connector.updates[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + + +def test_a_publication_that_is_its_own_post_is_addressed_by_itself() -> None: + adapter, connector = _teams() + + _run(adapter.update_rich(CHANNEL, AGENT, ROOT, _activity(), None)) + + assert connector.updates[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + + +def test_a_chat_is_its_own_conversation() -> None: + adapter, connector = _teams(chat=True) + + _run(adapter.post_rich(CHAT, AGENT, _activity(), None)) + _run(adapter.update_rich(CHAT, AGENT, "MSG1", _activity(), None)) + + assert connector.sends[0]["conversation_id"] == CHAT + assert connector.updates[0]["conversation_id"] == CHAT + + +def test_a_redraw_rebuilds_the_card_rather_than_replacing_it_with_text() -> None: + """`update_message` sends a bare text activity, which would strip the + agent's name and avatar off the status halfway through the turn.""" + adapter, connector = _teams() + + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT)) + + activity = connector.updates[0]["activity"] + assert "text" not in activity + assert activity["attachments"][0]["content"]["type"] == "AdaptiveCard" + + +def test_a_redraw_does_not_repeat_the_mention_the_post_already_made() -> None: + """An edit notifies nobody, so the handle would be a name in the post that + never reaches anyone it has not already reached.""" + adapter, connector = _teams() + adapter.prime_mention_targets({"ada": "aad-ada"}) + adapter._sender_handles["aad-ada"] = "ada" + + _run( + adapter.post_rich(CHANNEL, AGENT, _activity(notify_external_id="aad-ada"), ROOT) + ) + _run( + adapter.update_rich( + CHANNEL, AGENT, "MSG1", _activity(notify_external_id="aad-ada"), ROOT + ) + ) + + assert "ada" in _card_text(connector.sends[0]["activity"]) + assert "ada" not in _card_text(connector.updates[0]["activity"]) + + +def test_a_mention_carries_the_entity_that_makes_it_reach_anybody() -> None: + """Markup with no entity is inert text, and Teams rejects neither.""" + adapter, connector = _teams() + adapter.prime_mention_targets({"ada": "aad-ada"}) + adapter._sender_handles["aad-ada"] = "ada" + + _run( + adapter.post_rich(CHANNEL, AGENT, _activity(notify_external_id="aad-ada"), ROOT) + ) + + card = connector.sends[0]["activity"]["attachments"][0]["content"] + assert card["msteams"]["entities"][0]["mentioned"]["id"] == "aad-ada" + + +def test_a_person_this_bridge_holds_no_name_for_is_not_half_mentioned() -> None: + adapter, connector = _teams() + + _run( + adapter.post_rich( + CHANNEL, AGENT, _activity(notify_external_id="aad-stranger"), ROOT + ) + ) + + activity = connector.sends[0]["activity"] + assert "" not in _card_text(activity) + assert "msteams" not in activity["attachments"][0]["content"] + + +# ── Which failures mean "nothing was written" ──────────────────────────────── + + +def test_a_refusal_is_reported_as_one_so_the_reservation_can_go() -> None: + adapter, connector = _teams() + connector.fail_send = BotConnectorGone("gone", status=404, retry_after=None) + + with pytest.raises(RichContentFailed) as raised: + _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + assert RUNNING_LINE in raised.value.text + + +def test_an_unknown_outcome_is_not_reported_as_a_refusal() -> None: + """The message may be sitting in the post already. Calling this a refusal + releases the reservation, and the next cycle posts a second copy.""" + adapter, connector = _teams() + connector.fail_send = BotConnectorUnavailable( + "timeout", status=None, retry_after=None + ) + + with pytest.raises(BotConnectorUnavailable): + _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + +def test_throttling_carries_the_wait_teams_asked_for() -> None: + adapter, connector = _teams() + connector.fail_send = BotConnectorThrottled( + "slow down", status=429, retry_after=12.0 + ) + + with pytest.raises(RichContentThrottled) as raised: + _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + assert raised.value.retry_after == 12.0 + + +def test_throttling_with_no_stated_wait_still_backs_off() -> None: + adapter, connector = _teams() + connector.fail_send = BotConnectorThrottled( + "slow down", status=429, retry_after=None + ) + + with pytest.raises(RichContentThrottled) as raised: + _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + assert raised.value.retry_after > 0 + + +def test_a_refused_edit_is_reported_rather_than_logged_and_forgotten() -> None: + """A card that failed to redraw is still offering a settled request, and + the caller has a reply to post about it β€” but only if it is told.""" + adapter, connector = _teams() + connector.fail_update = BotConnectorGone("gone", status=404, retry_after=None) + + with pytest.raises(RichContentFailed): + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT)) + + +# ── Retiring a finished status, in each of the two layouts ─────────────────── + + +def test_a_finished_status_is_edited_rather_than_deleted_in_a_posts_channel() -> None: + """Teams substitutes "This message has been deleted." and keeps it in the + post, so deleting would leave one tombstone per turn per agent.""" + adapter, connector = _teams("post") + + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + + assert connector.deletes == [] + assert ENDED_LINE in _card_text(connector.updates[0]["activity"]) + + +def test_a_finished_status_is_removed_where_a_deletion_leaves_nothing() -> None: + adapter, connector = _teams("chat") + + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + + assert connector.updates == [] + assert connector.deletes[0]["activity_id"] == "MSG1" + assert connector.deletes[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + + +def test_a_finished_status_is_removed_from_a_chat() -> None: + adapter, connector = _teams(chat=True) + + _run(adapter.update_rich(CHAT, AGENT, "MSG1", _ended(), None)) + + assert connector.deletes[0]["conversation_id"] == CHAT + + +def test_a_request_card_is_never_taken_down() -> None: + """It is the record of a decision and says on its face what became of it.""" + adapter, connector = _teams("chat") + + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _run(_card()), ROOT)) + + assert connector.deletes == [] + assert len(connector.updates) == 1 + + +def test_a_status_still_reporting_a_problem_outlives_its_turn() -> None: + adapter, connector = _teams("chat") + + _run( + adapter.update_rich( + CHANNEL, AGENT, "MSG1", _ended(error_summary="Disk full."), ROOT + ) + ) + + assert connector.deletes == [] + assert "Disk full." in _card_text(connector.updates[0]["activity"]) + + +def test_a_refused_removal_leaves_the_final_state_instead_of_pretending( + caplog: pytest.LogCaptureFixture, +) -> None: + """A deletion Teams refused is not a deletion. Left as "Working…" the post + would report a turn that ended minutes ago as still running.""" + adapter, connector = _teams("chat") + connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) + + with caplog.at_level(logging.WARNING): + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + + assert ENDED_LINE in _card_text(connector.updates[0]["activity"]) + assert "would not remove" in caplog.text + + +def test_a_removal_whose_outcome_is_unknown_is_not_recorded_as_done() -> None: + """The publisher holds the anchor and can come back to it. A status + recorded as cleaned up when it was not is one that never goes.""" + adapter, connector = _teams("chat") + connector.fail_delete = BotConnectorUnavailable( + "timeout", status=None, retry_after=None + ) + + with pytest.raises(BotConnectorUnavailable): + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + + assert connector.updates == [] diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 057f3bd96..b2a4646cf 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -176,7 +176,7 @@ async def test_a_redraw_still_names_the_agent_after_a_restart() -> None: ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) restarted = _adapter() - await restarted.update_rich(CHANNEL, "my-agent", ref, _activity()) + await restarted.update_rich(CHANNEL, "my-agent", ref, _activity(), None) assert "my-agent" in _edited(restarted)["text"] @@ -268,7 +268,7 @@ async def test_a_redraw_does_not_repeat_the_mention() -> None: ) await adapter.update_rich( - CHANNEL, "my-agent", ref, await _card(notify_external_id=ASKER) + CHANNEL, "my-agent", ref, await _card(notify_external_id=ASKER), None ) assert "tg://user" in _posted(adapter)["text"] @@ -437,7 +437,7 @@ async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> N _bot(adapter).edit_error = BadRequest("message to edit not found") with pytest.raises(RichContentFailed): - await adapter.update_rich(CHANNEL, "my-agent", ref, await _card()) + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card(), None) async def test_an_edit_telegram_calls_unchanged_is_not_a_failure() -> None: @@ -447,7 +447,7 @@ async def test_an_edit_telegram_calls_unchanged_is_not_a_failure() -> None: ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) _bot(adapter).edit_error = BadRequest("Message is not modified") - await adapter.update_rich(CHANNEL, "my-agent", ref, await _card()) + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card(), None) async def test_a_publication_is_never_retried_as_stripped_plain_text() -> None: @@ -492,7 +492,7 @@ async def test_progress_arriving_faster_than_the_chat_can_take_it_waits() -> Non ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) with pytest.raises(RichContentThrottled) as caught: - await adapter.update_rich(CHANNEL, "my-agent", ref, _activity()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _activity(), None) assert 0 < caught.value.retry_after <= _REDRAW_INTERVAL assert _bot(adapter).edits == [] @@ -503,7 +503,7 @@ async def test_the_end_of_a_turn_is_never_held_back() -> None: adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) assert len(_bot(adapter).deletes) == 1 @@ -513,7 +513,7 @@ async def test_a_problem_somebody_has_to_act_on_is_never_held_back() -> None: ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) await adapter.update_rich( - CHANNEL, "my-agent", ref, _activity(error_summary="The agent went away.") + CHANNEL, "my-agent", ref, _activity(error_summary="The agent went away."), None ) assert "went away" in _edited(adapter)["text"] @@ -525,7 +525,7 @@ async def test_a_card_is_never_held_back() -> None: adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, await _card()) + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card(), None) assert len(_bot(adapter).edits) == 1 @@ -544,10 +544,10 @@ async def test_two_agents_publishing_in_one_chat_share_its_budget() -> None: async def test_one_agents_redraw_paces_the_next_agents() -> None: adapter = _adapter() - await adapter.update_rich(CHANNEL, "one", f"{CHAT_ID}:11", _activity()) + await adapter.update_rich(CHANNEL, "one", f"{CHAT_ID}:11", _activity(), None) with pytest.raises(RichContentThrottled): - await adapter.update_rich(CHANNEL, "two", f"{CHAT_ID}:12", _activity()) + await adapter.update_rich(CHANNEL, "two", f"{CHAT_ID}:12", _activity(), None) assert len(_bot(adapter).edits) == 1 @@ -571,7 +571,7 @@ async def test_a_finished_status_is_taken_out_of_the_chat() -> None: adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) assert _bot(adapter).deletes[0]["message_id"] == int(ref.split(":")[1]) assert _bot(adapter).edits == [] @@ -584,7 +584,7 @@ async def test_a_finished_status_in_a_forum_topic_goes_the_same_way() -> None: _forum(adapter) ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), TOPIC_ID) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), TOPIC_ID) assert len(_bot(adapter).deletes) == 1 @@ -596,7 +596,7 @@ async def test_a_finished_turn_that_still_has_a_problem_to_report_stays() -> Non ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) await adapter.update_rich( - CHANNEL, "my-agent", ref, _ended(error_summary="The host went away.") + CHANNEL, "my-agent", ref, _ended(error_summary="The host went away."), None ) assert _bot(adapter).deletes == [] @@ -609,7 +609,7 @@ async def test_a_request_card_is_never_taken_down() -> None: adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, await _card()) + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card(), None) assert _bot(adapter).deletes == [] @@ -617,9 +617,9 @@ async def test_a_request_card_is_never_taken_down() -> None: async def test_a_status_taken_down_is_not_edited_afterwards() -> None: adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) assert len(_bot(adapter).deletes) == 1 assert _bot(adapter).edits == [] @@ -635,7 +635,7 @@ async def test_a_deletion_telegram_refuses_leaves_the_final_state_showing( ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) _bot(adapter).delete_error = BadRequest("message can't be deleted") - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) assert len(_bot(adapter).edits) == 1 assert any("leaving its final state" in record.message for record in caplog.records) @@ -651,9 +651,9 @@ async def test_a_deletion_whose_outcome_is_unknown_is_retried_rather_than_assume _bot(adapter).delete_error = TimedOut() with pytest.raises(TimedOut): - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended()) + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) assert len(_bot(adapter).deletes) == 1 @@ -984,7 +984,7 @@ async def test_a_settled_card_is_redrawn_without_its_buttons() -> None: settled = replace( card, request=card.request.model_copy(update={"state": "resolved"}) ) - await adapter.update_rich(CHANNEL, "my-agent", ref, settled) + await adapter.update_rich(CHANNEL, "my-agent", ref, settled, None) assert _keyboard(_posted(adapter)["reply_markup"]) != [] assert _edited(adapter)["reply_markup"] is None @@ -1071,7 +1071,7 @@ async def test_a_redraw_takes_the_buttons_off_a_card_that_stopped_fitting() -> N adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, await _clipped_detail()) + await adapter.update_rich(CHANNEL, "my-agent", ref, await _clipped_detail(), None) assert _keyboard(_posted(adapter)["reply_markup"]) != [] assert _edited(adapter)["reply_markup"] is None diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index fe6ed13e9..6ec87d598 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -46,6 +46,7 @@ def __init__(self): self.messages = {} self.post_count = 0 self.edit_refs = [] + self.edit_threads = [] self.reactions = set() self.fail_after_post = False @@ -58,9 +59,10 @@ async def post_rich(self, channel, agent, content, thread): raise TimeoutError("Response lost after Slack accepted the post") return ref - async def update_rich(self, channel, agent, ref, content): + async def update_rich(self, channel, agent, ref, content, thread): assert ref in self.messages self.edit_refs.append(ref) + self.edit_threads.append(thread) self.messages[ref] = self._render_rich(content) async def find_request_card(self, channel, thread, token, created_at, handle): @@ -210,10 +212,10 @@ async def test_final_log_edit_failure_is_retried_after_restart( await publish(activity(session_factory, platform)) original = platform.update_rich - async def fail_log(channel, agent, ref, content): + async def fail_log(channel, agent, ref, content, thread): if content.tool_log: raise TimeoutError("Final log edit failed") - return await original(channel, agent, ref, content) + return await original(channel, agent, ref, content, thread) with monkeypatch.context() as patch: patch.setattr(platform, "update_rich", fail_log) @@ -225,6 +227,25 @@ async def fail_log(channel, agent, ref, content): assert not platform.reactions +async def test_a_redraw_is_given_the_thread_the_publication_went_into( + session_factory, +): + """Not every platform can address an edit by the message alone. A Teams + edit is addressed to the *conversation*, which inside a channel post is + named by the thread β€” so the redraw has to carry it, and it has to come + from the journal's anchor rather than from a map the next restart empties. + Each `publish` here is a fresh renderer, which is that restart. + """ + await setup(session_factory) + platform = ActivitySlack() + await publish(activity(session_factory, platform)) + + await publish(activity(session_factory, platform), "completed") + + assert platform.edit_threads + assert set(platform.edit_threads) == {"channel-demo:root"} + + async def test_competing_publishers_share_one_durable_anchor(session_factory): await setup(session_factory) platform = ActivitySlack() diff --git a/core/tests/switch_core/sessions/test_publication.py b/core/tests/switch_core/sessions/test_publication.py index a060f46e4..36ee11bb0 100644 --- a/core/tests/switch_core/sessions/test_publication.py +++ b/core/tests/switch_core/sessions/test_publication.py @@ -36,6 +36,7 @@ class Platform: def __init__(self): self.posts = [] self.edits = [] + self.edit_threads = [] async def post_rich(self, channel, agent, content: RequestCard, thread): message = render_request( @@ -47,7 +48,7 @@ async def post_rich(self, channel, agent, content: RequestCard, thread): self.posts.append((channel, message.text, message.blocks, thread)) return f"{channel}:111.0" - async def update_rich(self, channel, agent, post, content: RequestCard): + async def update_rich(self, channel, agent, post, content: RequestCard, thread): message = render_request( content.request, content.reference, @@ -55,6 +56,7 @@ async def update_rich(self, channel, agent, post, content: RequestCard): unavailable_reason=content.unavailable_reason, ) self.edits.append((channel, post, message.text, message.blocks)) + self.edit_threads.append(thread) async def test_card_callback_reservation_and_confirmed_settlement(session_factory): @@ -211,6 +213,12 @@ async def test_permission_uses_activity_thread_and_persists_it(session_factory, await service.submit(reply, user_id=None, bridge_id="bridge") ).status == "accepted" + await refresh_cards(session_factory, "bridge", "session-demo", cards) + # Where an edit is addressed to the conversation rather than to the + # message, the stored thread is the address, and the row is what survives + # a restart. + assert platform.edit_threads == [expected] + class ThreadlessPlatform(Platform): """A platform pointed at a thread it can neither find nor make. diff --git a/core/tests/switch_core/sessions/test_session_presentation.py b/core/tests/switch_core/sessions/test_session_presentation.py index d07277268..cc38adf17 100644 --- a/core/tests/switch_core/sessions/test_session_presentation.py +++ b/core/tests/switch_core/sessions/test_session_presentation.py @@ -212,7 +212,7 @@ async def post_rich(self, channel, agent, content, thread): self.contents.append(content) return "channel-demo:111.0" - async def update_rich(self, channel, agent, post, content): + async def update_rich(self, channel, agent, post, content, thread): self.contents.append(content) platform = Capture() @@ -266,7 +266,7 @@ async def post_rich(self, channel, agent, content, thread): self.contents.append(content) return "channel-demo:111.0" - async def update_rich(self, channel, agent, post, content): + async def update_rich(self, channel, agent, post, content, thread): self.contents.append(content) platform = MentionOnly() diff --git a/core/tests/switch_core/sessions/test_turn_activity_publication.py b/core/tests/switch_core/sessions/test_turn_activity_publication.py index 5c085e83f..582727347 100644 --- a/core/tests/switch_core/sessions/test_turn_activity_publication.py +++ b/core/tests/switch_core/sessions/test_turn_activity_publication.py @@ -37,7 +37,7 @@ async def post_rich(self, channel, agent, content, thread): self.posts.append((channel, content, thread)) return f"{channel}:activity.1" - async def update_rich(self, channel, agent, post, content): + async def update_rich(self, channel, agent, post, content, thread): self.edits.append((channel, post, content)) async def notify_working(self, channel, agent, thread_root_id): From 76a40dc7ee9717db16bf7e1bc34645db170ba96e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 11:33:01 +0100 Subject: [PATCH 019/120] Teams: address a publication by what Teams confirmed, not by a guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of c09e9650 found five things, all of which came back to the same habit: treating something this process worked out as if the platform had said it. A publication reference now carries its own address. post_rich returns `teams1|serviceUrl|conversationId|activityId` rather than a bare activity id, because a Teams edit is addressed to a conversation, the conversation Teams opens may not be the one that was asked for, and the service URL is regional and learned from inbound traffic. An update given one of these knows where it is editing; given a bare id it rebuilds, says so, and marks the address untrusted. A 404 completes a cleanup only at a trusted address β€” at a rebuilt one it may only mean the guess was wrong, so it is raised. This also fixes a chat card redrawn after a restart being rebuilt as a channel thread, because "is this a channel?" was answered from an emptied cache; it is now read off the address. 412 was reaching callers as RichContentFailed β€” the caller's licence to discard a reservation β€” for a conflict Microsoft documents as retryable. It is a short backoff now, on post, edit and delete, and the adapter serialises its own writes per conversation behind a bounded lock map. The unconfirmed-card notice was active on Teams by inheritance. Whether a platform can search for a lost publication and whether it may say in the channel that it cannot are now two flags: the second is False on the base and True on Telegram alone, which is where it was agreed. A platform that can do neither holds the reservation and logs it once, naming the card, the channel and the request. The first post of a card is on the same widening backoff the recovery search is. A refused post leaves no row, so the next cycle reserved, posted and released all over again, forever, against a destination that is saying no. No public disclosure was invented for it; that is a decision about the channel and is not this commit's to take. A failed card refresh addresses its notice to the conversation the card is in rather than to the card, so it lands somewhere on a platform where a message id is not a conversation. Also: a card whose recipient cannot be mentioned says so, and says something different from "nobody is linked", because they are; a Connector response that carries no usable id raises instead of yielding one; the size guard names both the UTF-16 metric it measures and the UTF-8 bytes it sends; and the `_claimants` docstring no longer carries a proof review disproved. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 16 + .../bridges/collaboration/session/outbound.py | 74 ++- .../bridges/collaboration/teams/adapter.py | 511 ++++++++++++++---- .../bridges/collaboration/teams/connector.py | 30 +- .../bridges/collaboration/telegram/adapter.py | 5 + core/switch_core/sessions/publication.py | 66 ++- .../test_session_request_lifecycle.py | 32 +- .../collaboration/test_teams_sdk_only.py | 263 ++++++++- .../sessions/test_publication_retries.py | 85 ++- 9 files changed, 926 insertions(+), 156 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index d190c6971..7dcdd3417 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -378,6 +378,22 @@ class CollaborationAdapter(ABC): #: publications it could not recognise before become recoverable. carries_publication_marker: ClassVar[bool] = False + #: Whether this platform may say in the channel that a card's delivery was + #: never confirmed. + #: + #: Deliberately not implied by `recovers_uncertain_posts`. That one is a + #: fact about the adapter β€” whether a lost publication can be looked for. + #: This is a decision about what the people in the channel are told when it + #: cannot be, and it posts a message they did not ask for into a + #: conversation this bridge does not own. The two were one flag, and a + #: platform gaining the first answer silently acquired the second. + #: + #: False here so a new platform discloses nothing until somebody has agreed + #: it should. Where it is False and recovery is impossible, the reservation + #: is still kept and the request is still answerable in Console β€” what is + #: withheld is the notice, not the request. + discloses_unconfirmed_posts: 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/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index f004aaaff..cf2b3ff40 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -1124,14 +1124,16 @@ async def _claimants(self, mark: dict[str, str]) -> set[tuple[str, str]]: with no journal has only the first, and a publisher restarted into one has only the second. - Turns, not attempts, and that is enough. What a snapshot of turns could - miss is a turn renewing its claim between the read and the answer, so - that the answer clears a mark put there after it. A turn claims only - while it has not ended and releases only once it has, and its two - publications cannot overlap: the journal holds the record lock for that - one turn across the whole of this, and a publisher without a journal - publishes a turn at a time. So the claim a removal clears under a given - turn is the claim it read. + Turns, not attempts, and that is **not** currently enough. A snapshot + of turns can miss a turn renewing its claim between the read and the + answer, so that the answer clears a mark put there after it. The + serialisation that would rule that out does not: a removal's record + lock is its own turn's, and the publisher deliberately allows a + provisional outcome to be replaced by the real turn under the same + command key, so another turn can claim under a key while this removal + is in flight and have its evidence cleared by the reply. Closing that + means keying the removal snapshot by attempt rather than by turn, + which is not done here. """ claimants = set(self._expecting.get(_mark_id(mark), ())) if self._journal is None: @@ -1199,6 +1201,7 @@ def __init__( self._posts = posts self._session_factory = session_factory self._reported_edit_failures: dict[str, tuple[int, str]] = {} + self._noted_unconfirmed: set[str] = set() @property def surface(self) -> str: @@ -1235,11 +1238,24 @@ def recovers_uncertain_posts(self) -> bool: """Whether a card whose send was never acknowledged can be found again. Where it is False there is nothing to wait for: the publisher stops - searching and discloses the card as unanswerable in the channel rather - than re-asking a question the platform cannot answer. + searching rather than re-asking a question the platform cannot answer. + Whether it then *says* so in the channel is a separate question, and + `discloses_unconfirmed_posts` is the one that answers it. """ return bool(getattr(self._adapter, "recovers_uncertain_posts", False)) + @property + def discloses_unconfirmed_posts(self) -> bool: + """Whether this platform may post the unconfirmed-card notice. + + Kept apart from `recovers_uncertain_posts` so that adding a platform + that cannot search does not, by that fact alone, start writing an + unrequested message into its channels. Where this is False the + reservation is still held and the request is still answerable in + Console; what is withheld is the notice. + """ + return bool(getattr(self._adapter, "discloses_unconfirmed_posts", False)) + async def post( self, request: SnapshotRequest, @@ -1366,6 +1382,34 @@ async def recover(self, post: SessionRequestPost) -> SessionRequestPost: await session.commit() return stored + def note_unconfirmed(self, post: SessionRequestPost) -> None: + """Record an unconfirmed card that this platform may not disclose. + + The honest middle of the two things that would be worse. Posting a + notice into a conversation nobody has agreed to write into is the + first; treating an unconfirmed send as a refusal, discarding the + reservation and asking the same question a second time, is the other. + So the reservation stays, and the operator gets one record of it + rather than one per cycle for as long as the request is open. + + Not stamped on the row: `unconfirmed_notice_at` means the channel was + told, and it must stay true, so that when a disclosure policy is + agreed the notice can still be made. Memory is the right lifetime for + "this process has already said this". + """ + if post.token in self._noted_unconfirmed: + return + self._noted_unconfirmed.add(post.token) + logger.error( + "Delivery of card %s in channel %s was never confirmed, and %s can " + "neither search for it nor say so in the channel. The reservation is " + "held and request %s can still be answered in Console.", + post.handle, + post.external_channel_id, + self._surface, + post.request_id, + ) + async def disclose_unconfirmed( self, post: SessionRequestPost, *, console_url: str | None ) -> None: @@ -1607,7 +1651,15 @@ async def refresh( f"The card for request {post.handle} above could not be updated, " f"so it may still be offering buttons that no longer " f"work.\n{error.text}", - post.external_post_id, + # The conversation the card is in, which is the thread it + # was posted into where there was one. Not the card's own + # id: where a platform addresses a reply by its + # conversation rather than by the message β€” Teams β€” a card + # that is itself a reply names no conversation, and a + # notice about a card people cannot see is worse than the + # stale card. Where the card opened the conversation, it + # is the root, and that is the unchanged behaviour. + post.thread_id or post.external_post_id, ) self._reported_edit_failures[post.token] = state raise diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index efc6c16e1..fae04d1b3 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -10,10 +10,10 @@ import time from collections import OrderedDict from collections.abc import Awaitable, Callable -from dataclasses import replace +from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta from typing import Any, ClassVar -from urllib.parse import quote +from urllib.parse import quote, unquote import httpx from aiohttp import web @@ -57,6 +57,8 @@ ) from switch_core.bridges.collaboration.teams.connector import ( BotConnectorClient, + BotConnectorConflict, + BotConnectorGone, BotConnectorRefused, BotConnectorThrottled, ) @@ -100,6 +102,103 @@ Ours, not Microsoft's, and only used when the 429 carried no `Retry-After`. """ +_CONFLICT_BACKOFF = 1.0 +"""How long to wait after Teams reports the activity changed under an edit. + +Short: nothing is rate limited, something else simply wrote first, and the +next attempt renders the current state from scratch. Long enough that two +writers racing do not simply race again. +""" + +_MAX_CONVERSATION_LOCKS = 512 +"""How many conversations' write locks one adapter keeps. + +Enough for every conversation a bridge is realistically mid-turn in at once. +Only an unheld lock is ever dropped β€” see `_writes_to`. +""" + +_PUBLICATION_MARK = "teams1" +"""What marks a string as a publication reference rather than a message id. + +Versioned, because the parts are what an edit is addressed with and a later +shape has to be told from this one rather than guessed at. +""" + + +def _publication_ref(service_url: str, conversation_id: str, activity_id: str) -> str: + """The durable address of a publication, as one string. + + A Teams edit or delete needs three things and a bare message id is one of + them. The conversation is the part nothing else can supply β€” in a channel + it names the post the message sits in, and Teams may answer a create with + a conversation of its own choosing rather than the one that was asked for + β€” and the regional service URL is learned from inbound traffic, so a + process that has only heard from one region would send the next edit + somewhere else entirely. + + So the caller is given all three to write down, in the single opaque + string its column and its journal already hold. Discord's publications + carry `channel:message` the same way and for the same reason. + + Percent-encoded per part so a service URL's own separators cannot be read + as this one's. + """ + return "|".join( + [ + _PUBLICATION_MARK, + *( + quote(part, safe="") + for part in (service_url, conversation_id, activity_id) + ), + ] + ) + + +def _read_publication_ref(ref: str) -> tuple[str, str, str] | None: + """`(service_url, conversation_id, activity_id)`, or None if this is not one. + + None rather than an error: every reference written before publication + carried its address is a bare message id, and so is anything the relay + stored. The caller decides what a missing address is worth. + """ + parts = ref.split("|") + if len(parts) != 4 or parts[0] != _PUBLICATION_MARK: + return None + service_url, conversation_id, activity_id = (unquote(part) for part in parts[1:]) + if not (service_url and conversation_id and activity_id): + return None + return (service_url, conversation_id, activity_id) + + +@dataclass(frozen=True) +class _Publication: + """Where a publication is, and whether that is known or reconstructed. + + `trusted` is the part decisions hang off. A trusted address came back from + Teams and was written down, so a 404 against it means the message is gone; + a reconstructed one is this adapter's guess from a channel id and a stored + root, so the same 404 may only mean the guess was wrong, and an outcome + that cannot be told apart from a bad address is not an outcome. + """ + + service_url: str + conversation_id: str + activity_id: str + channel_id: str + trusted: bool + + @property + def in_a_channel(self) -> bool: + """Whether this publication sits in a channel post rather than a chat. + + Read off the address rather than the channel-type cache, which a + restart empties and which then answers "channel" for everything it has + not heard from. A chat is addressed as itself; a channel post is + addressed as a conversation inside the channel, so the two differ + exactly when the message is in a post. + """ + return self.conversation_id != self.channel_id + class _TeamsMarkup(Markup): """Markdown as an Adaptive Card TextBlock actually parses it. @@ -546,6 +645,8 @@ def __init__(self, *, config: TeamsConnectionConfig) -> None: self._channel_layouts: dict[str, str] = {} # message id -> (service_url, conversation_id) for later edit/delete. self._sent: dict[str, tuple[str, str]] = {} + # conversation id -> the lock ordering this bridge's writes to it. + self._conversation_writes: OrderedDict[str, asyncio.Lock] = OrderedDict() # Inbound de-duplication β€” the Bot Framework and Graph capture paths can # both deliver the same channel message, keyed on the Teams message id. self._seen: OrderedDict[str, None] = OrderedDict() @@ -822,6 +923,70 @@ def _is_channel(self, channel_id: str) -> bool: def _thread_conversation(channel_id: str, root_id: str) -> str: return f"{channel_id};messageid={root_id}" + @staticmethod + def _root_of(conversation_id: str) -> str | None: + """The post a conversation is, where it is one β€” the inverse of above. + + None for a chat, which is its own conversation, and for a conversation + Teams named itself rather than by the post it opened. + """ + _, sep, root = conversation_id.partition(";messageid=") + return root if sep and root else None + + def _reply_conversation( + self, channel_id: str, thread_root_id: str | None + ) -> str | None: + """Where a reply goes, or None to say a channel post must be opened. + + The thread root may arrive as a publication reference rather than a + bare message id β€” `admin_message`'s callers name the conversation a + card is in, and a card that opened its own post *is* that conversation. + Where it does, the address it carries is used as given: it is the one + Teams confirmed, and reconstructing it from a channel id would throw + away the part that survives a restart. + """ + if thread_root_id is not None: + carried = _read_publication_ref(thread_root_id) + if carried is not None: + return carried[1] + if not self._is_channel(channel_id): + return channel_id + if thread_root_id is None: + return None + return self._thread_conversation(channel_id, thread_root_id) + + def _writes_to(self, conversation_id: str) -> asyncio.Lock: + """The lock that orders this bridge's own writes to one conversation. + + Teams answers an edit to an activity it is still processing with 412, + and two publishers redrawing in one conversation generate those against + each other for as long as both keep retrying. A lock per conversation + turns the race into a queue; it says nothing about writers in another + process, which is what the 412 handling is still for. + + Bounded, and only ever forgetting a lock nobody holds β€” dropping a held + one would hand the next writer a fresh lock and quietly undo the + ordering it asked for. + """ + lock = self._conversation_writes.get(conversation_id) + if lock is None: + lock = asyncio.Lock() + self._conversation_writes[conversation_id] = lock + self._conversation_writes.move_to_end(conversation_id) + while len(self._conversation_writes) > _MAX_CONVERSATION_LOCKS: + idle = next( + ( + key + for key, held in self._conversation_writes.items() + if key != conversation_id and not held.locked() + ), + None, + ) + if idle is None: + break + del self._conversation_writes[idle] + return lock + async def _channel_layout(self, channel_id: str) -> str | None: """The channel's conversation layout as Graph reports it, or None. @@ -943,33 +1108,49 @@ async def send_message( if self._connector is None: raise RuntimeError("Cannot send message: Teams adapter not started") - service_url = self._service_url_for(channel_id) # `content` arrives rendered: every caller of `send_message` runs # `translate_outbound` first, and rendering again here put the body # through the conversion twice. activity = await self._message_activity(sender_name, content) thread_root_id = await self._post_to_answer_in(channel_id, thread_root_id) + return await self._relay(self._connector, channel_id, thread_root_id, activity) - if self._is_channel(channel_id) and thread_root_id is None: - conversation_id, msg_id = await self._connector.create_channel_thread( + async def _relay( + self, + connector: BotConnectorClient, + channel_id: str, + thread_root_id: str | None, + activity: dict[str, Any], + ) -> str: + """Send one relayed activity and remember where it went. + + Where the thread root is a publication reference the address it carries + is used whole, service URL included: a bridge that has only heard from + one region since it started would otherwise send a reply to a card in + another region's conversation to the region it happens to know. + """ + carried = _read_publication_ref(thread_root_id) if thread_root_id else None + service_url = carried[0] if carried else self._service_url_for(channel_id) + conversation_id = self._reply_conversation(channel_id, thread_root_id) + if conversation_id is None: + conversation_id, msg_id = await connector.create_channel_thread( service_url=service_url, channel_id=channel_id, activity=activity ) + # A message that opened its own post is that post. + remembered = msg_id else: - conversation_id = ( - self._thread_conversation(channel_id, thread_root_id) - if thread_root_id and self._is_channel(channel_id) - else channel_id - ) - msg_id = await self._connector.send_to_conversation( + msg_id = await connector.send_to_conversation( service_url=service_url, conversation_id=conversation_id, activity=activity, ) - + # `_last_post` holds a post id, so a reply contributes the post it + # went into rather than itself β€” read back off the conversation, + # which is the one form a publication reference and a bare root + # both reduce to. + remembered = self._root_of(conversation_id) or thread_root_id or msg_id self._sent[msg_id] = (service_url, conversation_id) - await self._remember_post( - channel_id, thread_root_id or msg_id, only_if_unset=True - ) + await self._remember_post(channel_id, remembered, only_if_unset=True) return msg_id async def admin_message( @@ -986,7 +1167,6 @@ async def admin_message( if self._connector is None: raise RuntimeError("Cannot post admin message: Teams adapter not started") - service_url = self._service_url_for(channel_id) body = self.translate_outbound(content) thread_root_id = await self._post_to_answer_in(channel_id, thread_root_id) activity: dict[str, Any] = {"type": "message", "text": body} @@ -995,28 +1175,7 @@ async def admin_message( # A plain-text activity carries its mention entities directly; only # a card puts them under `msteams`. activity["entities"] = mentions - - if self._is_channel(channel_id) and thread_root_id is None: - conversation_id, msg_id = await self._connector.create_channel_thread( - service_url=service_url, channel_id=channel_id, activity=activity - ) - else: - conversation_id = ( - self._thread_conversation(channel_id, thread_root_id) - if thread_root_id and self._is_channel(channel_id) - else channel_id - ) - msg_id = await self._connector.send_to_conversation( - service_url=service_url, - conversation_id=conversation_id, - activity=activity, - ) - - self._sent[msg_id] = (service_url, conversation_id) - await self._remember_post( - channel_id, thread_root_id or msg_id, only_if_unset=True - ) - return msg_id + return await self._relay(self._connector, channel_id, thread_root_id, activity) def _locate(self, channel_id: str, message_ref: str) -> tuple[str, str]: """Resolve ``(service_url, conversation_id)`` for a previously sent @@ -1111,10 +1270,20 @@ def rich_fallback_text(self, content: RichContent) -> str: a `RichContentFailed`, where a name this bridge could not resolve would add nothing to a post that did not happen. """ - return self._draw(content, mention=None, responder=None) + return self._draw( + content, + mention=None, + responder=None, + notice=self.unnotified_notice() if content.notify_unreachable else None, + ) def _draw( - self, content: RichContent, *, mention: str | None, responder: str | None + self, + content: RichContent, + *, + mention: str | None, + responder: str | None, + notice: str | None, ) -> str: """The body of a publication, as a card TextBlock will render it. @@ -1130,7 +1299,7 @@ def _draw( # Charged to the same budget as the status it follows: a body that # just fits, plus a line saying it reached nobody, is a body over # the budget. - tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + tail = f"\n{notice}" if notice else "" drawn = ( turn_status( content.items, @@ -1150,7 +1319,7 @@ def _draw( # heading: the card is a block, and a name wedged before "Permission # needed" reads as part of it. lead = f"{mention}\n" if mention else "" - tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + tail = f"\n{notice}" if notice else "" body = request_summary( content.request, content.reference, @@ -1186,38 +1355,123 @@ def _mention(self, external_id: str | None) -> str | None: return None return f"{html.escape(name)}" + def _unmentionable_notice(self) -> str: + """Why a publication that had someone to name did not name them. + + Not `unnotified_notice`, which says nobody is linked: here somebody is, + and what failed was turning their directory id into a name this team + can be `@`-mentioned by. Telling them to link an account they have + already linked would send the one person who could act to fix something + that is not broken. + """ + return ( + "Switch could not work out how to mention the person this is for, " + "so this notified no one. They are linked; finding their name in " + f"this {self.platform_name} team is what failed." + ) + def _render_rich(self, content: RichContent) -> str: + """Draw a publication, saying so when the mention could not be made. + + Two different failures reach the same reader. The publisher sets + `notify_unreachable` when there was nobody to name at all; this covers + the other one, where there was and the name could not be resolved here. + Either way the person who can answer has not been pinged, and the one + thing that must not happen is a card that looks like it went to them. + """ + mention = self._mention(content.notify_external_id) + unmentionable = mention is None and content.notify_external_id is not None + if unmentionable: + logger.warning( + "No Teams mention target for %s, so the publication names " + "nobody and says so.", + content.notify_external_id, + ) return self._draw( content, - mention=self._mention(content.notify_external_id), + mention=mention, responder=self._mention(content.responder_external_id) if isinstance(content, RequestCard) else None, + notice=self._unmentionable_notice() + if unmentionable + else self.unnotified_notice() + if content.notify_unreachable + else None, ) def _publication_conversation(self, channel_id: str, root_id: str | None) -> str: - """Where a publication lives, from durable facts only. + """Where a *new* publication goes, from durable facts only. - A Teams edit or delete is addressed to a *conversation*, and in a - channel the conversation is named by the post the message sits in - rather than by the message. So the answer needs the thread β€” which the - caller has written down, in the journal's anchor or in the card's row, - and passes back on every redraw. + A Teams message is sent to a *conversation*, and in a channel the + conversation is named by the post it sits in rather than by the + message. So the answer needs the thread β€” which the caller has written + down, in the journal's anchor or in the card's row, and passes in. - Deliberately neither `_sent`, which a restart empties and which would - then have `_locate` reconstruct an address that does not exist, nor + Deliberately neither `_sent`, which a restart empties, nor `_last_post`, which is the relay's guess at where an untied reply belongs and crosses conversations whenever two run in one channel. A chat is its own conversation and has no root. So does a channel with - no post named β€” which is where a *new* post begins, and is why this is + no post named β€” which is where a new post begins, and is why this is not an error: only `post_rich` ever asks with nothing to name, and it asks in order to start one. + + For a redraw, `_publication_address` is the method to want: it reads + the address Teams confirmed instead of rebuilding one. """ + if root_id is not None: + carried = _read_publication_ref(root_id) + if carried is not None: + return carried[1] if not self._is_channel(channel_id) or root_id is None: return channel_id return self._thread_conversation(channel_id, root_id) + def _publication_address( + self, channel_id: str, message_ref: str, thread_root_id: str | None + ) -> _Publication: + """The address to redraw or remove a publication at. + + The reference `post_rich` handed back carries the whole of it β€” + service URL, conversation and activity β€” because those are what Teams + confirmed rather than what this process can work out, and they are the + three that a restart, a regional service URL or a conversation Teams + named itself would each break separately. + + A reference from before that, or one the relay wrote, is a bare + message id, and then there is nothing to do but rebuild the address + from the channel and the stored thread. That is said out loud and + marked untrusted, because a 404 against a guess says the guess may be + wrong and not that the message is gone. + """ + carried = _read_publication_ref(message_ref) + if carried is not None: + service_url, conversation_id, activity_id = carried + return _Publication( + service_url=service_url, + conversation_id=conversation_id, + activity_id=activity_id, + channel_id=channel_id, + trusted=True, + ) + logger.warning( + "Teams publication %s in channel %s carries no address; rebuilding " + "one from the channel and its stored thread, which a chat whose " + "type this process has not learned will get wrong.", + message_ref, + channel_id, + ) + return _Publication( + service_url=self._service_url_for(channel_id), + conversation_id=self._publication_conversation( + channel_id, thread_root_id or message_ref + ), + activity_id=message_ref, + channel_id=channel_id, + trusted=False, + ) + @staticmethod def _throttled(error: BotConnectorThrottled, text: str) -> RichContentThrottled: return RichContentThrottled( @@ -1227,6 +1481,20 @@ def _throttled(error: BotConnectorThrottled, text: str) -> RichContentThrottled: text=text, ) + @staticmethod + def _conflicted(error: BotConnectorConflict, text: str) -> RichContentThrottled: + """A 412 is a wait, not a refusal. + + The message is there and something else wrote to it first, so the + publication is neither lost nor wrong β€” it is one revision behind. Sent + back as a backoff so the caller keeps its anchor and comes round again + with content rendered from the state as it is by then, rather than + replaying an intent that has already been overtaken. Calling it a + failure instead put a "could not be updated" notice in the channel for + something that fixes itself in a second. + """ + return RichContentThrottled(retry_after=_CONFLICT_BACKOFF, text=text) + async def post_rich( self, channel_id: str, @@ -1248,6 +1516,11 @@ async def post_rich( relay uses to steer an untied reply into whatever post the channel last spoke in, is not consulted: a publication has a durable address to keep and a guess is not one. + + What comes back is that address rather than a message id β€” see + `_publication_ref`. It is what the caller stores, and the only thing + that makes a redraw after a restart address the conversation the post + actually went to. """ text = self._render_rich(content) if self._connector is None: @@ -1256,28 +1529,38 @@ async def post_rich( ) service_url = self._service_url_for(channel_id) activity = await self._message_activity(agent_name, text) + opening = self._is_channel(channel_id) and thread_root_id is None + # A new post has no conversation to queue behind yet, so its writes are + # ordered against the channel instead. + target = ( + channel_id + if opening + else self._publication_conversation(channel_id, thread_root_id) + ) try: - if self._is_channel(channel_id) and thread_root_id is None: - conversation_id, ref = await self._connector.create_channel_thread( - service_url=service_url, channel_id=channel_id, activity=activity - ) - else: - conversation_id = self._publication_conversation( - channel_id, thread_root_id - ) - ref = await self._connector.send_to_conversation( - service_url=service_url, - conversation_id=conversation_id, - activity=activity, - ) + async with self._writes_to(target): + if opening: + conversation_id, ref = await self._connector.create_channel_thread( + service_url=service_url, + channel_id=channel_id, + activity=activity, + ) + else: + conversation_id = target + ref = await self._connector.send_to_conversation( + service_url=service_url, + conversation_id=conversation_id, + activity=activity, + ) except BotConnectorThrottled as error: raise self._throttled(error, text) from error + except BotConnectorConflict as error: + raise self._conflicted(error, text) from error except BotConnectorRefused as error: raise RichContentFailed( f"Teams would not post in channel {channel_id}: {error}", text=text ) from error - self._sent[ref] = (service_url, conversation_id) - return ref + return _publication_ref(service_url, conversation_id, ref) async def update_rich( self, @@ -1293,7 +1576,7 @@ async def update_rich( activity with plain text, which would strip the agent's card off a status halfway through the turn; and it addresses the message through `_sent`, which a restart empties. Here the card is rebuilt, and the - address comes from the thread the caller has kept. + address is the one `post_rich` returned and the caller wrote down. `agent_name` is what the redraw writes back into the header. One bot posts for every agent here, so the name is part of what was drawn, and @@ -1306,22 +1589,17 @@ async def update_rich( "Teams is not connected, so the publication could not be redrawn.", text=text, ) + address = self._publication_address(channel_id, message_ref, thread_root_id) if _retires(content): - await self._retire_rich( - connector, channel_id, agent_name, message_ref, thread_root_id, text - ) + await self._retire_rich(connector, agent_name, address, text) return - await self._edit_rich( - connector, channel_id, agent_name, message_ref, thread_root_id, text - ) + await self._edit_rich(connector, agent_name, address, text) async def _retire_rich( self, connector: BotConnectorClient, - channel_id: str, agent_name: str, - message_ref: str, - thread_root_id: str | None, + address: _Publication, text: str, ) -> None: """Take a finished status out of the conversation, where that is clean. @@ -1339,60 +1617,73 @@ async def _retire_rich( instead and the refusal is logged. An outcome nobody knows is raised: the publisher holds the anchor and can come back to it, and a status recorded as cleaned up when it was not is one that never goes. + + Gone is the one refusal that is neither. At an address Teams itself + confirmed, a 404 says the message is not there β€” most often because an + earlier delete landed and its acknowledgement did not β€” so the cleanup + this exists to do is already done, and editing instead would ask Teams + to rewrite a message that does not exist and fail on that too, every + cycle, forever. At an address this rebuilt, the same 404 may only mean + the address was wrong, which is not an outcome, so it is raised. """ - if await self._leaves_a_tombstone(channel_id): - await self._edit_rich( - connector, channel_id, agent_name, message_ref, thread_root_id, text - ) + if await self._uses_post_layout( + address.channel_id, is_channel=address.in_a_channel + ): + await self._edit_rich(connector, agent_name, address, text) return try: - await connector.delete_activity( - service_url=self._service_url_for(channel_id), - conversation_id=self._publication_conversation( - channel_id, thread_root_id or message_ref - ), - activity_id=message_ref, - ) + async with self._writes_to(address.conversation_id): + await connector.delete_activity( + service_url=address.service_url, + conversation_id=address.conversation_id, + activity_id=address.activity_id, + ) except BotConnectorThrottled as error: raise self._throttled(error, text) from error + except BotConnectorConflict as error: + raise self._conflicted(error, text) from error + except BotConnectorGone: + if not address.trusted: + raise + logger.info( + "Teams has no activity %s in conversation %s; the finished " + "status is already gone, so its cleanup is complete.", + address.activity_id, + address.conversation_id, + ) except BotConnectorRefused as error: logger.warning( - "Teams would not remove the finished status %s in channel %s " - "(%s); leaving its final state there instead.", - message_ref, - channel_id, + "Teams would not remove the finished status %s in conversation " + "%s (%s); leaving its final state there instead.", + address.activity_id, + address.conversation_id, error, ) - await self._edit_rich( - connector, channel_id, agent_name, message_ref, thread_root_id, text - ) - return - self._sent.pop(message_ref, None) + await self._edit_rich(connector, agent_name, address, text) async def _edit_rich( self, connector: BotConnectorClient, - channel_id: str, agent_name: str, - message_ref: str, - thread_root_id: str | None, + address: _Publication, text: str, ) -> None: try: - await connector.update_activity( - service_url=self._service_url_for(channel_id), - conversation_id=self._publication_conversation( - channel_id, thread_root_id or message_ref - ), - activity_id=message_ref, - activity=await self._message_activity(agent_name, text), - ) + async with self._writes_to(address.conversation_id): + await connector.update_activity( + service_url=address.service_url, + conversation_id=address.conversation_id, + activity_id=address.activity_id, + activity=await self._message_activity(agent_name, text), + ) except BotConnectorThrottled as error: raise self._throttled(error, text) from error + except BotConnectorConflict as error: + raise self._conflicted(error, text) from error except BotConnectorRefused as error: raise RichContentFailed( - f"Teams refused the edit to {message_ref} in channel " - f"{channel_id}: {error}", + f"Teams refused the edit to {address.activity_id} in " + f"conversation {address.conversation_id}: {error}", text=text, ) from error diff --git a/core/switch_core/bridges/collaboration/teams/connector.py b/core/switch_core/bridges/collaboration/teams/connector.py index 63b67092a..a0bd8c339 100644 --- a/core/switch_core/bridges/collaboration/teams/connector.py +++ b/core/switch_core/bridges/collaboration/teams/connector.py @@ -11,15 +11,22 @@ logger = logging.getLogger(__name__) ACTIVITY_SIZE_LIMIT = 64 * 1024 -"""Refuse an activity larger than this, measured as UTF-16 bytes. +"""Refuse an activity that measures more than this against Teams' own metric. Microsoft asks that a bot message stay within 80 KB and describes its own ceiling of 100 KB as approximate, counted in UTF-16. Two readings of "80 KB" are possible β€” bytes, or code units β€” so this sits below the smaller of them -with room to spare, and the count includes everything on the wire: the card, +with room to spare, and the measurement covers the whole activity: the card, the mention entities, and the body text repeated in `summary` and `fallbackText`. Going over earns a 413 the sender cannot do anything useful with; refusing here names the size instead. + +This is the platform's metric and not the bytes actually sent, which are +UTF-8. Neither encoding is reliably the larger β€” ASCII doubles in UTF-16 while +CJK grows in UTF-8 instead β€” so the budget is set far enough under the +platform's ceiling to cover the gap either way rather than guessing which text +is coming. A refusal reports both numbers so the reader can tell which one was +measured. """ @@ -111,15 +118,17 @@ def _failure(operation: str, resp: httpx.Response) -> BotConnectorError: def _payload(operation: str, body: dict[str, Any]) -> bytes: text = json.dumps(body, ensure_ascii=False) + wire = text.encode() measured = len(text.encode("utf-16-le")) if measured > ACTIVITY_SIZE_LIMIT: raise BotConnectorRefused( - f"{operation} was not attempted: the activity is {measured} bytes of " - f"UTF-16 and Teams accepts up to about {ACTIVITY_SIZE_LIMIT}.", + f"{operation} was not attempted: the activity measures {measured} " + f"bytes against Teams' UTF-16 metric, over the {ACTIVITY_SIZE_LIMIT} " + f"allowed here. It is {len(wire)} bytes of UTF-8 on the wire.", status=None, retry_after=None, ) - return text.encode() + return wire class BotConnectorClient: @@ -188,14 +197,19 @@ def _identifier(operation: str, resp: httpx.Response, field: str) -> str: retry_after=None, ) from error value = data.get(field) if isinstance(data, dict) else None - if not value: + # A string, and not merely something truthy: this becomes the address + # the message is edited and deleted at, and `str()` of a number, a + # `True` or a nested object would turn a malformed answer into an + # address this claims to have confirmed. + if not isinstance(value, str) or not value.strip(): raise BotConnectorUnaddressable( f"{operation} was accepted ({resp.status_code}) but returned no " - f"{field}, so the message it wrote cannot be edited or deleted.", + f"usable {field} ({value!r}), so the message it wrote cannot be " + "edited or deleted.", status=resp.status_code, retry_after=None, ) - return str(value) + return value async def create_channel_thread( self, *, service_url: str, channel_id: str, activity: dict[str, Any] diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index f3f1123ba..3f08f6cc5 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -425,6 +425,11 @@ class TelegramAdapter(CollaborationAdapter): renders_legacy_runtime_state: ClassVar[bool] = False + # Telegram's is the one disclosure that has been agreed: T2, accepted for + # this platform on this platform's evidence. It does not travel to another + # adapter that happens to share the inability to search. + discloses_unconfirmed_posts: ClassVar[bool] = True + def __init__(self, *, config: TelegramConnectionConfig) -> None: super().__init__() self._config = config diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 1e4367b55..5af992b02 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -69,8 +69,9 @@ class PublicationIncomplete(Exception): caller over HTTP acts on: this is an internal signal `publish_pending` reads to decide how loudly to say so. `errors` is what actually broke β€” still worth an error-level log with a traceback; `backed_off` is requests - still waiting out a recovery search's own backoff, which is working as - designed and must not read as a fresh failure on every retry. + deliberately not tried again yet, either a recovery search or a post to a + destination that keeps refusing, which is working as designed and must not + read as a fresh failure on every retry. """ def __init__( @@ -81,7 +82,7 @@ def __init__( self.backed_off = backed_off super().__init__( f"Session {session_id}: {len(errors)} request(s) failed to " - f"publish and {backed_off} are waiting out a recovery backoff." + f"publish and {backed_off} are waiting out a retry backoff." ) @@ -94,6 +95,8 @@ async def refresh_cards( gateway_public_url: str | None = None, recovery_allowed: Callable[[str], bool] = _always_recover, recovery_succeeded: Callable[[str], None] = _ignore_recovery, + post_allowed: Callable[[str], bool] = _always_recover, + post_succeeded: Callable[[str], None] = _ignore_recovery, refresh_needed: Callable[[str, tuple[int, str]], bool] = _always_refresh, refreshed: Callable[[str, tuple[int, str]], None] = _ignore_refresh, ) -> None: @@ -101,6 +104,18 @@ async def refresh_cards( `recovery_allowed` gates `recover` β€” the search for a card whose post is unconfirmed β€” per token, and `recovery_succeeded` is told when one lands. + + `post_allowed` gates the *first* post of a card, keyed by session and + request rather than by token because a refused post releases its handle + and the next attempt mints a new one. It exists for the destination that + is permanently unavailable β€” a deleted channel, a thread nobody can write + in β€” where without it this reserved a handle, had the platform refuse it + and released it again on every publish cycle, forever, logging a failure + each time. The wait stretches instead, so a destination that comes back is + still picked up and one that does not stops drowning the log. It does not + decide what the channel is told: that is the platform's disclosure policy + and is deliberately not made here. + `refresh_needed` gates redrawing an already-confirmed card, per token and `(revision, state)`, and `refreshed` is told once one lands. Both parts of that pair matter: `request.submitting` moves a request from `open` to @@ -244,6 +259,10 @@ async def refresh_cards( if post is None: if request.state != "open": continue + attempt = f"{session_id}:{request.request_id}" + if not post_allowed(attempt): + backed_off += 1 + continue new_post = await cards.post( request, channel_id=channel_id, @@ -257,6 +276,7 @@ async def refresh_cards( notify_unreachable=unreachable, unavailable_reason=unavailable_reason, ) + post_succeeded(attempt) refreshed(new_post.token, state) elif post.external_post_id == post.token: if post.unconfirmed_notice_at is not None: @@ -267,7 +287,15 @@ async def refresh_cards( # well as it can be. continue if not cards.recovers_uncertain_posts: - await cards.disclose_unconfirmed(post, console_url=console_url) + if cards.discloses_unconfirmed_posts: + await cards.disclose_unconfirmed(post, console_url=console_url) + else: + # Nothing to search for and nothing this platform has + # been cleared to say, so the reservation is simply + # held: it is what stops a second card, and the + # request is still answerable in Console. Said once + # per process rather than on every cycle. + cards.note_unconfirmed(post) continue if not recovery_allowed(post.token): backed_off += 1 @@ -306,7 +334,7 @@ async def refresh_cards( # any that came after. Each is retried on its own next cycle # regardless β€” what this function reports below is what stops # `publish_pending` marking the session done while any request in - # it is still broken or waiting out a recovery backoff. + # it is still broken or waiting out a retry backoff. logger.exception( "Could not publish request %s of session %s on bridge %s; " "the rest of the session's requests were tried anyway.", @@ -731,15 +759,18 @@ async def refresh_activity( class _RecoveryBackoff: - """Bounds how often `recover` re-scans a channel's history for one card. - - Unbounded retries were the problem this closes: a card whose post - genuinely never landed had this run again every publish cycle, forever, - against a search that gets more expensive over time as the channel - accumulates history past the point it started from. The wait doubles per - token on every attempt that still finds nothing, up to `_MAX`, and clears - the moment one succeeds β€” so a card that does eventually turn up is not - left waiting out a long interval it no longer needs. + """Bounds how often one card's platform call is attempted again. + + Unbounded retries were the problem this closes. A card whose post + genuinely never landed had `recover` re-scan the channel's history every + publish cycle, forever, against a search that gets more expensive over + time as the channel accumulates history past the point it started from; + and a card whose destination no longer exists had the post itself + reserved, refused and released on every cycle just as often. The wait + doubles per key on every attempt that does not get there, up to `_MAX`, + and clears the moment one succeeds β€” so a destination that comes back, or + a card that does eventually turn up, is not left waiting out a long + interval it no longer needs. """ _MIN = 5.0 @@ -891,6 +922,7 @@ def __init__( self._activity = activity self._published: dict[str, tuple[int, bool]] = {} self._recovery = _RecoveryBackoff() + self._card_post = _RecoveryBackoff() self._redraw = _RedrawGuard() self._turn_redraw = _TurnRedrawGuard() self._activity_retry = _RecoveryBackoff(max_interval=30.0) @@ -1008,6 +1040,8 @@ async def publish_pending(self) -> None: gateway_public_url=self._gateway_public_url, recovery_allowed=self._recovery.allowed, recovery_succeeded=self._recovery.succeeded, + post_allowed=self._card_post.allowed, + post_succeeded=self._card_post.succeeded, refresh_needed=self._redraw.needed, refreshed=self._redraw.drawn, ) @@ -1016,7 +1050,7 @@ async def publish_pending(self) -> None: if incomplete.errors: logger.exception( "Session %s card publication failed on bridge %s " - "(%d failed, %d waiting on a recovery backoff); " + "(%d failed, %d waiting on a retry backoff); " "will retry.", session_id, self._bridge_id, @@ -1029,7 +1063,7 @@ async def publish_pending(self) -> None: # as one would put a real broken-and-continuing signal in # the same stream as a wait that is working as designed. logger.warning( - "Session %s has %d request(s) waiting out a recovery " + "Session %s has %d request(s) waiting out a retry " "backoff on bridge %s.", session_id, incomplete.backed_off, diff --git a/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py index b4f26db4b..879d7e1ee 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py @@ -254,7 +254,10 @@ def _post() -> SessionRequestPost: external_channel_id="C1", external_post_id="C1:111.0", room_id="room-demo", - thread_id="thread-demo", + # The thread the card was posted into, in the form the platform itself + # uses for a message: Slack's inbound path stores a thread root as + # `channel:ts`, not a bare id. + thread_id="C1:100.0", session_id="session-demo", epoch="epoch-demo", request_id="request-demo", @@ -352,6 +355,11 @@ async def test_a_failed_edit_puts_the_outcome_in_the_thread_instead() -> None: failure to know to retry β€” but the reply is only posted once: a card stuck at the same revision and state would otherwise get the same notice again on every retry. + + The notice goes into the conversation the card is in, which is the thread + it was posted into where there was one. Slack would have put it in the + same place either way; Teams would not, because there a reply is addressed + to the conversation and the card's own id is not one. """ request = await _request(through=SETTLED) adapter, client = _adapter() @@ -366,12 +374,32 @@ async def test_a_failed_edit_puts_the_outcome_in_the_thread_instead() -> None: assert client.updated == [] assert len(client.posted) == 1 reply = client.posted[0] - assert reply["thread_ts"] == "111.0" + assert reply["thread_ts"] == "100.0" assert "R42" in reply["text"] assert "could not be updated" in reply["text"] assert "Allow once Β· actor-demo from Mattermost." in reply["text"] +async def test_a_card_posted_at_the_channel_root_is_replied_to_under_itself() -> None: + """With no thread to reply into, the card itself is the thread to start. + + This is the branch that keeps the flat-channel platforms reading the way + they did: the notice hangs off the card rather than landing loose in the + channel above it. + """ + request = await _request(through=SETTLED) + adapter, client = _adapter() + client.update_error = "message_not_found" + post = _post() + post.thread_id = None + + with pytest.raises(RichContentFailed): + await _cards(adapter, post).refresh(post, request, agent_name="agent") + + assert len(client.posted) == 1 + assert client.posted[0]["thread_ts"] == "111.0" + + async def test_resolved_plan_uses_display_name_and_keeps_slack_mention_in_details() -> ( None ): diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py index e977bec42..34211c9a0 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py @@ -14,6 +14,7 @@ from __future__ import annotations +import asyncio import logging from pathlib import Path from typing import Any @@ -31,9 +32,14 @@ FixtureEventSource, project, ) -from switch_core.bridges.collaboration.teams.adapter import TeamsAdapter +from switch_core.bridges.collaboration.teams.adapter import ( + TeamsAdapter, + _publication_ref, +) from switch_core.bridges.collaboration.teams.connector import ( + BotConnectorConflict, BotConnectorGone, + BotConnectorRefused, BotConnectorThrottled, BotConnectorUnavailable, ) @@ -64,21 +70,36 @@ def __init__(self) -> None: self.fail_send: Exception | None = None self.fail_update: Exception | None = None self.fail_delete: Exception | None = None + # What a create answers with. Teams is entitled to name the + # conversation itself rather than after the post it opened. + self.conversation: str | None = None async def create_channel_thread( self, *, service_url: str, channel_id: str, activity: dict[str, Any] ) -> tuple[str, str]: if self.fail_send is not None: raise self.fail_send - self.threads.append({"channel_id": channel_id, "activity": activity}) - return f"{channel_id};messageid={ROOT}", ROOT + self.threads.append( + { + "channel_id": channel_id, + "activity": activity, + "service_url": service_url, + } + ) + return (self.conversation or f"{channel_id};messageid={ROOT}", ROOT) async def send_to_conversation( self, *, service_url: str, conversation_id: str, activity: dict[str, Any] ) -> str: if self.fail_send is not None: raise self.fail_send - self.sends.append({"conversation_id": conversation_id, "activity": activity}) + self.sends.append( + { + "conversation_id": conversation_id, + "activity": activity, + "service_url": service_url, + } + ) return "MSG1" async def send_signal( @@ -101,6 +122,7 @@ async def update_activity( "conversation_id": conversation_id, "activity_id": activity_id, "activity": activity, + "service_url": service_url, } ) @@ -110,7 +132,11 @@ async def delete_activity( if self.fail_delete is not None: raise self.fail_delete self.deletes.append( - {"conversation_id": conversation_id, "activity_id": activity_id} + { + "conversation_id": conversation_id, + "activity_id": activity_id, + "service_url": service_url, + } ) @@ -129,6 +155,21 @@ def _teams( return adapter, connector +def _restart(adapter: TeamsAdapter) -> None: + """Everything this process learned about a conversation, gone. + + What a restart leaves is what was written down β€” the publication reference + the caller stored β€” and nothing else. `_channel_type` emptying is the one + that bit: an id it has not heard of reads as a channel, so a chat came back + as a post inside itself. + """ + adapter._sent.clear() + adapter._channel_type.clear() + adapter._channel_layouts.clear() + adapter._last_post.clear() + adapter._service_url.clear() + + def _activity(**kwargs: Any) -> TurnActivity: items = [_item(status="in-progress", title=RUNNING_LINE)] return TurnActivity(items, _turn("running"), **kwargs) @@ -236,7 +277,7 @@ def test_a_publication_with_no_thread_opens_its_own_post() -> None: ref = _run(adapter.post_rich(CHANNEL, AGENT, _activity(), None)) - assert ref == ROOT + assert ref == _publication_ref(SERVICE_URL, f"{CHANNEL};messageid={ROOT}", ROOT) assert [t["channel_id"] for t in connector.threads] == [CHANNEL] @@ -471,9 +512,15 @@ def test_a_refused_removal_leaves_the_final_state_instead_of_pretending( caplog: pytest.LogCaptureFixture, ) -> None: """A deletion Teams refused is not a deletion. Left as "Working…" the post - would report a turn that ended minutes ago as still running.""" + would report a turn that ended minutes ago as still running. + + A refusal, and not a 404: the message being absent is the one answer where + editing it instead cannot work, and that has its own handling below. + """ adapter, connector = _teams("chat") - connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) + connector.fail_delete = BotConnectorRefused( + "forbidden", status=403, retry_after=None + ) with caplog.at_level(logging.WARNING): _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) @@ -494,3 +541,203 @@ def test_a_removal_whose_outcome_is_unknown_is_not_recorded_as_done() -> None: _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) assert connector.updates == [] + + +def test_confirmed_absence_finishes_the_cleanup_rather_than_editing_nothing() -> None: + """A delete that landed and whose acknowledgement did not is answered 404 + on the retry. Editing the missing message instead is refused too, so the + cleanup never settled and every later cycle tried it again.""" + adapter, connector = _teams(chat=True) + ref = _run(adapter.post_rich(CHAT, AGENT, _activity(), None)) + connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) + + _run(adapter.update_rich(CHAT, AGENT, ref, _ended(), None)) + + assert connector.updates == [] + + +def test_absence_at_an_address_this_rebuilt_is_not_taken_as_an_outcome() -> None: + """Without a stored address the 404 may only mean the address was wrong, + and a status wrongly recorded as removed is one that never goes.""" + adapter, _connector = _teams("chat") + _connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) + + with pytest.raises(BotConnectorGone): + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + + assert _connector.updates == [] + + +# ── The address Teams confirmed is the one kept ────────────────────────────── + + +def test_the_conversation_the_server_returned_is_the_one_edited() -> None: + """Teams may name the conversation itself rather than after the post it + opened. Rebuilding `channel;messageid=root` then edits somewhere else.""" + adapter, connector = _teams() + connector.conversation = "19:opaque-conversation@thread.tacv2" + + ref = _run(adapter.post_rich(CHANNEL, AGENT, _activity(), None)) + _run(adapter.update_rich(CHANNEL, AGENT, ref, _activity(), None)) + + assert connector.updates[0]["conversation_id"] == connector.conversation + + +def test_a_chat_redraw_after_a_restart_does_not_become_a_channel_thread() -> None: + """`_channel_type` empties on restart and an unknown id reads as a channel, + so a chat's card was edited at `chat;messageid=card` β€” a conversation that + does not exist, and every later edit of that card was refused.""" + adapter, connector = _teams(chat=True) + ref = _run(adapter.post_rich(CHAT, AGENT, _activity(), None)) + + _restart(adapter) + _run(adapter.update_rich(CHAT, AGENT, ref, _activity(), None)) + + assert connector.updates[0]["conversation_id"] == CHAT + + +def test_a_chat_status_is_still_removed_rather_than_edited_after_a_restart() -> None: + """The same lost channel type decides whether a finished status is deleted + or left as a tombstone, so it has to come off the address too.""" + adapter, connector = _teams(chat=True) + ref = _run(adapter.post_rich(CHAT, AGENT, _activity(), None)) + + _restart(adapter) + _run(adapter.update_rich(CHAT, AGENT, ref, _ended(), None)) + + assert connector.updates == [] + assert connector.deletes[0]["conversation_id"] == CHAT + + +def test_a_channel_reply_is_redrawn_in_its_post_after_a_restart() -> None: + adapter, connector = _teams() + ref = _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + _restart(adapter) + _run(adapter.update_rich(CHANNEL, AGENT, ref, _activity(), ROOT)) + + assert connector.updates[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + assert connector.updates[0]["activity_id"] == "MSG1" + + +def test_a_publications_own_region_is_the_one_it_is_edited_in() -> None: + """The service URL is regional and learned from inbound traffic. A process + that has since heard from one other region sent the edit there instead.""" + adapter, connector = _teams() + ref = _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + _restart(adapter) + adapter._default_service_url = "https://smba.example/emea/" + _run(adapter.update_rich(CHANNEL, AGENT, ref, _activity(), ROOT)) + + assert connector.updates[0]["service_url"] == SERVICE_URL + + +def test_a_notice_about_a_card_lands_in_the_card_s_own_conversation() -> None: + """`admin_message` is given the card's publication reference as its thread + root, because a card that opened its own post *is* that conversation.""" + adapter, connector = _teams() + ref = _run(adapter.post_rich(CHANNEL, AGENT, _run(_card()), None)) + + _restart(adapter) + _run(adapter.admin_message(CHANNEL, "That card is stale.", ref)) + + assert connector.sends[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + + +def test_a_reference_without_an_address_is_rebuilt_and_said_so( + caplog: pytest.LogCaptureFixture, +) -> None: + """Anything stored before publications carried their address is a bare + message id. It still works where the guess holds, and the guess is named.""" + adapter, connector = _teams() + + with caplog.at_level(logging.WARNING): + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT)) + + assert connector.updates[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + assert "carries no address" in caplog.text + + +# ── A conflict is a wait, not a refusal ────────────────────────────────────── + + +def test_a_transient_edit_conflict_backs_off_instead_of_reporting_failure() -> None: + """412 means something wrote to the activity first, so the card is one + revision behind rather than broken. Reported as a failure it put a "could + not be updated" notice in the channel for something that fixes itself.""" + adapter, connector = _teams() + connector.fail_update = BotConnectorConflict( + "changed", status=412, retry_after=None + ) + + with pytest.raises(RichContentThrottled) as raised: + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT)) + + assert raised.value.retry_after > 0 + + +def test_a_conflict_on_removal_is_retried_rather_than_left_as_final_state() -> None: + """The status is still there and still removable; writing its final state + instead would leave a line in a chat that clears itself.""" + adapter, connector = _teams("chat") + connector.fail_delete = BotConnectorConflict( + "changed", status=412, retry_after=None + ) + + with pytest.raises(RichContentThrottled): + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + + assert connector.updates == [] + + +def test_writes_to_one_conversation_do_not_overlap() -> None: + """Two publishers redrawing in the same conversation generate 412s against + each other for as long as both keep retrying. One lock makes it a queue.""" + adapter, connector = _teams() + overlapped = False + inside = False + + async def update_activity(**kwargs: Any) -> None: + nonlocal overlapped, inside + if inside: + overlapped = True + inside = True + await asyncio.sleep(0) + inside = False + connector.updates.append(kwargs) + + connector.update_activity = update_activity # type: ignore[assignment] + + async def both() -> None: + await asyncio.gather( + adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT), + adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT), + ) + + _run(both()) + + assert len(connector.updates) == 2 + assert overlapped is False + + +# ── A mention that cannot be made is said out loud ─────────────────────────── + + +def test_a_recipient_whose_name_cannot_be_resolved_is_disclosed() -> None: + """The publisher only knows about the recipient it could not *find*. A name + that fails to resolve here loses the mention as well, and a card that names + nobody reads as one whose reader has already seen it.""" + adapter, connector = _teams() + + _run( + adapter.post_rich( + CHANNEL, AGENT, _run(_card(notify_external_id="aad-unknown")), ROOT + ) + ) + + body = _card_text(connector.sends[0]["activity"]) + assert "notified no one" in body + # Not the "nobody is linked" line: somebody is, and sending them to link an + # account they have already linked fixes nothing. + assert "Link your" not in body diff --git a/core/tests/switch_core/sessions/test_publication_retries.py b/core/tests/switch_core/sessions/test_publication_retries.py index 6f1442a22..4d0ad7a6a 100644 --- a/core/tests/switch_core/sessions/test_publication_retries.py +++ b/core/tests/switch_core/sessions/test_publication_retries.py @@ -73,6 +73,14 @@ def cards_for(factory, platform): async def test_host_ack_and_retry_survive_publication_failure( session_factory, monkeypatch, caplog ): + """What a refused first post must not cost: the host's acknowledgement. + + The floor on the publisher's post backoff is taken out of the way so the + retry happens on the very next cycle. That the backoff is there at all is + `test_a_card_that_cannot_be_posted_is_not_retried_every_cycle`'s claim; + this one is about the ack surviving and the card arriving in the end. + """ + monkeypatch.setattr(_RecoveryBackoff, "_MIN", 0.0) service, epoch = await setup(session_factory) await opened(service, epoch) platform = RecoverablePlatform() @@ -284,9 +292,13 @@ class UnsearchablePlatform(Platform): whose response was lost can never be matched to what is in the chat. It also declines to linkify a `switchdash://` URL, so the Console link in the notice has to be the gateway's https redirect to be a link at all. + + It is also the one platform cleared to say so in the channel, which is a + separate flag on purpose β€” see `UndisclosingPlatform`. """ renders_custom_url_schemes = False + discloses_unconfirmed_posts = True def __init__(self): super().__init__() @@ -297,6 +309,17 @@ async def admin_message(self, channel, content, thread=None, *, message_type=Non return f"{channel}:333.0" +class UndisclosingPlatform(UnsearchablePlatform): + """Cannot search either, and has not been cleared to say so in the channel. + + Teams is the real one. The notice writes an unrequested message into a + conversation this bridge does not own, and whether that is wanted is a + decision about the channel rather than a fact about the adapter. + """ + + discloses_unconfirmed_posts = False + + async def _lose_the_card(session_factory, platform, monkeypatch): """Reserve a card, then lose the response to the post that would confirm it.""" service, epoch = await setup(session_factory) @@ -410,6 +433,35 @@ async def test_a_notice_that_cannot_be_sent_is_not_retried_into_a_cascade( assert "nothing in the channel says so" in caplog.text +async def test_a_platform_not_cleared_to_disclose_says_nothing_in_the_channel( + session_factory, monkeypatch, caplog +): + """Being unable to search does not, by itself, authorise the notice. + + The two used to be one flag, so a new platform declaring it could not look + for a lost card silently started posting an unrequested message into its + channels. Here the reservation is kept β€” no second card, and the request is + still answerable in Console β€” and the operator is told once. + """ + platform = UndisclosingPlatform() + await _lose_the_card(session_factory, platform, monkeypatch) + cards = cards_for(session_factory, platform) + + with caplog.at_level(logging.ERROR): + for _ in range(3): + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert platform.notices == [] + assert platform.posts == [] + assert len([r for r in caplog.records if "never confirmed" in r.message]) == 1 + async with session_factory() as db: + post = (await db.scalars(select(SessionRequestPost))).one() + assert post.external_post_id == post.token + # Not stamped: the mark means the channel was told, and it has to stay + # true so the notice can still be made once a policy is agreed. + assert post.unconfirmed_notice_at is None + + async def test_a_platform_that_can_search_still_waits_for_its_card( session_factory, monkeypatch ): @@ -667,6 +719,37 @@ async def test_the_publisher_does_not_re_search_every_cycle_for_a_card_that_neve assert find.await_count == 1 # backing off; not attempted again immediately +async def test_a_card_that_cannot_be_posted_is_not_retried_every_cycle( + session_factory, monkeypatch, caplog +): + """A refused post leaves no row, so the next cycle sees a request with no + card and reserves, posts and releases all over again β€” at whatever rate + the publisher runs, against a destination that is saying no. The first + post of a card is on the same backoff the recovery search is, so a + channel the bot has been removed from costs one attempt and then a + widening wait rather than a permanent spin.""" + service, epoch = await setup(session_factory) + await opened(service, epoch) + platform = RecoverablePlatform() + refused = AsyncMock(side_effect=RichContentFailed("no such channel", text="gone")) + monkeypatch.setattr(platform, "post_rich", refused) + publisher = SessionPublisher( + session_factory, "bridge", cards_for(session_factory, platform) + ) + + await publisher.publish_pending() + assert refused.await_count == 1 + async with session_factory() as db: + # Refused outright, so the handle went back rather than being held + # against a card nobody can see. + assert (await db.scalars(select(SessionRequestPost))).all() == [] + + caplog.clear() + await publisher.publish_pending() + assert refused.await_count == 1 + assert "waiting out a retry backoff" in caplog.text + + # ── A confirmed card is only redrawn when something about it changed ──────── @@ -810,7 +893,7 @@ async def test_a_pure_backoff_wait_logs_a_warning_not_an_exception( assert not any(record.levelname == "ERROR" for record in caplog.records) assert any( record.levelname == "WARNING" - and "waiting out a recovery backoff" in record.message + and "waiting out a retry backoff" in record.message for record in caplog.records ) From 2f876e659d3eaa0a7da07c1378d0f8c3a38a9a5d Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 12:24:10 +0100 Subject: [PATCH 020/120] Understand a stored message reference as a Telegram thread root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread root reaches the Telegram adapter in two spellings. Inbound records a bare number β€” the topic in a forum, the message replied to everywhere else. The publication seam resolves its root through the message map, and what that stores is this platform's own reference to a message, `chat:message`. All three anchor helpers read the whole string as a number, so the composite form failed: `_publication_anchor` refused the send, which is why no Telegram permission card has ever been posted and the status was spinning on the same refusal every thirty seconds; `_anchor_kwargs` detached and lost the quote; `_topic_kwargs` silently widened a chat action to the whole forum. One resolver now answers what a root names in a given chat, and the three callers keep their own policy for one it cannot address. Its substantive judgement is that a composite reference is a reply target even in a forum: it names one message, and reading `75` out of `-100123:75` as a topic id would aim the post at whichever topic happens to hold that number. A composite belonging to another chat resolves to nothing rather than being stripped to its number β€” that is a confusion of destinations, not a missing quote. Once a reply anchor is possible in a forum, `_send_text` carrying it only on the first chunk would drop the tail of a long answer into General, away from the people reading the topic. So the anchor repeats on every chunk in a forum whichever kind it is: a repeated quote is noise, a split audience is not. The existing tests all passed a root of `"88"`, a form the publication seam never produces. Three of the six added here fail against the old adapter. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/telegram/adapter.py | 142 +++++++++++++----- .../collaboration/test_telegram_adapter.py | 27 ++++ .../collaboration/test_telegram_sdk_only.py | 52 +++++++ 3 files changed, 182 insertions(+), 39 deletions(-) diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 3f08f6cc5..87d6be76c 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -353,6 +353,27 @@ class _ChatVisibility(NamedTuple): via_admin: bool +def _as_int(value: str) -> int | None: + """The number this names, where it names one.""" + try: + return int(value) + except ValueError: + return None + + +class _ThreadRoot(NamedTuple): + """Where in one chat a thread root points. + + The same number means one of two things. A forum topic is addressed with + ``message_thread_id`` and every message in it carries that id; anywhere + else Telegram has no thread and the root is a message to reply to. The two + are not interchangeable β€” see ``_resolve_root``. + """ + + is_topic: bool + id: int + + class TelegramConnectionConfig(BridgeConnectionConfig): bot_token: str bot_username: str @@ -2981,20 +3002,47 @@ async def _is_forum(self, channel_id: str) -> bool: self._forum_chats[channel_id] = is_forum return is_forum + async def _resolve_root( + self, channel_id: str, thread_root_id: str + ) -> _ThreadRoot | None: + """What a thread root names in this chat, or None if it names nothing. + + A root arrives in either of two spellings, and they do not mean the + same thing. Inbound records a bare number β€” the topic in a forum, the + message replied to everywhere else. The publication seam records this + platform's own reference to a message, `chat:message`, because that is + the form the message map stores, and it always names one message. So a + composite reference is a reply target even in a forum: reading `75` out + of `-100123:75` and passing it as a topic would aim the post at + whichever topic happens to hold that number. + + Nothing is returned for a reference this chat cannot address β€” a number + that is not one, or a message belonging to another chat, which is a + confusion of destinations rather than a missing quote. Callers disagree + about what that should cost, so none of it is settled here. + """ + chat, separator, message = thread_root_id.partition(":") + if separator: + if chat != channel_id: + return None + numbered = _as_int(message) + return None if numbered is None else _ThreadRoot(False, numbered) + root = _as_int(chat) + if root is None: + return None + return _ThreadRoot(await self._is_forum(channel_id), root) + async def _anchor_kwargs( self, channel_id: str, thread_root_id: str | None ) -> dict[str, Any]: """Anchor a post where the conversation it belongs to is. - Two different things are spelled the same way. In a forum the root is - the topic, and a topic is addressed with `message_thread_id` β€” every - message in it carries that id, not the id of anything one of them - replied to. Everywhere else Telegram has no thread at all and the root - is a message to reply to. Sending one as the other is not a formatting - difference: a topic id used as a reply target is a reply to whichever - message happens to hold that number, and it lands in the General topic - the moment the topic's opening message is gone β€” so a card asked for in - one topic would be put to the whole group instead. + Sending a topic as a reply target, or the other way about, is not a + formatting difference: a topic id used as a reply target quotes + whichever message happens to hold that number, and it lands in the + General topic the moment the topic's opening message is gone β€” so a + card asked for in one topic would be put to the whole group instead. + Which of the two a root names is `_resolve_root`'s question. A reply target that has since been deleted does not stop the send. Detaching there costs the quote, not the audience: it is the same chat @@ -3002,16 +3050,19 @@ async def _anchor_kwargs( """ if not thread_root_id: return {} - try: - root = int(thread_root_id) - except ValueError: - logger.error("Ignoring unparseable Telegram thread root %s", thread_root_id) + root = await self._resolve_root(channel_id, thread_root_id) + if root is None: + logger.error( + "Ignoring Telegram thread root %s, which chat %s cannot address.", + thread_root_id, + channel_id, + ) return {} - if await self._is_forum(channel_id): - return {"message_thread_id": root} + if root.is_topic: + return {"message_thread_id": root.id} return { "reply_parameters": ReplyParameters( - message_id=root, allow_sending_without_reply=True + message_id=root.id, allow_sending_without_reply=True ) } @@ -3030,26 +3081,25 @@ async def _publication_anchor( and the publisher takes the route it keeps for a destination it cannot reach β€” which ends at the Console rather than in the wrong place. - A root that is not a number is the same thing arriving differently: the - caller asked for somewhere this cannot address, and posting to the chat - instead would be answering a question nobody asked. + A root this chat cannot address is the same thing arriving differently: + the caller asked for somewhere this cannot reach, and posting to the + chat instead would be answering a question nobody asked. """ if not thread_root_id: return {} - try: - root = int(thread_root_id) - except ValueError: + root = await self._resolve_root(channel_id, thread_root_id) + if root is None: raise RichContentFailed( f"Cannot publish to Telegram chat {channel_id}: {thread_root_id!r} " - "is not a topic or message id, so there is no conversation this " - "belongs to.", + "is not a topic or a message in it, so there is no conversation " + "this belongs to.", text=text, - ) from None - if await self._is_forum(channel_id): - return {"message_thread_id": root} + ) + if root.is_topic: + return {"message_thread_id": root.id} return { "reply_parameters": ReplyParameters( - message_id=root, allow_sending_without_reply=False + message_id=root.id, allow_sending_without_reply=False ) } @@ -3058,13 +3108,24 @@ async def _topic_kwargs( ) -> dict[str, Any]: """The forum topic to address, where the root names one. - Outside a forum the root is a message rather than a topic, and there is - nothing but the chat to aim at.""" - if not thread_root_id or not thread_root_id.isdigit(): + A chat action has no target finer than a topic, so a root naming a + message leaves nothing but the chat to aim at β€” which is every root + outside a forum, and a reference to a specific message inside one. + """ + if not thread_root_id: + return {} + root = await self._resolve_root(channel_id, thread_root_id) + if root is None: + logger.warning( + "Signalling to the whole of Telegram chat %s: thread root %s " + "names nothing it can address.", + channel_id, + thread_root_id, + ) return {} - if not await self._is_forum(channel_id): + if not root.is_topic: return {} - return {"message_thread_id": int(thread_root_id)} + return {"message_thread_id": root.id} @staticmethod def _is_photo(mimetype: str, size: int) -> bool: @@ -3118,14 +3179,17 @@ async def _send_text( head of the run.""" bot = self._require_bot() anchor = await self._anchor_kwargs(channel_id, thread_root_id) - # A topic is where the message lives, so every chunk carries it or the - # tail of a long answer lands in General. A reply target is a pointer - # at one message, and repeating it on each chunk would quote the same - # message several times over. - topic = "message_thread_id" in anchor + # In a forum the anchor is what keeps the run together: a chunk without + # one lands in General, so the tail of a long answer would be read by + # people who never saw its head. Everywhere else the anchor is a pointer + # at one message and repeating it quotes that message once per chunk, + # which is noise rather than a misdelivery β€” so it goes on the first + # only. The cost of the forum rule is that same repeated quote when the + # anchor is a reply rather than a topic, which is the better trade. + every_chunk = bool(anchor) and await self._is_forum(channel_id) first_ref: str | None = None for index, chunk in enumerate(chunk_message(body)): - kwargs = anchor if topic or index == 0 else {} + kwargs = anchor if every_chunk or index == 0 else {} sent = await self._send_chunk(bot, channel_id, chunk, kwargs) if sent is None: return first_ref diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py index 2c3e7b5f2..4b479281e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py @@ -859,6 +859,17 @@ def test_a_threaded_reply_is_anchored_to_its_root() -> None: assert params.allow_sending_without_reply is True +def test_a_reply_anchored_to_a_stored_message_reference_still_quotes_it() -> None: + # `chat:message` is how this platform's own refs are spelled β€” it is what + # `send_message` returns and what the message map keeps β€” so a root read + # back out of storage arrives in that form rather than as a bare number. + adapter = _adapter() + + _run(adapter.send_message(str(CHAT_ID), "scout", "in thread", f"{CHAT_ID}:88")) + + assert _bot(adapter).messages[0]["reply_parameters"].message_id == 88 + + def test_a_body_over_the_cap_is_split_rather_than_rejected() -> None: adapter = _adapter() body = "\n".join(["x" * 200] * 60) @@ -885,6 +896,22 @@ def test_only_the_first_chunk_replies_into_the_thread() -> None: assert all("reply_parameters" not in m for m in sent[1:]) +def test_every_chunk_of_a_forum_reply_stays_in_the_topic() -> None: + # A reply anchor in a forum is the one case where the two rules collide: + # dropping it after the first chunk is what keeps the quote tidy, and it is + # also what drops the tail of the answer into General, away from the people + # reading the topic. Re-quoting is the lesser cost. + adapter = _adapter() + _bot(adapter).chat.is_forum = True + body = "\n".join(["x" * 200] * 60) + + _run(adapter.send_message(str(CHAT_ID), "scout", body, f"{CHAT_ID}:88")) + + sent = _bot(adapter).messages + assert len(sent) > 1 + assert all(m["reply_parameters"].message_id == 88 for m in sent) + + def test_markup_telegram_rejects_is_resent_as_plain_text() -> None: # Losing the message is the one outcome that is not acceptable. adapter = _adapter() diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index b2a4646cf..70b512f73 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -302,6 +302,58 @@ async def test_a_card_in_an_ordinary_group_replies_to_what_was_asked() -> None: assert _posted(adapter)["reply_parameters"].message_id == int(TOPIC_ID) +async def test_a_card_anchored_to_a_stored_message_reference_replies_to_it() -> None: + """The form the publication seam actually passes, which is not the form + inbound records. + + A card's root is resolved through the message map, and what that stores is + this platform's own reference to a message β€” `chat:message`, the same + string `post_rich` hands back. Read as a bare number it is not one, so + every card raised in an ordinary Telegram chat was refused before it was + drawn and only ever reached the Console. + """ + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), f"{CHANNEL}:75") + + params = _posted(adapter)["reply_parameters"] + assert params.message_id == 75 + # A card that detaches is the agent's question put to the whole chat. + assert params.allow_sending_without_reply is False + + +async def test_a_message_reference_in_a_forum_is_a_reply_rather_than_a_topic() -> None: + """A composite reference names one message, so its number is a message id + in a forum too. Passed as `message_thread_id` it would name whichever topic + happens to hold that number β€” a different room of people.""" + adapter = _adapter() + _forum(adapter) + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), f"{CHANNEL}:75") + + assert "message_thread_id" not in _posted(adapter) + assert _posted(adapter)["reply_parameters"].message_id == 75 + + +async def test_a_card_rooted_in_another_chat_is_refused() -> None: + """Replying to message 75 of some other chat would either fail or quote + this chat's message 75, which is a different conversation. Neither is the + exchange that raised the request.""" + adapter = _adapter() + + with pytest.raises(RichContentFailed): + await adapter.post_rich(CHANNEL, "my-agent", await _card(), "-1009999999:75") + assert _bot(adapter).messages == [] + + +async def test_a_root_that_names_no_number_at_all_is_still_refused() -> None: + adapter = _adapter() + + with pytest.raises(RichContentFailed): + await adapter.post_rich(CHANNEL, "my-agent", await _card(), "sw_abc123") + assert _bot(adapter).messages == [] + + async def test_the_chat_is_asked_once_rather_than_on_every_publication() -> None: adapter = _adapter() calls: list[Any] = [] From 74160f1d9193d94f699cc035651f35c68e2fc254 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 12:46:53 +0100 Subject: [PATCH 021/120] Make a forum's reply anchor mandatory, and drop a nudge it cannot place Repeating an anchor on every chunk does not hold the destination if the anchor is optional. In a forum the reply target is also the only thing naming the topic, so `allow_sending_without_reply=True` is permission to deliver into General, and that permission travels on every repeated copy of it. A target deleted before or during a run therefore scattered the answer in front of the whole group instead of the people in the conversation. The flag is now set from the chat: required in a forum, where the send fails instead, and still permissive outside one, where detaching costs the quote and not the audience. The unformatted retry in `_send_chunk` re-sends with the same kwargs, so it cannot detach either. `_topic_kwargs` was described in the previous handoff as fixed and was not: a same-chat message reference resolves to a message, so `notify_working` went on raising a chat-wide typing indicator, seen by a whole forum that did not ask for it while the people who did saw nothing. No topic id can be recovered from a message reference, and the number in one would name whichever topic happens to hold it, so the nudge is now dropped rather than misplaced. It is pure best effort, it expires in about five seconds, and the status that follows carries the real state. Outside a forum the chat is still the right destination. Seven tests: the mandatory flag on every chunk, the ordinary-chat policy left alone, a target already gone before the first chunk where the retry must keep its anchor, a target deleted mid-run which must truncate rather than scatter, the attachment caller of the same helper, and the nudge dropped in a forum but still sent in an ordinary chat. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/telegram/adapter.py | 69 +++++++---- .../collaboration/test_telegram_adapter.py | 117 ++++++++++++++++++ 2 files changed, 164 insertions(+), 22 deletions(-) diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 87d6be76c..484effa5f 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -1912,13 +1912,17 @@ async def notify_working( thread root has here. Outside a forum the root is a message to reply to and there is nothing to send an action to but the chat, so it is not passed on: `message_thread_id` set to a reply target would aim the - nudge at a topic that is not one. + nudge at a topic that is not one. A forum whose topic cannot be + located gets no nudge at all rather than one in General. """ + topic = await self._topic_kwargs(channel_id, thread_root_id) + if topic is None: + return try: await self._require_bot().send_chat_action( chat_id=self._chat_id(channel_id), action=ChatAction.TYPING, - **await self._topic_kwargs(channel_id, thread_root_id), + **topic, ) except Exception as error: logger.warning( @@ -3044,9 +3048,17 @@ async def _anchor_kwargs( card asked for in one topic would be put to the whole group instead. Which of the two a root names is `_resolve_root`'s question. - A reply target that has since been deleted does not stop the send. - Detaching there costs the quote, not the audience: it is the same chat - either way, and a reply nobody can trace back beats no message at all. + Outside a forum, a reply target that has since been deleted does not + stop the send. Detaching there costs the quote, not the audience: it is + the same chat either way, and a reply nobody can trace back beats no + message at all. + + Inside one it is the opposite, because a reply target is also the only + thing naming the topic. Permitting the send without it is permitting it + into General β€” in front of the whole group rather than the people in + the conversation β€” so the anchor is required and the send fails + instead. Repeating an optional anchor on each chunk would not have + prevented that: the permission travels with every copy of it. """ if not thread_root_id: return {} @@ -3062,7 +3074,8 @@ async def _anchor_kwargs( return {"message_thread_id": root.id} return { "reply_parameters": ReplyParameters( - message_id=root.id, allow_sending_without_reply=True + message_id=root.id, + allow_sending_without_reply=not await self._is_forum(channel_id), ) } @@ -3105,27 +3118,36 @@ async def _publication_anchor( async def _topic_kwargs( self, channel_id: str, thread_root_id: str | None - ) -> dict[str, Any]: - """The forum topic to address, where the root names one. - - A chat action has no target finer than a topic, so a root naming a - message leaves nothing but the chat to aim at β€” which is every root - outside a forum, and a reference to a specific message inside one. + ) -> dict[str, Any] | None: + """The forum topic to signal in, or None to signal nowhere. + + A chat action has no target finer than a topic. Outside a forum there + are no topics, so the chat is the right and only destination and an + empty mapping says so. + + Inside one, a root that names a message rather than a topic leaves this + unable to locate the conversation β€” and a typing indicator raised in + General is shown to a whole group who did not ask for it, while the + people who did see nothing. There is no topic id to be had: the number + in a message reference is a message, and guessing from it would aim at + whichever topic happens to hold it. So the nudge is dropped. It is the + one signal here that is pure best effort, expiring in about five + seconds, and the status that follows carries the real state. """ if not thread_root_id: return {} root = await self._resolve_root(channel_id, thread_root_id) - if root is None: - logger.warning( - "Signalling to the whole of Telegram chat %s: thread root %s " - "names nothing it can address.", - channel_id, - thread_root_id, - ) - return {} - if not root.is_topic: + if root is not None and root.is_topic: + return {"message_thread_id": root.id} + if not await self._is_forum(channel_id): return {} - return {"message_thread_id": root.id} + logger.warning( + "Not signalling in Telegram chat %s: thread root %s does not name a " + "topic there, and the whole forum is the wrong audience.", + channel_id, + thread_root_id, + ) + return None @staticmethod def _is_photo(mimetype: str, size: int) -> bool: @@ -3186,6 +3208,9 @@ async def _send_text( # which is noise rather than a misdelivery β€” so it goes on the first # only. The cost of the forum rule is that same repeated quote when the # anchor is a reply rather than a topic, which is the better trade. + # Repetition alone is not what holds the destination: a reply anchor in + # a forum is also mandatory, so a target deleted mid-run fails the rest + # of the send instead of scattering it into General. every_chunk = bool(anchor) and await self._is_forum(channel_id) first_ref: str | None = None for index, chunk in enumerate(chunk_message(body)): diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py index 4b479281e..e0f7f49d0 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py @@ -912,6 +912,123 @@ def test_every_chunk_of_a_forum_reply_stays_in_the_topic() -> None: assert all(m["reply_parameters"].message_id == 88 for m in sent) +def test_a_forum_reply_anchor_is_mandatory_because_it_is_the_only_topic() -> None: + # Repeating the anchor is not what holds the destination. In a forum the + # reply target is also the only thing naming the topic, so permitting the + # send without it permits it into General β€” on every chunk that carries the + # permission, which after the repeat rule is all of them. + adapter = _adapter() + _bot(adapter).chat.is_forum = True + body = "\n".join(["x" * 200] * 60) + + _run(adapter.send_message(str(CHAT_ID), "scout", body, f"{CHAT_ID}:88")) + + sent = _bot(adapter).messages + assert len(sent) > 1 + assert all(m["reply_parameters"].allow_sending_without_reply is False for m in sent) + + +def test_an_ordinary_chat_still_detaches_rather_than_lose_the_message() -> None: + # The forum rule must not become the general one: outside a forum there is + # no topic to lose, so a deleted target costs the quote and nothing else. + adapter = _adapter() + + _run(adapter.send_message(str(CHAT_ID), "scout", "in thread", f"{CHAT_ID}:88")) + + assert ( + _bot(adapter).messages[0]["reply_parameters"].allow_sending_without_reply + is True + ) + + +def test_a_forum_reply_whose_target_is_already_gone_keeps_its_anchor_on_retry() -> None: + # The unformatted retry re-sends with the same kwargs. If it ever dropped + # them, a refused reply would come back as a successful send into General β€” + # the exact outcome the mandatory anchor exists to prevent. + adapter = _adapter() + _bot(adapter).chat.is_forum = True + _bot(adapter).send_message_error = BadRequest("message to be replied not found") + + _run(adapter.send_message(str(CHAT_ID), "scout", "hello", f"{CHAT_ID}:88")) + + sent = _bot(adapter).messages + assert len(sent) == 1 + assert sent[0]["reply_parameters"].message_id == 88 + assert sent[0]["reply_parameters"].allow_sending_without_reply is False + + +def test_a_forum_target_deleted_mid_run_truncates_rather_than_scatters() -> None: + # Losing the tail of a long answer is a visible failure with an error in + # the log. Delivering it to General is an invisible one, read by the whole + # group instead of the topic. + adapter = _adapter() + _bot(adapter).chat.is_forum = True + bot = _bot(adapter) + original = bot.send_message + calls: list[int] = [] + + async def gone_after_the_first(**kwargs: Any) -> Any: + calls.append(1) + if len(calls) > 1: + raise BadRequest("message to be replied not found") + return await original(**kwargs) + + bot.send_message = gone_after_the_first # type: ignore[method-assign] + body = "\n".join(["x" * 200] * 60) + + _run(adapter.send_message(str(CHAT_ID), "scout", body, f"{CHAT_ID}:88")) + + sent = bot.messages + assert len(sent) == 1 + assert all("reply_parameters" in m for m in sent) + + +def test_a_forum_attachment_carries_the_same_mandatory_anchor() -> None: + # send_attachment shares the helper, and one file in the wrong topic is the + # same misdelivery as one message in it. + adapter = _adapter() + _bot(adapter).chat.is_forum = True + + _run( + adapter.send_attachment( + str(CHAT_ID), + "scout", + "note.txt", + "text/plain", + b"hello", + caption="here", + thread_root_id=f"{CHAT_ID}:88", + ) + ) + + params = _bot(adapter).documents[0]["reply_parameters"] + assert params.message_id == 88 + assert params.allow_sending_without_reply is False + + +def test_a_forum_nudge_with_no_locatable_topic_is_dropped_not_widened() -> None: + # A typing indicator in General is shown to a whole group who did not ask + # for it, while the people who did see nothing. There is no topic id to be + # had from a message reference, so the nudge is simply not sent. + adapter = _adapter() + _bot(adapter).chat.is_forum = True + + _run(adapter.notify_working(str(CHAT_ID), "scout", f"{CHAT_ID}:88")) + + assert _bot(adapter).actions == [] + + +def test_an_ordinary_chat_still_gets_its_nudge() -> None: + # Outside a forum the chat is the only destination there is, so sending to + # it is right rather than a widening. + adapter = _adapter() + + _run(adapter.notify_working(str(CHAT_ID), "scout", f"{CHAT_ID}:88")) + + assert len(_bot(adapter).actions) == 1 + assert "message_thread_id" not in _bot(adapter).actions[0] + + def test_markup_telegram_rejects_is_resent_as_plain_text() -> None: # Losing the message is the one outcome that is not acceptable. adapter = _adapter() From 30638eb5aff8b3df2147bd0d50808a03e93b7a1d Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 13:48:12 +0100 Subject: [PATCH 022/120] Teams: keep a conversation's writers together, and its region with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four residuals from the Teams publication-address work, plus a Telegram regression from the commit before last. The conversation write registry asked `asyncio.Lock.locked()` whether a lock was in use. That is False for the whole window between a release and the woken waiter resuming, so a lock with a writer queued behind it looks idle and can be evicted; the next writer then gets a brand-new lock and the two run side by side in one conversation. The registry now counts its own users, holder and queue alike, and `_writes_to` is the context manager that keeps the count. A notice about a card that could not be updated went to the thread root, which Teams rebuilds into an address in whatever region the process last heard from. Where the publication reference is there it names the conversation Teams confirmed, service URL included, and it now wins. The choice is the adapter's: the base port keeps today's behaviour, which is right for the other four platforms. A card posted into a carried publication reference took the conversation from the reference and the region from the channel, sending a card in one region to another and writing the wrong region into the reference that comes back. Both halves of the reference are kept, as `_relay` already did. A 404 at a rebuilt address said the status was gone. It says nothing of the kind β€” the address may simply be wrong β€” so it leaves as `RichContentFailed` with the cleanup outstanding, and the port's contract no longer claims more than a refusal arriving as the port's own error. Telegram: working out where a nudge belongs needs a getChat on a cold cache, and hoisting that lookup out of the try turned a best-effort signal into a precondition of the status that follows. A chat that cannot be read now costs the nudge and nothing else. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 26 +++- .../bridges/collaboration/session/outbound.py | 10 +- .../bridges/collaboration/teams/adapter.py | 108 +++++++++++---- .../bridges/collaboration/telegram/adapter.py | 10 +- .../collaboration/test_teams_sdk_only.py | 124 +++++++++++++++++- .../collaboration/test_telegram_adapter.py | 17 ++- 6 files changed, 255 insertions(+), 40 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 7dcdd3417..092e1f1a9 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -713,7 +713,14 @@ async def update_rich( `update_message` swallows its own errors by design, for the runtime-status paths that depend on that β€” but not all of them, so this catches broadly rather than trusting the convention: whichever it does, - a caller of `update_rich` sees `RichContentFailed` or nothing. + a caller of *this* wrapper sees `RichContentFailed` or nothing. + + That is the default, not the port's whole contract. An adapter with its + own failure policy overrides this, and the overrides deliberately let a + throttle and an unknown outcome through unwrapped, because a caller has + to be able to tell "refused" from "come back to this". What the port + does promise is that a *refusal* arrives as `RichContentFailed`, never + as a platform client's own exception. `agent_name` is the same name `post_rich` was given, and is here for the platform that writes it into the body: one bot identity means the @@ -739,6 +746,23 @@ async def update_rich( text=text, ) from error + def notice_address(self, message_ref: str, thread_root_id: str | None) -> str: + """Where a notice *about* a publication has to be said. + + Beside the publication, so the people who can see the stale card are + the people who read the correction: the thread it went into where there + was one, and otherwise the publication itself, which is then the root + of its own conversation. + + Not the publication's id where a thread exists β€” on a platform that + addresses a reply by its conversation rather than by the message, a + publication that is itself a reply names no conversation, and a notice + nobody can see is worse than the stale card it is about. An adapter + whose own reference carries a confirmed conversation overrides this to + prefer it: a rebuilt address is the weaker of the two. + """ + return thread_root_id or message_ref + def rich_fallback_text(self, content: RichContent) -> str: """The neutral text form of `content`, for `post_rich` / `update_rich`'s base and for any adapter that wants the same fallback rather than its diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index cf2b3ff40..c7af3eae2 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -1651,15 +1651,7 @@ async def refresh( f"The card for request {post.handle} above could not be updated, " f"so it may still be offering buttons that no longer " f"work.\n{error.text}", - # The conversation the card is in, which is the thread it - # was posted into where there was one. Not the card's own - # id: where a platform addresses a reply by its - # conversation rather than by the message β€” Teams β€” a card - # that is itself a reply names no conversation, and a - # notice about a card people cannot see is worse than the - # stale card. Where the card opened the conversation, it - # is the root, and that is the unchanged behaviour. - post.thread_id or post.external_post_id, + self._adapter.notice_address(post.external_post_id, post.thread_id), ) self._reported_edit_failures[post.token] = state raise diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index fae04d1b3..15b387054 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -9,7 +9,8 @@ import secrets import time from collections import OrderedDict -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta from typing import Any, ClassVar @@ -114,7 +115,7 @@ """How many conversations' write locks one adapter keeps. Enough for every conversation a bridge is realistically mid-turn in at once. -Only an unheld lock is ever dropped β€” see `_writes_to`. +Only a lock nobody is using is ever dropped β€” see `_writes_to`. """ _PUBLICATION_MARK = "teams1" @@ -200,6 +201,20 @@ def in_a_channel(self) -> bool: return self.conversation_id != self.channel_id +@dataclass +class _ConversationWrites: + """One conversation's write lock, and how many writers are on it. + + `users` counts everyone between asking for the lock and finishing with it, + holder and queue alike, because the lock itself cannot be asked: it reports + unheld from the moment it is released until the waiter it woke gets a turn + to run. Eviction reads this instead. + """ + + lock: asyncio.Lock + users: int + + class _TeamsMarkup(Markup): """Markdown as an Adaptive Card TextBlock actually parses it. @@ -646,7 +661,7 @@ def __init__(self, *, config: TeamsConnectionConfig) -> None: # message id -> (service_url, conversation_id) for later edit/delete. self._sent: dict[str, tuple[str, str]] = {} # conversation id -> the lock ordering this bridge's writes to it. - self._conversation_writes: OrderedDict[str, asyncio.Lock] = OrderedDict() + self._conversation_writes: OrderedDict[str, _ConversationWrites] = OrderedDict() # Inbound de-duplication β€” the Bot Framework and Graph capture paths can # both deliver the same channel message, keyed on the Teams message id. self._seen: OrderedDict[str, None] = OrderedDict() @@ -955,8 +970,9 @@ def _reply_conversation( return None return self._thread_conversation(channel_id, thread_root_id) - def _writes_to(self, conversation_id: str) -> asyncio.Lock: - """The lock that orders this bridge's own writes to one conversation. + @asynccontextmanager + async def _writes_to(self, conversation_id: str) -> AsyncIterator[None]: + """Order this bridge's own writes to one conversation. Teams answers an edit to an activity it is still processing with 412, and two publishers redrawing in one conversation generate those against @@ -964,28 +980,38 @@ def _writes_to(self, conversation_id: str) -> asyncio.Lock: turns the race into a queue; it says nothing about writers in another process, which is what the 412 handling is still for. - Bounded, and only ever forgetting a lock nobody holds β€” dropping a held - one would hand the next writer a fresh lock and quietly undo the - ordering it asked for. + Bounded, and the registry counts its own users rather than asking the + lock whether it is held. `asyncio.Lock.locked()` is False for the whole + window between a release and the woken waiter resuming, so a lock with + someone queued behind it looks idle; dropping it there would hand the + next writer a brand-new lock and run the two side by side β€” which is + the ordering this exists to provide, quietly withdrawn under load. + An entry with a user is never evicted, so everyone who asks for one + conversation holds the same lock. """ - lock = self._conversation_writes.get(conversation_id) - if lock is None: - lock = asyncio.Lock() - self._conversation_writes[conversation_id] = lock + entry = self._conversation_writes.get(conversation_id) + if entry is None: + entry = _ConversationWrites(asyncio.Lock(), 0) + self._conversation_writes[conversation_id] = entry self._conversation_writes.move_to_end(conversation_id) + entry.users += 1 while len(self._conversation_writes) > _MAX_CONVERSATION_LOCKS: - idle = next( + unused = next( ( key - for key, held in self._conversation_writes.items() - if key != conversation_id and not held.locked() + for key, waiting in self._conversation_writes.items() + if not waiting.users ), None, ) - if idle is None: + if unused is None: break - del self._conversation_writes[idle] - return lock + del self._conversation_writes[unused] + try: + async with entry.lock: + yield + finally: + entry.users -= 1 async def _channel_layout(self, channel_id: str) -> str | None: """The channel's conversation layout as Graph reports it, or None. @@ -1400,6 +1426,19 @@ def _render_rich(self, content: RichContent) -> str: else None, ) + def notice_address(self, message_ref: str, thread_root_id: str | None) -> str: + """The reference wins over the thread, where the publication has one. + + Both name the same conversation, but the reference names the one Teams + confirmed, service URL included, while the thread is a root this + rebuilds into `channel;messageid=root` in whatever region the process + last heard from. A notice about a card in another region would go to + the region this happens to know. + """ + if _read_publication_ref(message_ref) is not None: + return message_ref + return thread_root_id or message_ref + def _publication_conversation(self, channel_id: str, root_id: str | None) -> str: """Where a *new* publication goes, from durable facts only. @@ -1515,7 +1554,12 @@ async def post_rich( `thread_root_id` is used as given. `_post_to_answer_in`, which the relay uses to steer an untied reply into whatever post the channel last spoke in, is not consulted: a publication has a durable address to keep - and a guess is not one. + and a guess is not one. Where the root is itself a publication + reference, both halves of it are kept β€” the conversation *and* the + service that holds it. Taking the conversation and the region from + different places sends a card in one region to another, and writes the + wrong region into the reference that comes back, so the next redraw + repeats it. What comes back is that address rather than a message id β€” see `_publication_ref`. It is what the caller stores, and the only thing @@ -1527,7 +1571,8 @@ async def post_rich( raise RichContentFailed( "Teams is not connected, so the publication was not sent.", text=text ) - service_url = self._service_url_for(channel_id) + carried = _read_publication_ref(thread_root_id) if thread_root_id else None + service_url = carried[0] if carried else self._service_url_for(channel_id) activity = await self._message_activity(agent_name, text) opening = self._is_channel(channel_id) and thread_root_id is None # A new post has no conversation to queue behind yet, so its writes are @@ -1624,7 +1669,12 @@ async def _retire_rich( this exists to do is already done, and editing instead would ask Teams to rewrite a message that does not exist and fail on that too, every cycle, forever. At an address this rebuilt, the same 404 may only mean - the address was wrong, which is not an outcome, so it is raised. + the address was wrong, so it says nothing about whether the status is + still showing and the cleanup must stay outstanding. It is a refusal + all the same, and it leaves through the port as one: `RichContentFailed` + rather than the connector's own exception, which no caller of this port + is expecting. Logged at error, because a status may be sitting in the + conversation with no address left that Teams has confirmed. """ if await self._uses_post_layout( address.channel_id, is_channel=address.in_a_channel @@ -1642,9 +1692,21 @@ async def _retire_rich( raise self._throttled(error, text) from error except BotConnectorConflict as error: raise self._conflicted(error, text) from error - except BotConnectorGone: + except BotConnectorGone as error: if not address.trusted: - raise + logger.error( + "Teams has no activity %s in conversation %s, and that " + "conversation was rebuilt rather than confirmed, so " + "whether the finished status is still showing is unknown " + "and there is no other address to try.", + address.activity_id, + address.conversation_id, + ) + raise RichContentFailed( + f"Teams has no activity {address.activity_id} at the " + f"rebuilt conversation {address.conversation_id}: {error}", + text=text, + ) from error logger.info( "Teams has no activity %s in conversation %s; the finished " "status is already gone, so its cleanup is complete.", diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 484effa5f..2ee9e8999 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -1913,12 +1913,14 @@ async def notify_working( to and there is nothing to send an action to but the chat, so it is not passed on: `message_thread_id` set to a reply target would aim the nudge at a topic that is not one. A forum whose topic cannot be - located gets no nudge at all rather than one in General. + located gets no nudge at all rather than one in General, and neither + does one whose chat cannot be read: working out where this belongs is + part of the best effort, not a precondition of the status that follows. """ - topic = await self._topic_kwargs(channel_id, thread_root_id) - if topic is None: - return try: + topic = await self._topic_kwargs(channel_id, thread_root_id) + if topic is None: + return await self._require_bot().send_chat_action( chat_id=self._chat_id(channel_id), action=ChatAction.TYPING, diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py index 34211c9a0..c91bb67ed 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py @@ -17,6 +17,7 @@ import asyncio import logging from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -27,11 +28,13 @@ RichContentThrottled, TurnActivity, ) +from switch_core.bridges.collaboration.session.outbound import SessionRequestCards from switch_core.bridges.collaboration.session.renderers import RequestReference from switch_core.bridges.collaboration.session.transport import ( FixtureEventSource, project, ) +from switch_core.bridges.collaboration.teams import adapter as teams_adapter from switch_core.bridges.collaboration.teams.adapter import ( TeamsAdapter, _publication_ref, @@ -558,11 +561,17 @@ def test_confirmed_absence_finishes_the_cleanup_rather_than_editing_nothing() -> def test_absence_at_an_address_this_rebuilt_is_not_taken_as_an_outcome() -> None: """Without a stored address the 404 may only mean the address was wrong, - and a status wrongly recorded as removed is one that never goes.""" + and a status wrongly recorded as removed is one that never goes. + + It reaches the caller as `RichContentFailed`, which is what this port + promises a refusal looks like, rather than as the connector's own exception + β€” `_edit` catches the former and nothing catches the latter. The cleanup + stays outstanding either way; that is the property, not any claim about + what is still on screen.""" adapter, _connector = _teams("chat") _connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) - with pytest.raises(BotConnectorGone): + with pytest.raises(RichContentFailed): _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) assert _connector.updates == [] @@ -583,6 +592,64 @@ def test_the_conversation_the_server_returned_is_the_one_edited() -> None: assert connector.updates[0]["conversation_id"] == connector.conversation +def test_a_publication_into_a_carried_reference_keeps_its_region() -> None: + """The reference names a conversation *and* the service holding it. Taking + the conversation from it and the region from whatever this process last + heard from sends the card to the wrong region β€” and writes that region into + the reference it returns, so every later redraw repeats it.""" + adapter, connector = _teams() + adapter._default_service_url = "https://smba.example/other-region/" + carried = _publication_ref(SERVICE_URL, "19:confirmed@thread.tacv2", "card-1") + + ref = _run(adapter.post_rich(CHANNEL, AGENT, _activity(), carried)) + + assert connector.sends[0]["service_url"] == SERVICE_URL + assert connector.sends[0]["conversation_id"] == "19:confirmed@thread.tacv2" + assert ref.startswith( + _publication_ref(SERVICE_URL, "19:confirmed@thread.tacv2", "") + ) + + +def test_a_notice_about_a_card_goes_to_the_address_teams_confirmed() -> None: + """A row can hold both a raw thread root and the reference Teams gave back. + The edit already used the reference; the notice about that edit failing was + still rebuilding `channel;messageid=root` in the current default region, so + the correction could land somewhere the card is not.""" + adapter, _connector = _teams() + carried = _publication_ref(SERVICE_URL, "19:confirmed@thread.tacv2", "card-1") + + assert adapter.notice_address(carried, ROOT) == carried + assert adapter.notice_address("MSG1", ROOT) == ROOT + assert adapter.notice_address("MSG1", None) == "MSG1" + + connector = _Connector() + adapter._connector = connector # type: ignore[assignment] + adapter._default_service_url = "https://smba.example/other-region/" + connector.fail_update = BotConnectorRefused("no edit", status=403, retry_after=None) + request = _run(_card()).request + post = SimpleNamespace( + token="tok", + handle="R7", + external_channel_id=CHANNEL, + external_post_id=carried, + thread_id=ROOT, + request_id=request.request_id, + ) + cards = SessionRequestCards( + adapter, + bridge_id="bridge-1", + surface="teams", + posts=None, # type: ignore[arg-type] + session_factory=None, # type: ignore[arg-type] + ) + + with pytest.raises(RichContentFailed): + _run(cards.refresh(post, request, agent_name=AGENT)) # type: ignore[arg-type] + + assert connector.sends[0]["service_url"] == SERVICE_URL + assert connector.sends[0]["conversation_id"] == "19:confirmed@thread.tacv2" + + def test_a_chat_redraw_after_a_restart_does_not_become_a_channel_thread() -> None: """`_channel_type` empties on restart and an unknown id reads as a channel, so a chat's card was edited at `chat;messageid=card` β€” a conversation that @@ -721,6 +788,59 @@ async def both() -> None: assert overlapped is False +def test_a_conversation_with_a_writer_queued_behind_it_is_not_evicted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The registry is bounded, and a lock it drops has to be one nobody is + using. `locked()` cannot answer that: it reads False from the moment a lock + is released until the waiter it woke gets a turn to run, so a conversation + with someone queued on it looks idle for exactly long enough to be thrown + away. The next writer then takes a brand-new lock and runs beside the + waiter, which is the ordering the lock was there to provide.""" + monkeypatch.setattr(teams_adapter, "_MAX_CONVERSATION_LOCKS", 1) + inside = 0 + overlapped = False + + async def scenario() -> None: + nonlocal inside, overlapped + adapter, _connector = _teams() + holding = asyncio.Event() + queue_formed = asyncio.Event() + + async def write(conversation: str) -> None: + nonlocal inside, overlapped + async with adapter._writes_to(conversation): + inside += 1 + overlapped = overlapped or inside > 1 + for _ in range(3): + await asyncio.sleep(0) + inside -= 1 + + async def hold_then_write_elsewhere() -> None: + async with adapter._writes_to("conversation-A"): + holding.set() + await queue_formed.wait() + # Released, and the waiter is awake but has not resumed: this is + # the whole window the bug lived in. Writing elsewhere now is what + # a bounded registry does on a busy bridge. + async with adapter._writes_to("conversation-B"): + pass + + first = asyncio.create_task(hold_then_write_elsewhere()) + await holding.wait() + queued = asyncio.create_task(write("conversation-A")) + await asyncio.sleep(0) + queue_formed.set() + await asyncio.sleep(0) + await first + later = asyncio.create_task(write("conversation-A")) + await asyncio.gather(queued, later) + + _run(scenario()) + + assert overlapped is False + + # ── A mention that cannot be made is said out loud ─────────────────────────── diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py index e0f7f49d0..ef1371ede 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest -from telegram.error import BadRequest, Conflict, TelegramError +from telegram.error import BadRequest, Conflict, TelegramError, TimedOut from switch_core.bridges.collaboration.models import ( InboundCommand, @@ -179,6 +179,7 @@ def __init__(self) -> None: self.chat: _FakeChat = _FakeChat() # What getChatMember reports for the bot itself in `chat`. self.member_status = "administrator" + self.get_chat_error: Exception | None = None self.get_chat_member_error: Exception | None = None self._next_id = 500 # Set to an exception to make the next send_message raise it once. @@ -255,6 +256,8 @@ async def set_my_commands(self, commands: Any) -> None: self.published_commands = list(commands) async def get_chat(self, chat_id: Any) -> _FakeChat: + if self.get_chat_error is not None: + raise self.get_chat_error return self.chat async def get_chat_member(self, **kwargs: Any) -> _FakeMember: @@ -1029,6 +1032,18 @@ def test_an_ordinary_chat_still_gets_its_nudge() -> None: assert "message_thread_id" not in _bot(adapter).actions[0] +def test_a_chat_that_cannot_be_read_costs_the_nudge_and_nothing_else() -> None: + # Placing the nudge needs a getChat on a cold cache, and that call can time + # out. The nudge is worth five seconds; the status it precedes is worth the + # turn, so a failure to place one must not take the other down with it. + adapter = _adapter() + _bot(adapter).get_chat_error = TimedOut() + + _run(adapter.notify_working(str(CHAT_ID), "scout", f"{CHAT_ID}:88")) + + assert _bot(adapter).actions == [] + + def test_markup_telegram_rejects_is_resent_as_plain_text() -> None: # Losing the message is the one outcome that is not acceptable. adapter = _adapter() From 4c11c553e0b83f06e1c7b814a592b43a553ce996 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 13:57:14 +0100 Subject: [PATCH 023/120] Name a reaction's evidence by the ask, not only by the turn that made it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A removal is issued on behalf of the turns expecting the mark, and clears their expectations when the platform answers. A turn in that set can ask for the mark again while the answer is in flight β€” the publisher allows a queued receipt to be replaced by the real turn under the same command key, and a publisher that takes the message up with no claim of its own asks afresh β€” and the reaction the second ask puts there is as new as any other turn's. Clearing by turn loses it, and the turn then finishes clean with the πŸ‘€ still on the message. Nothing serialises the two: a removal holds its own turn's record lock and no other's. So an expectation is now stamped with the attempt that recorded it, in memory and in the journal, and a removal clears a holder only while it still carries the ask the removal went out against. A refusal rolls its own attempt back to what stood before it rather than dropping the expectation outright, which is the same rule read from the other end: an answer speaks for the ask that provoked it and for no other. An expectation written before the asks were stamped carries no stamp and is named by the empty one, until the turn asks again. The regression drives the window through the real publisher and a real database: a queued receipt claims the mark, another turn ends last and its removal is answered late, and the queued command becomes its real turn and marks afresh in the gap. It fails without the fix with the mark visible and the turn reporting a clean finish. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/activity_journal.py | 33 +++- .../bridges/collaboration/session/outbound.py | 169 ++++++++++++------ .../sessions/test_activity_durability.py | 58 ++++++ 3 files changed, 195 insertions(+), 65 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index 634c0f552..fc2c3b887 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -131,14 +131,20 @@ async def mark_holders( mark: dict[str, str], *, sessions: async_sessionmaker[AsyncSession], - ) -> set[tuple[str, str]]: - """Which turns are expecting this reaction right now. + ) -> set[tuple[str, str, str]]: + """Which turns are expecting this reaction right now, and on what ask. Read immediately before a removal is asked for, so that what the removal later clears is what it was actually removing. A turn that starts expecting the mark after this read has asked for a reaction of its own, and the answer to a request issued before it existed says nothing about that one. + + The ask as well as the turn, because a turn that already had the mark + can ask for it again β€” a restart, a publisher taking the message up + with no claim of its own β€” and the reaction the second ask puts there + is as new as any other turn's. A row written before the asks were + stamped carries no stamp, and is named by the empty one. """ async with sessions() as db: rows = await db.scalars( @@ -148,16 +154,19 @@ async def mark_holders( SessionActivityPost.data.contains({"mark": mark}), ) ) - return {(row.session_id, row.command_id) for row in rows} + return { + (row.session_id, row.command_id, row.data.get("mark_attempt", "")) + for row in rows + } async def forget_mark( self, mark: dict[str, str], *, - holders: set[tuple[str, str]], + holders: set[tuple[str, str, str]], sessions: async_sessionmaker[AsyncSession], ) -> None: - """Erase the expectation of this reaction, for the given turns only. + """Erase the expectation of this reaction, for the given asks only. Called when the platform has taken the mark off. Every holder it was taken off on behalf of loses its expectation together, because they are @@ -166,8 +175,10 @@ async def forget_mark( `holders` rather than all of them, because a removal answers only for the claims that existed when it was issued. Its acknowledgement can - arrive after another publisher has put the mark back for a new turn, - and that turn's mark really is on the message. + arrive after another publisher has put the mark back β€” for a turn of + its own, or for one of these turns asking again β€” and that mark really + is on the message. So a row is cleared only while it still carries the + ask the removal was issued against. """ if not holders: return @@ -180,10 +191,16 @@ async def forget_mark( ) ) for row in rows: - if (row.session_id, row.command_id) not in holders: + held = ( + row.session_id, + row.command_id, + row.data.get("mark_attempt", ""), + ) + if held not in holders: continue data = dict(row.data) data.pop("mark", None) + data.pop("mark_attempt", None) row.data = data await db.commit() diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index c7af3eae2..664100e50 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -107,6 +107,26 @@ class _Anchor: status_state: tuple[str, str, str | None] | None = None +@dataclass(frozen=True) +class _MarkAttempt: + """One request to put the reaction on a message, and what it renewed. + + A turn asks for the mark again whenever a publisher takes the message up + with no holder of its own β€” a restart, or every claim here having been let + go β€” and any of those asks can be the one that puts the reaction there. So + the expectation is identified by the attempt rather than by the turn: + a removal answers for the attempts it was issued against, and an attempt + made while it was in flight is about a reaction added behind it. + + `renewed` is the attempt this one displaced, where the turn already had + grounds. A refusal puts those back: it answers the attempt that provoked + it and says nothing about an addition an earlier one may have made. + """ + + token: str + renewed: str | None + + def _violates(error: IntegrityError, constraint: str) -> bool: """Whether Postgres refused this particular uniqueness. @@ -203,7 +223,7 @@ def __init__( self._marks_publications = getattr(adapter, "carries_publication_marker", False) self._anchors: OrderedDict[tuple[str, str], _Anchor] = OrderedDict() self._thread_turns: dict[tuple[str, str, str], set[tuple[str, str]]] = {} - self._expecting: dict[tuple[str, str, str], set[tuple[str, str]]] = {} + self._expecting: dict[tuple[str, str, str], dict[tuple[str, str], str]] = {} self._attention: OrderedDict[tuple[str, str], tuple[str, str]] = OrderedDict() @property @@ -971,19 +991,21 @@ async def _mark_thread( channel showing an agent still working on something it has finished. Which expectations a removal retracts is settled before it is sent, not - after it is answered. Between the two, another publisher can put the - mark back for a turn of its own, and that turn's expectation is not - this removal's to clear. + after it is answered. Between the two the mark can go back on β€” for a + turn of its own, or for one of the turns the removal was issued + against, asking again β€” and neither of those is this removal's to + clear, which is why an expectation is named by the ask and not only by + the turn that made it. """ if anchor.reaction_ref is None or not getattr( self._adapter, "supports_activity_reactions", False ): return True mark = self._mark_key(anchor) - removing: set[tuple[str, str]] = set() - recorded_here = False + removing: set[tuple[str, str, str]] = set() + attempt: _MarkAttempt | None = None if working: - recorded_here = await self._expect_mark(key, mark) + attempt = await self._expect_mark(key, mark) else: removing = await self._claimants(mark) try: @@ -996,8 +1018,8 @@ async def _mark_thread( ) except ActivityMarkRefused as refusal: if working: - if recorded_here: - await self._retract_claim(key, mark) + if attempt is not None: + await self._retract_attempt(key, mark, attempt) logger.warning("%s The turn goes on without the mark.", refusal) return True if not await self._mark_may_be_there(mark): @@ -1022,7 +1044,7 @@ async def _mark_thread( ) return False if not working: - await self._mark_taken_off(mark, removing) + await self._mark_taken_off(key, mark, removing) return True def _mark_key(self, anchor: _Anchor) -> dict[str, str]: @@ -1041,74 +1063,105 @@ def _mark_key(self, anchor: _Anchor) -> dict[str, str]: "agent_name": anchor.agent_name if self._reactions_per_agent else "", } - async def _expect_mark(self, key: tuple[str, str], mark: dict[str, str]) -> bool: - """Record that this turn's mark may be on the message, before asking. + async def _expect_mark( + self, key: tuple[str, str], mark: dict[str, str] + ) -> _MarkAttempt: + """Record that this attempt's mark may be on the message, before asking. Before, not after, because a request that fails without an answer may still have landed. Written where the answer will be needed: durably when there is a journal, since the turn that eventually takes the mark off may be running in a later process than the turn that put it on. - Recorded per turn rather than once per reaction, so that a retraction - can say which attempt it is retracting. + Recorded against the turn and stamped with the attempt, so that an + answer can say which of the two it is answering: a refusal speaks for + the attempt it was given, and a removal for the attempts that had been + made when it went out. A turn that asks again lands under the same + stamp neither of them can be about. - Returns whether this attempt is the one that recorded the expectation. - A turn asks for the mark again whenever it is republished, so an - addition refused now can be the second attempt against a reaction an - earlier one already put there, or already left in doubt. The refusal - answers the attempt it was given, and grounds this turn already had are - not its to take away. + An expectation written before attempts were stamped carries no stamp, + and is addressed by the empty one until the turn asks again. """ - expecting = self._expecting.setdefault(_mark_id(mark), set()) + expecting = self._expecting.setdefault(_mark_id(mark), {}) record = self._record.get() - already = key in expecting or ( - record is not None and record.data.get("mark") == mark - ) - expecting.add(key) - if record is not None and record.data.get("mark") != mark: + renewed = expecting.get(key) + if renewed is None and record is not None and record.data.get("mark") == mark: + renewed = str(record.data.get("mark_attempt", "")) + attempt = _MarkAttempt(secrets.token_urlsafe(16), renewed) + expecting[key] = attempt.token + if record is not None: record.data["mark"] = mark + record.data["mark_attempt"] = attempt.token await record.save() - return not already + return attempt - async def _retract_claim(self, key: tuple[str, str], mark: dict[str, str]) -> None: - """Drop this turn's expectation, after its own attempt was refused. + async def _retract_attempt( + self, key: tuple[str, str], mark: dict[str, str], attempt: _MarkAttempt + ) -> None: + """Put the expectation back as it was, after this attempt was refused. - Only this turn's, and only where this attempt is what recorded it. A + Back to what the turn had before, which is nothing where this attempt + is what gave it grounds and the attempt it renewed where it is not. A refusal describes the attempt it answers: it says nothing about an addition made earlier β€” by another turn, or by this one before a restart β€” which may well be sitting on the message still. Erasing that as well is how a mark comes to be reported as cleaned up with the πŸ‘€ in plain sight. + + Only where this attempt's stamp is still the one standing. A later + attempt is a claim in its own right, and this refusal is not about it. """ expecting = self._expecting.get(_mark_id(mark)) - if expecting is not None: - expecting.discard(key) - if not expecting: - del self._expecting[_mark_id(mark)] + if expecting is not None and expecting.get(key) == attempt.token: + if attempt.renewed is None: + del expecting[key] + if not expecting: + del self._expecting[_mark_id(mark)] + else: + expecting[key] = attempt.renewed record = self._record.get() - if record is not None and record.data.pop("mark", None) is not None: - await record.save() + if record is None or record.data.get("mark_attempt") != attempt.token: + return + if attempt.renewed is None: + record.data.pop("mark", None) + record.data.pop("mark_attempt", None) + else: + record.data["mark_attempt"] = attempt.renewed + await record.save() async def _mark_taken_off( - self, mark: dict[str, str], holders: set[tuple[str, str]] + self, + key: tuple[str, str], + mark: dict[str, str], + holders: set[tuple[str, str, str]], ) -> None: """Drop the expectations the platform has just answered for. - Every holder the removal was made on behalf of, not only this turn, + Every attempt the removal was made on behalf of, not only this turn's, because they are all talking about the same reaction β€” one left behind would have a later turn on that message reporting a mark that is not - there and never finishing. `holders` is read before the removal is - sent, so a turn that claimed the mark while it was in flight keeps its - claim. + there and never finishing. + + The attempts as they stood when the removal went out, not as they stand + now. A holder that has asked for the mark again since is asking about a + reaction put there behind the removal, which the platform's answer says + nothing about β€” and which really is on the message. """ expecting = self._expecting.get(_mark_id(mark)) if expecting is not None: - expecting -= holders + for session_id, command_id, token in holders: + if expecting.get((session_id, command_id)) == token: + del expecting[(session_id, command_id)] if not expecting: del self._expecting[_mark_id(mark)] record = self._record.get() - if record is not None and record.data.pop("mark", None) is not None: - await record.save() + if ( + record is not None + and (*key, record.data.get("mark_attempt", "")) in holders + ): + if record.data.pop("mark", None) is not None: + record.data.pop("mark_attempt", None) + await record.save() if self._journal is not None: await self._journal.forget_mark( mark, @@ -1116,26 +1169,28 @@ async def _mark_taken_off( sessions=record.sessions if record else self._journal.sessions, ) - async def _claimants(self, mark: dict[str, str]) -> set[tuple[str, str]]: - """The turns expecting this mark, as of now. + async def _claimants(self, mark: dict[str, str]) -> set[tuple[str, str, str]]: + """The attempts expecting this mark, as of now. Both halves of the evidence: what this process remembers claiming, and what any process has written down. Taken together because a publisher with no journal has only the first, and a publisher restarted into one has only the second. - Turns, not attempts, and that is **not** currently enough. A snapshot - of turns can miss a turn renewing its claim between the read and the - answer, so that the answer clears a mark put there after it. The - serialisation that would rule that out does not: a removal's record - lock is its own turn's, and the publisher deliberately allows a - provisional outcome to be replaced by the real turn under the same - command key, so another turn can claim under a key while this removal - is in flight and have its evidence cleared by the reply. Closing that - means keying the removal snapshot by attempt rather than by turn, - which is not done here. + Attempts rather than turns, because nothing serialises a claim against + another turn's removal. A removal holds its own turn's record lock and + no other's, and the publisher deliberately allows a provisional outcome + to be replaced by the real turn under the same command key β€” so a turn + already in this snapshot can go on to ask for the mark again while the + removal is in flight, and the reaction that second ask puts there + outlives the answer to the first. Named by the ask, it survives it. """ - claimants = set(self._expecting.get(_mark_id(mark), ())) + claimants = { + (session_id, command_id, token) + for (session_id, command_id), token in self._expecting.get( + _mark_id(mark), {} + ).items() + } if self._journal is None: return claimants record = self._record.get() diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 6ec87d598..1225dd162 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -1356,6 +1356,64 @@ async def another_publisher_takes_the_message(): assert chat["reactions"] == {"channel-demo:question"} +async def test_a_removal_in_flight_does_not_clear_a_mark_its_own_holder_put_back( + session_factory, +): + """The same window, where the turn that marks afresh is one it answers for. + + A queued receipt claims the mark and ends; another turn runs on that + message and ends last, so its removal is issued on behalf of both. While + the answer is in flight the queued command becomes the real turn in place + β€” the publisher allows exactly that β€” asks for the mark again and puts it + back. A removal that clears by turn loses that claim, because the turn it + names is one of its own holders, and the real turn then finishes clean + with the πŸ‘€ in plain sight. What the answer settles is the ask it was + issued against. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + + async def published(platform, command, turn_id, status): + turn = _turn(status).model_copy( + update={"command_id": command, "turn_id": turn_id} + ) + return await activity(session_factory, platform).publish( + [], + turn, + session_id="session-demo", + channel_id="channel-demo", + thread_root_id="channel-demo:root", + asked_on="channel-demo:question", + agent_name="Agent", + elapsed_seconds=12, + ) + + async def the_queued_command_becomes_its_real_turn(): + assert await published( + RefusingPlatform(chat), "second", "real:second", "running" + ) + assert chat["reactions"] == {"channel-demo:question"} + + assert await published(RefusingPlatform(chat), "second", "pending:second", "queued") + assert await published(RefusingPlatform(chat), "first", "turn-first", "running") + assert await published(RefusingPlatform(chat), "second", "pending:second", "error") + + assert await published( + DelayedRemoval( + chat, while_unacknowledged=the_queued_command_becomes_its_real_turn + ), + "first", + "turn-first", + "completed", + ) + assert chat["reactions"] == {"channel-demo:question"} + + assert not await published( + RefusingPlatform(chat, refuse_remove=True), "second", "real:second", "completed" + ) + assert chat["reactions"] == {"channel-demo:question"} + + async def test_a_refused_addition_leaves_one_publishers_own_earlier_mark_standing( session_factory, ): From 1a8404397d3d2fbed1c37ab114d1ca0863e03b74 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 14:15:16 +0100 Subject: [PATCH 024/120] Stop reserving a handle for a destination that will not take the card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel that was deleted, or that the bot has been put out of, refuses the post every time. The widening wait bounds how often that costs a reservation and a released handle, but it never ends it: the post is tried again every ten minutes for as long as the request is open, and the session it belongs to is an unfinished publication throughout, so every cycle reports the same failure over whatever is new. Once the wait has stretched as far as it goes the card is given up on, with one error-level record naming the request and saying it can still be answered in Console. Nothing is said in the channel β€” there is no reachable channel to say it in, and this does not choose somewhere else to say it. The giving up is this process's, not the row's: the reservation went back with the refusal, so there is nothing to stamp, and a restart is entitled to try again. `CardRefused` separates the platform's own refusal from the other ways a card comes to be unposted, so that bound applies to the destination answering and not, say, to a card that turns out to be posted already. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 64 +++++++++++++++++- core/switch_core/sessions/publication.py | 65 +++++++++++++++---- .../sessions/test_publication_retries.py | 44 +++++++++++++ 3 files changed, 159 insertions(+), 14 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 664100e50..54c368ed6 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -163,6 +163,16 @@ class CardAlreadyPosted(CardNotPosted): """ +class CardRefused(CardNotPosted): + """The platform would not take the card, so its handle was released. + + A subclass because nobody was asked either way. What the separate type + adds is that the destination itself answered: the reservation is gone and + a later attempt starts again from nothing, which is what lets a caller + bound how many times it is worth starting. + """ + + class ActivityAbandoned(CardNotPosted): """A turn's activity slot that will never be settled, whatever happens. @@ -1257,6 +1267,7 @@ def __init__( self._session_factory = session_factory self._reported_edit_failures: dict[str, tuple[int, str]] = {} self._noted_unconfirmed: set[str] = set() + self._undeliverable: set[str] = set() @property def surface(self) -> str: @@ -1393,7 +1404,7 @@ async def post( except RichContentFailed as error: await session.delete(post) await session.commit() - raise CardNotPosted( + raise CardRefused( f"Could not post the card for request {request.request_id} " f"in channel {channel_id}: {error}. Nobody has been asked, " f"and the handle {post.handle} was released." @@ -1465,6 +1476,57 @@ def note_unconfirmed(self, post: SessionRequestPost) -> None: post.request_id, ) + def undeliverable(self, attempt: str) -> bool: + """Whether this process has given up posting this request's card.""" + return attempt in self._undeliverable + + def note_undeliverable( + self, + attempt: str, + *, + request_id: str, + channel_id: str, + console_url: str | None, + refusal: BaseException, + ) -> None: + """Stop posting a card the destination has refused for long enough. + + A channel that was deleted, or that this bot has been put out of, + refuses the post every time it is tried. Stretching the wait between + tries bounds how often that costs a reservation and a released handle; + it does not end it, and a request nobody can be asked stays an + unfinished publication for as long as it is open, so the session it + belongs to never settles and every cycle reports the same failure over + whatever is new. + + So the attempts end, with the one record of why. Nothing is said in + the channel: there is no reachable channel to say it in, and the notice + does not go somewhere else of this module's choosing. The request stays + open and answerable in Console, which is the route that does not depend + on the destination existing. + + Memory, not the row β€” the reservation was released with the refusal, so + there is no row to stamp. A restart tries again, which is the right + lifetime for it: nothing here can tell a channel that is gone from one + that will be back, and a process that has just started has no grounds + for the giving up the last one did. + """ + if attempt in self._undeliverable: + return + self._undeliverable.add(attempt) + console = console_url or "Switch Console" + logger.error( + "Giving up posting the card for request %s in %s channel %s: %s. " + "Nobody has been asked there, and no further attempt will be made " + "until this bridge restarts. The request is still open and can be " + "answered at %s.", + request_id, + self._surface, + channel_id, + refusal, + console, + ) + async def disclose_unconfirmed( self, post: SessionRequestPost, *, console_url: str | None ) -> None: diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 5af992b02..49a41411a 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -11,6 +11,7 @@ from switch_core.bridges.collaboration.adapter import RichContentThrottled from switch_core.bridges.collaboration.session.outbound import ( + CardRefused, SessionRequestCards, SessionTurnActivity, ) @@ -46,6 +47,10 @@ def _always_recover(_token: str) -> bool: return True +def _never_spent(_token: str) -> bool: + return False + + def _ignore_delay(_token: str, _delay: float) -> None: return None @@ -97,6 +102,7 @@ async def refresh_cards( recovery_succeeded: Callable[[str], None] = _ignore_recovery, post_allowed: Callable[[str], bool] = _always_recover, post_succeeded: Callable[[str], None] = _ignore_recovery, + post_spent: Callable[[str], bool] = _never_spent, refresh_needed: Callable[[str, tuple[int, str]], bool] = _always_refresh, refreshed: Callable[[str, tuple[int, str]], None] = _ignore_refresh, ) -> None: @@ -116,6 +122,13 @@ async def refresh_cards( decide what the channel is told: that is the platform's disclosure policy and is deliberately not made here. + `post_spent` says that wait has stretched as far as it goes, and is where a + destination stops being treated as one that might come back: the card is + given up on, `cards.note_undeliverable` makes the single record of it, and + the request is skipped from then on rather than counted as a failure of + this session's publication on every later cycle. A caller that passes + nothing keeps the old behaviour β€” every refusal raised, forever. + `refresh_needed` gates redrawing an already-confirmed card, per token and `(revision, state)`, and `refreshed` is told once one lands. Both parts of that pair matter: `request.submitting` moves a request from `open` to @@ -260,22 +273,36 @@ async def refresh_cards( if request.state != "open": continue attempt = f"{session_id}:{request.request_id}" + if cards.undeliverable(attempt): + continue if not post_allowed(attempt): backed_off += 1 continue - new_post = await cards.post( - request, - channel_id=channel_id, - thread_root_id=thread_id, - asked_at_root=asked_at_root, - room_id=room_id, - session_id=session_id, - epoch=epoch, - agent_name=agent_name, - notify_external_id=recipient, - notify_unreachable=unreachable, - unavailable_reason=unavailable_reason, - ) + try: + new_post = await cards.post( + request, + channel_id=channel_id, + thread_root_id=thread_id, + asked_at_root=asked_at_root, + room_id=room_id, + session_id=session_id, + epoch=epoch, + agent_name=agent_name, + notify_external_id=recipient, + notify_unreachable=unreachable, + unavailable_reason=unavailable_reason, + ) + except CardRefused as refusal: + if not post_spent(attempt): + raise + cards.note_undeliverable( + attempt, + request_id=request.request_id, + channel_id=channel_id, + console_url=console_url, + refusal=refusal, + ) + continue post_succeeded(attempt) refreshed(new_post.token, state) elif post.external_post_id == post.token: @@ -790,6 +817,17 @@ def allowed(self, token: str) -> bool: self._interval[token] = min(interval * 2, self._max_interval) return True + def spent(self, token: str) -> bool: + """Whether this key's waits have stretched as far as they go. + + True once the interval has doubled its way to `_MAX`, which takes + several failed attempts over several minutes. A caller that has a + terminal disposition for the thing it keeps retrying reads this to + decide the destination is not coming back inside a wait; one that has + none ignores it and keeps trying at the capped interval. + """ + return self._interval.get(token, self._MIN) >= self._max_interval + def delay(self, token: str, seconds: float) -> None: self._next_attempt[token] = time.monotonic() + seconds self._interval.pop(token, None) @@ -1042,6 +1080,7 @@ async def publish_pending(self) -> None: recovery_succeeded=self._recovery.succeeded, post_allowed=self._card_post.allowed, post_succeeded=self._card_post.succeeded, + post_spent=self._card_post.spent, refresh_needed=self._redraw.needed, refreshed=self._redraw.drawn, ) diff --git a/core/tests/switch_core/sessions/test_publication_retries.py b/core/tests/switch_core/sessions/test_publication_retries.py index 4d0ad7a6a..7996e3525 100644 --- a/core/tests/switch_core/sessions/test_publication_retries.py +++ b/core/tests/switch_core/sessions/test_publication_retries.py @@ -750,6 +750,50 @@ async def test_a_card_that_cannot_be_posted_is_not_retried_every_cycle( assert "waiting out a retry backoff" in caplog.text +async def test_a_destination_that_never_takes_the_card_is_given_up_on( + session_factory, monkeypatch, caplog +): + """The widening wait bounds how often a refused post costs a reservation + and a released handle. On its own it never ends: a deleted channel is + posted to every ten minutes for as long as the request is open, and the + session it belongs to reports the same failure over whatever is new. Once + the wait has stretched as far as it goes the card is given up on, with one + record of where the request can still be answered.""" + clock = 0.0 + + def fake_monotonic() -> float: + return clock + + monkeypatch.setattr(publication.time, "monotonic", fake_monotonic) + service, epoch = await setup(session_factory) + await opened(service, epoch) + platform = RecoverablePlatform() + refused = AsyncMock(side_effect=RichContentFailed("no such channel", text="gone")) + monkeypatch.setattr(platform, "post_rich", refused) + publisher = SessionPublisher( + session_factory, "bridge", cards_for(session_factory, platform) + ) + + for _ in range(12): + clock += _RecoveryBackoff._MAX + 1.0 + await publisher.publish_pending() + + # Doubling from five seconds, the seventh attempt is the one that stretches + # the wait to the ten-minute cap, and its own refusal is the last. + assert refused.await_count == 7 + assert caplog.text.count("Giving up posting the card") == 1 + async with session_factory() as db: + assert (await db.scalars(select(SessionRequestPost))).all() == [] + + caplog.clear() + clock += _RecoveryBackoff._MAX + 1.0 + await publisher.publish_pending() + assert refused.await_count == 7 + # Nor is it still counted against the session, which would have it report + # a failure that has been dealt with as well as it can be on every cycle. + assert "card publication failed" not in caplog.text + + # ── A confirmed card is only redrawn when something about it changed ──────── From a0eed8fe87dc8a5b2708b840fcbb218aa9513097 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 14:37:23 +0100 Subject: [PATCH 025/120] Keep a finished Telegram status in the chat as the record of the turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Telegram status used to be deleted when the turn ended, on the reasoning that a chat is the conversation itself and a permanent "Worked for 12s" is clutter. That left a reader scrolling back with nothing: no sign the turn ran, no duration, and no way into the Console. The status now stays, edited to its final state, and it is compact for the reason it used to be deleted. The tool log Telegram folded into an expandable quotation is gone, and so is the "Now:/Last:" line naming the tool of the moment β€” `turn_status` takes `current_tool` and Telegram declines it. What is left is the state, the duration, one Console link, the agent's name (a bot has no other identity) and, when a call failed or was declined, the tally saying so. With nothing retired there is no retirement to remember, so the delete path and its bookkeeping come out with it. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 1 + .../collaboration/mattermost/adapter.py | 1 + .../session/renderers/neutral.py | 61 ++--- .../bridges/collaboration/teams/adapter.py | 1 + .../bridges/collaboration/telegram/adapter.py | 164 ++------------ .../collaboration/test_telegram_sdk_only.py | 209 ++++++++++-------- 6 files changed, 146 insertions(+), 291 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index accd62577..835d6704b 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -887,6 +887,7 @@ def _draw( session_url=content.session_url, mention=mention, error_summary=content.error_summary, + current_tool=True, ) + tail ) diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index c4f5d05c6..4dcee6440 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -669,6 +669,7 @@ def _draw( session_url=content.session_url, mention=mention, error_summary=content.error_summary, + current_tool=True, ) + tail ) diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 9b293a8a2..7a09ef82b 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -94,11 +94,6 @@ "declined": "⊘", } -# How much of one tool call's title a disclosed line keeps. A title is host -# text: long enough to recognise the call, short enough that five of them are -# still a glance rather than a page. -_DETAIL_TITLE = 120 - _OUTCOME_WORDS = { "in-progress": "running", "completed": "done", @@ -174,6 +169,7 @@ def turn_status( session_url: str | None = None, mention: str | None = None, error_summary: str | None = None, + current_tool: bool, ) -> str: """A turn's progress, compact enough to live in one message that is edited. @@ -190,6 +186,13 @@ def turn_status( - what it is doing right now, while it is still doing something; - how the tool calls went, once there is more than one outcome to report. + `current_tool` is the middle one, and a platform can decline it. Where the + status shares the conversation with everything else that is said there, a + line naming the tool of the moment is the part that reads as noise: the + state and its link are the record, and what the turn is doing is in + Console. The outcome tally is not covered by it β€” a call that failed is + not chatter about progress. + `error_summary` is the attention slot rather than the status: a distinct problem somebody has to act on, said in one sentence with the mention that makes it reach them. When it is set, that is the whole message β€” the state @@ -214,7 +217,9 @@ def turn_status( lines = [head] spent = len(head) did = [item for item in items if item.kind == "tool-activity"] - for line in _doing(did, turn, escape=escape, budget=budget): + for line in _doing( + did, turn, escape=escape, budget=budget, current_tool=current_tool + ): if spent + len(line) + 1 > budget: break lines.append(line) @@ -222,53 +227,13 @@ def turn_status( return _mentioned(mention, "\n".join(lines)) -def activity_detail( - items: list[Item], - *, - escape: Callable[[str], str], - limit: int, - lines: int, -) -> list[str]: - """The last few tool calls as their own lines, oldest first. - - What a platform puts behind a disclosure it already has β€” Telegram's - expandable quotation β€” rather than in the status itself, which stays the - three compact lines `turn_status` draws whether or not anything expands. - The caller supplies the wrapper; this decides what is safe to say inside - it and how much of it there is room for. - - Only the title and how the call went. Arguments and output are host text - with no bound worth trusting, and a status is not a transcript: five - labels say what the turn has been doing, and a count says there was more. - Lines are dropped from the oldest end when the budget is short, because - the reader opening this wants to know what it is doing now. - - `limit` counts the escaped text and the newlines between the lines, not - whatever the caller wraps around them. - """ - did = [item for item in items if item.kind == "tool-activity"] - if not did or limit <= 0 or lines <= 0: - return [] - shown = did[-lines:] - per = max(1, min(_DETAIL_TITLE, limit // len(shown))) - drawn = [ - f"{_OUTCOME[item.status]} {_fit(item.title or 'Tool call', per, escape=escape)}" - for item in shown - ] - hidden = len(did) - len(shown) - if hidden: - drawn.insert(0, f"…{hidden} earlier, not shown.") - while drawn and sum(len(line) + 1 for line in drawn) - 1 > limit: - drawn.pop(0) - return drawn - - def _doing( did: list[Item], turn: TurnUpsert, *, escape: Callable[[str], str], budget: int, + current_tool: bool, ) -> list[str]: """The optional lines under the state: what is running, and how it is going. @@ -281,7 +246,7 @@ def _doing( return [] lines: list[str] = [] ended = turn.status in TURN_ENDED - if not ended: + if current_tool and not ended: current = next( (item for item in reversed(did) if item.status == "in-progress"), None ) diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 15b387054..96cead232 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -1337,6 +1337,7 @@ def _draw( session_url=content.session_url, mention=mention, error_summary=content.error_summary, + current_tool=True, ) + tail ) diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 2ee9e8999..2895e3c3b 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -68,7 +68,6 @@ position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( - activity_detail, render_request, turn_status, ) @@ -226,39 +225,11 @@ def _button_label(control: Control) -> str: # every agent publishing there shares one budget, and being paced by Telegram # costs the conversation rather than only the redraw. # -# Only intermediate progress is held back. A turn's last state and its cleanup, -# the attention slot and every request card go through immediately, because a -# reader waiting on one of those is waiting on the thing this pacing would -# delay. +# Only intermediate progress is held back. A turn's last state, the attention +# slot and every request card go through immediately, because a reader waiting +# on one of those is waiting on the thing this pacing would delay. _REDRAW_INTERVAL = 1.5 -# Telegram's own disclosure control: a quotation the reader opens, documented -# under HTML style in the Bot API. Nothing may be nested inside it, and a -# collapsed block still costs its whole length against the message limit, so -# what goes in is bounded to the latest few tool calls and a count of the rest. -_EXPAND_OPEN = "
" -_EXPAND_CLOSE = "
" -_EXPAND_LINES = 5 - - -def _retires(content: RichContent) -> bool: - """Whether this redraw is the end of something that should not stay. - - A status is the thing in the chat saying work is happening, and a Telegram - chat or topic is the conversation itself rather than a side channel, so it - goes when the turn does β€” as the legacy indicator did. Two things stay: a - request card, which is the record of a decision and says on its face what - became of it, and anything still reporting a problem or an unreached - reader, which is the message somebody has to act on and outlives the turn - that raised it. - """ - return ( - isinstance(content, TurnActivity) - and content.turn.status in TURN_ENDED - and not content.error_summary - and not content.notify_unreachable - ) - def _throttle_delay(error: RetryAfter) -> float: """How long Telegram's 429 asks us to wait. @@ -519,10 +490,6 @@ def __init__(self, *, config: TelegramConnectionConfig) -> None: # entry is only ever a timestamp to compare against. self._rich_drawn_at: OrderedDict[str, float] = OrderedDict() self._rich_drawn_at_max = 1000 - # Publications taken down at the end of their turn, so a later redraw - # of one is a no-op rather than an edit to a message that is gone. - self._rich_retired: OrderedDict[str, None] = OrderedDict() - self._rich_retired_max = 1000 # ── Lifecycle ──────────────────────────────────────────────────────────── @@ -1411,15 +1378,9 @@ def _draw( session_url=content.session_url, mention=mention, error_summary=content.error_summary, + current_tool=False, ) - detail = ( - "" - if content.error_summary or content.status_only - else self._expandable( - content, escape=escape, budget=limit - len(body) - len(tail) - ) - ) - return Drawn(text=f"{prefix}{body}{detail}{tail}", answerable=False) + return Drawn(text=f"{prefix}{body}{tail}", answerable=False) # The mention goes on its own line rather than in front of the heading: # a card is a block, and a handle wedged before "Permission needed" # reads as part of the heading. @@ -1436,42 +1397,6 @@ def _draw( ) return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") - def _expandable( - self, - content: TurnActivity, - *, - escape: Callable[[str], str], - budget: int, - ) -> str: - """What the turn has been doing, folded away under the status. - - Telegram's own disclosure: a collapsed quotation the reader opens if - they want it, in the message that is already there. It is why this - platform has no separate activity log β€” the detail lives inside the - status rather than in a second message that would notify the chat - again. - - Assembled here rather than in the renderer because the tags have to go - on after escaping: `translate_outbound` escapes a body whole and then - re-introduces the tags it knows by pattern, so markup written upstream - of it would reach the chat as visible angle brackets. The lines inside - are escaped host text; the quotation around them is ours. - - A collapsed block still costs its full length against the message - limit, so it is the first thing to go when the status is already large: - the reader loses the detail, not the state. - """ - lines = activity_detail( - content.items, - escape=escape, - limit=budget - len(_EXPAND_OPEN) - len(_EXPAND_CLOSE) - 1, - lines=_EXPAND_LINES, - ) - if not lines: - return "" - quoted = "\n".join(lines) - return f"\n{_EXPAND_OPEN}{quoted}{_EXPAND_CLOSE}" - def _controls( self, content: RichContent, drawn: Drawn ) -> InlineKeyboardMarkup | None: @@ -1639,16 +1564,15 @@ async def update_rich( content: RichContent, thread_root_id: str | None, ) -> None: - """Redraw a publication in place β€” or take it down, once the turn it - was reporting is over. + """Redraw a publication in place, including the last time. - A Telegram chat and a forum topic are both the conversation itself: - there is no side channel a finished status could sit quietly in, and - the legacy indicator was deleted at the end of a turn for that reason. - The status keeps that lifecycle, so a chat is not left carrying one - permanent "Worked for 12s" per turn. What is still worth reading stays: - a request card is the record of a decision and is never taken down, and - an attention message about a problem outlives the turn that raised it. + Nothing is taken down. A finished status is edited to its final state + and stays in the chat as the record that the turn ran, how long it + took, and where to open it β€” which is what a reader scrolling back + wants and what a deletion left them without. It is compact for the same + reason it used to be deleted: a Telegram chat or topic is the + conversation itself, so the status is a line and its link rather than a + running commentary on tool calls. `agent_name` is what the redraw writes back into the body. The name is the message here β€” one bot posts for every agent β€” so an edit that did @@ -1667,8 +1591,6 @@ async def update_rich( "chat:message reference.", text=self.rich_fallback_text(content), ) - if message_ref in self._rich_retired: - return # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the chat that never reaches anybody it # has not already reached. @@ -1676,55 +1598,11 @@ async def update_rich( replace(content, notify_external_id=None), agent_name ) self._refuse_while_throttled(drawn.text) - if _retires(content): - await self._retire_rich(channel_id, message_ref, drawn.text) - return self._pace_publication(channel_id, content, drawn.text) await self._edit_rich( channel_id, message_ref, drawn.text, self._controls(content, drawn) ) - async def _retire_rich(self, channel_id: str, message_ref: str, text: str) -> None: - """Take a finished status out of the chat, or say why it is still there. - - A deletion Telegram refuses is not quietly treated as one that - happened: the message stays, so it is left showing the turn's final - state rather than "Working…", and the refusal is logged. An outcome - nobody knows β€” a timeout, a reset β€” is raised, because the publisher - holds the anchor and can come back to it, and a turn recorded as - cleaned up when it was not is a status that never goes. - """ - _, message_id = self._parse_message_ref(message_ref) - try: - await self._require_bot().delete_message( - chat_id=self._chat_id(channel_id), message_id=int(message_id) - ) - except BadRequest as error: - if "not found" in str(error).lower(): - self._retire_ref(message_ref) - return - logger.warning( - "Telegram would not remove the finished status %s in chat %s " - "(%s); leaving its final state there instead.", - message_ref, - channel_id, - error, - ) - await self._edit_rich(channel_id, message_ref, text, None) - return - except Forbidden as error: - logger.warning( - "Telegram would not remove the finished status %s in chat %s " - "(%s); leaving its final state there instead.", - message_ref, - channel_id, - error, - ) - await self._edit_rich(channel_id, message_ref, text, None) - return - self._note_publication(channel_id) - self._retire_ref(message_ref) - async def _edit_rich( self, channel_id: str, @@ -1768,12 +1646,6 @@ async def _edit_rich( ) from error self._note_publication(channel_id) - def _retire_ref(self, message_ref: str) -> None: - self._rich_retired[message_ref] = None - self._rich_retired.move_to_end(message_ref) - while len(self._rich_retired) > self._rich_retired_max: - self._rich_retired.popitem(last=False) - def _rich_failure(self, error: Exception, description: str, text: str) -> Exception: """The exception to raise for `error`: Telegram's refusal, or its own. @@ -1814,11 +1686,11 @@ def _pace_publication( agents' statuses share one budget the way they share the 429 that follows from overspending it. - Only progress. A turn's final state and its cleanup, the attention - slot and every request card go through however recently the chat was - last written to, because a reader waiting on one of those is waiting - on precisely the thing this would delay β€” and the publisher retries a - throttle, so what is held back here is postponed rather than lost. + Only progress. A turn's final state, the attention slot and every + request card go through however recently the chat was last written to, + because a reader waiting on one of those is waiting on precisely the + thing this would delay β€” and the publisher retries a throttle, so what + is held back here is postponed rather than lost. """ if not isinstance(content, TurnActivity): return diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 70b512f73..fa7733694 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -53,7 +53,7 @@ _REDRAW_INTERVAL, TelegramAdapter, ) -from switch_core.sessions.contract import ApprovalResult +from switch_core.sessions.contract import ApprovalResult, Item from .test_session_activity import _item, _turn from .test_telegram_adapter import ( @@ -75,6 +75,7 @@ CHANNEL = str(CHAT_ID) TOPIC_ID = "88" +SESSION_URL = "https://console.example/sessions/session-demo" ASKER_ID = 60606 ASKER = str(ASKER_ID) @@ -89,6 +90,26 @@ def _ended(**kwargs: Any) -> TurnActivity: return TurnActivity(items, _turn("completed"), **kwargs) +def _tool(status: str) -> Item: + return _item(itemId=f"item-{status}", title="Read the adapter", status=status) + + +def _running(*extra: Item) -> TurnActivity: + """A turn mid-flight, with everything a status can draw from: a tool call + in progress, a duration to count and a session to link to.""" + items = [_tool("in-progress"), *extra] + return TurnActivity( + items, _turn("running"), elapsed_seconds=12, session_url=SESSION_URL + ) + + +def _finished(*extra: Item) -> TurnActivity: + items = [_tool("completed"), *extra] + return TurnActivity( + items, _turn("completed"), elapsed_seconds=42, session_url=SESSION_URL + ) + + async def _card(**kwargs: Any) -> RequestCard: source = FixtureEventSource.from_examples(EXAMPLES_PATH, events=[]) projection = await project(source, "session-demo") @@ -551,13 +572,13 @@ async def test_progress_arriving_faster_than_the_chat_can_take_it_waits() -> Non async def test_the_end_of_a_turn_is_never_held_back() -> None: """A reader waiting on the outcome is waiting on precisely the thing the - pacing would delay β€” and here the outcome is the status going away.""" + pacing would delay.""" adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) - assert len(_bot(adapter).deletes) == 1 + assert "Turn complete." in _edited(adapter)["text"] async def test_a_problem_somebody_has_to_act_on_is_never_held_back() -> None: @@ -612,101 +633,114 @@ async def test_another_chat_is_not_held_back_by_this_one() -> None: assert len(_bot(adapter).messages) == 2 -# ── A finished turn does not stay on the screen ────────────────────────────── +# ── A finished turn stays, as a line ───────────────────────────────────────── -async def test_a_finished_status_is_taken_out_of_the_chat() -> None: - """A Telegram chat is the conversation itself: there is no side channel a - completed status can sit quietly in, and the legacy indicator was deleted - for that reason. One permanent "Worked for 12s" per turn is the clutter - this platform's own rule exists to avoid.""" +async def test_a_finished_status_is_edited_to_its_final_state_and_left_there() -> None: + """What a reader scrolling the chat wants from a turn that has ended is + that it ran, how long it took and where to open it. Deleting the status + left them with none of that.""" adapter = _adapter() - ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + ref = await adapter.post_rich(CHANNEL, "my-agent", _running(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) + await adapter.update_rich(CHANNEL, "my-agent", ref, _finished(), None) - assert _bot(adapter).deletes[0]["message_id"] == int(ref.split(":")[1]) - assert _bot(adapter).edits == [] + text = _edited(adapter)["text"] + assert _bot(adapter).deletes == [] + assert "Worked for 42s" in text + assert f'
' in text -async def test_a_finished_status_in_a_forum_topic_goes_the_same_way() -> None: - """A topic is a conversation people read, not a hidden thread to leave a - record in.""" +async def test_a_finished_status_in_a_forum_topic_is_kept_the_same_way() -> None: + """A topic is the conversation, and the record belongs in it.""" adapter = _adapter() _forum(adapter) - ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), TOPIC_ID) + ref = await adapter.post_rich(CHANNEL, "my-agent", _running(), TOPIC_ID) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), TOPIC_ID) + await adapter.update_rich(CHANNEL, "my-agent", ref, _finished(), TOPIC_ID) - assert len(_bot(adapter).deletes) == 1 + assert _bot(adapter).deletes == [] + assert "Worked for 42s" in _edited(adapter)["text"] -async def test_a_finished_turn_that_still_has_a_problem_to_report_stays() -> None: - """The attention message outlives the turn that raised it: somebody has to - act on it, and a turn ending is not that having happened.""" +async def test_a_finished_status_stops_counting_and_drops_what_was_running() -> None: + """The timer and the running marks are the parts that are wrong the moment + the turn ends, and a retained status keeps showing whatever it last said.""" adapter = _adapter() - ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) + ref = await adapter.post_rich(CHANNEL, "my-agent", _running(), None) + assert "Working…" in _bot(adapter).messages[0]["text"] - await adapter.update_rich( - CHANNEL, "my-agent", ref, _ended(error_summary="The host went away."), None - ) + await adapter.update_rich(CHANNEL, "my-agent", ref, _finished(), None) - assert _bot(adapter).deletes == [] - assert "went away" in _edited(adapter)["text"] + text = _edited(adapter)["text"] + assert "Working…" not in text + assert "running" not in text -async def test_a_request_card_is_never_taken_down() -> None: - """It is the record of a decision, and it says on its face what became of - it.""" +async def test_a_telegram_status_never_names_the_tool_of_the_moment() -> None: + """It is compact for the reason it used to be deleted: the chat is the + conversation itself, so the status is a line and its link rather than a + running commentary. What the turn is doing is in the Console.""" adapter = _adapter() - ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, await _card(), None) + await adapter.post_rich(CHANNEL, "my-agent", _running(), None) - assert _bot(adapter).deletes == [] + text = _bot(adapter).messages[0]["text"] + assert "Read the adapter" not in text + assert "Now:" not in text and "Last:" not in text -async def test_a_status_taken_down_is_not_edited_afterwards() -> None: +async def test_a_call_that_failed_still_shows_on_a_finished_status() -> None: + """Not chatter about progress. A turn whose total reads as a clean run, + with a declined call inside it, is the status saying the wrong thing.""" adapter = _adapter() - ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) + ref = await adapter.post_rich(CHANNEL, "my-agent", _running(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) + await adapter.update_rich( + CHANNEL, "my-agent", ref, _finished(_tool("failed")), None + ) - assert len(_bot(adapter).deletes) == 1 - assert _bot(adapter).edits == [] + assert "1 failed" in _edited(adapter)["text"] -async def test_a_deletion_telegram_refuses_leaves_the_final_state_showing( - caplog: pytest.LogCaptureFixture, -) -> None: - """Visibly degraded rather than quietly wrong: the status cannot be taken - down, so it is left saying what actually happened instead of saying the - turn is still running.""" +async def test_a_finished_turn_that_still_has_a_problem_to_report_says_so() -> None: + """The attention message outlives the turn that raised it: somebody has to + act on it, and a turn ending is not that having happened.""" adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) - _bot(adapter).delete_error = BadRequest("message can't be deleted") - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) + await adapter.update_rich( + CHANNEL, "my-agent", ref, _ended(error_summary="The host went away."), None + ) - assert len(_bot(adapter).edits) == 1 - assert any("leaving its final state" in record.message for record in caplog.records) + assert "went away" in _edited(adapter)["text"] -async def test_a_deletion_whose_outcome_is_unknown_is_retried_rather_than_assumed() -> ( - None -): - """A turn recorded as cleaned up when it was not is a status that never - goes.""" +async def test_nothing_this_bridge_publishes_is_ever_taken_down() -> None: + """A status and a card are both the record of something that happened, and + each says on its face what became of it.""" adapter = _adapter() - ref = await adapter.post_rich(CHANNEL, "my-agent", _activity(), None) - _bot(adapter).delete_error = TimedOut() + status = await adapter.post_rich(CHANNEL, "my-agent", _running(), None) + card = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) - with pytest.raises(TimedOut): - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) + await adapter.update_rich(CHANNEL, "my-agent", status, _finished(), None) + await adapter.update_rich(CHANNEL, "my-agent", card, await _card(), None) - await adapter.update_rich(CHANNEL, "my-agent", ref, _ended(), None) - assert len(_bot(adapter).deletes) == 1 + assert _bot(adapter).deletes == [] + + +async def test_a_finished_status_is_still_redrawn_when_the_turn_says_more() -> None: + """Nothing is retired, so a late revision of a turn that has ended reaches + the chat rather than being dropped on the floor.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _running(), None) + await adapter.update_rich(CHANNEL, "my-agent", ref, _finished(), None) + + await adapter.update_rich( + CHANNEL, "my-agent", ref, _finished(_tool("declined")), None + ) + + assert "1 declined" in _edited(adapter)["text"] # ── Publications do not drift out of the conversation they belong to ───────── @@ -746,7 +780,7 @@ async def test_a_root_that_is_not_an_id_refuses_the_publication() -> None: assert _bot(adapter).messages == [] -# ── What the turn has been doing, folded away ──────────────────────────────── +# ── What the turn has been doing stays in the Console ──────────────────────── def _tools(count: int) -> TurnActivity: @@ -757,61 +791,42 @@ def _tools(count: int) -> TurnActivity: return TurnActivity(items, _turn("running")) -async def test_the_tool_log_is_folded_into_the_status_rather_than_posted() -> None: - """Telegram's own disclosure control, in the message that is already - there: no second message, and no notification for a tool call.""" +async def test_the_tool_log_is_not_drawn_into_the_chat_at_all() -> None: + """A status that stays needs to be worth keeping. Nine tool titles folded + into it is a running commentary on a turn the reader can open in the + Console, sitting permanently in the conversation.""" adapter = _adapter() - await adapter.post_rich(CHANNEL, "my-agent", _tools(2), None) + await adapter.post_rich(CHANNEL, "my-agent", _tools(9), None) text = _posted(adapter)["text"] - assert "
" in text - assert "Read file 1" in text + assert "Read file" not in text + assert " None: - """A collapsed block still costs its whole length against the 4096, and a - reader opening it wants what the turn is doing now.""" +async def test_the_status_still_says_how_the_calls_went() -> None: + """Dropping the log is not dropping the outcome: nine done is one short + line, and it is the part a reader cannot get from the duration.""" adapter = _adapter() await adapter.post_rich(CHANNEL, "my-agent", _tools(9), None) - text = _posted(adapter)["text"] - assert "…4 earlier, not shown." in text - assert "Read file 8" in text - assert "Read file 3" not in text - - -async def test_a_tool_title_cannot_break_out_of_the_quotation() -> None: - """The lines inside are host text. The quotation around them is ours.""" - adapter = _adapter() - content = TurnActivity( - [_item(title="
everything below is mine")], - _turn("running"), - ) - - await adapter.post_rich(CHANNEL, "my-agent", content, None) - - text = _posted(adapter)["text"] - assert text.count("") == 1 - assert "</blockquote>" in text + assert "9 done" in _posted(adapter)["text"] -async def test_the_attention_message_carries_no_tool_log() -> None: - """It is one sentence about a problem somebody has to act on. A fold of - tool calls under it would bury the only line that matters.""" +async def test_a_tool_title_cannot_reach_the_chat_as_markup() -> None: + """Nothing draws it today, and a title is host text whatever draws it + next.""" adapter = _adapter() content = TurnActivity( - [_item(title="Read file")], + [_item(title="everything below is mine")], _turn("running"), - status_only=True, - error_summary="The host went away.", ) await adapter.post_rich(CHANNEL, "my-agent", content, None) - assert " Date: Tue, 15 Sep 2026 14:38:46 +0100 Subject: [PATCH 026/120] Keep a finished Discord status where it was published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn ending at the channel root or in a DM took its status down; only a thread kept one. The reasoning was that outside a thread the status was just the thing saying work was happening β€” but the channel root is where most turns are published, so in practice the outcome, the duration and the link to the session were exactly where a reader scrolling back could not find them. Now nothing is taken down, which is what a request card already did. With no retirement there is no retired set to consult, and no half-deleted case to degrade into, so `_retire_rich`, `_is_flat`, `_turn_has_ended` and the ref bookkeeping come out with it. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 91 ++----------------- .../collaboration/test_discord_sdk_only.py | 40 ++++---- 2 files changed, 23 insertions(+), 108 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 835d6704b..1337c360c 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -54,7 +54,6 @@ request_summary, turn_status, ) -from switch_core.sessions.contract import TURN_ENDED logger = logging.getLogger(__name__) @@ -151,15 +150,6 @@ def _as_rich_failure( return None -def _turn_has_ended(content: RichContent) -> bool: - """Whether this publication is a turn with nothing left to happen in it. - - A request card is never one, whatever state its turn is in: the card is the - record of a decision and outlives the turn that asked for it. - """ - return isinstance(content, TurnActivity) and content.turn.status in TURN_ENDED - - class _WebhookIdentity: """Keep one accepted identity across chunks and attachment retries.""" @@ -343,11 +333,6 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: # marked several messages. self._eyes: set[str] = set() self._agent_eyes: dict[tuple[str, str], set[str]] = {} - # Publications this adapter has taken down at the end of a turn, so a - # later redraw of one is recognised as finished rather than reported as - # a message Discord has lost. - self._rich_retired: OrderedDict[str, None] = OrderedDict() - self._rich_retired_max = 1000 # Set once Discord has told us it will not host agent roles, so the # bridge stops asking and says so only once. self._agent_roles_off_reason: str | None = None @@ -1112,16 +1097,14 @@ async def update_rich( content: RichContent, thread_root_id: str | None, ) -> None: - """Redraw a publication in place β€” or take it down, where it has served - its purpose and staying would just be clutter. + """Redraw a publication in place, including the last time. - A turn that has ended leaves nothing behind outside a thread. At the - channel root and in a DM the status was only ever the thing saying work - was happening, and Discord deletes it cleanly, so it goes the way the - legacy indicator went. Inside a thread it stays: a thread is the record - of one exchange, and the outcome, the time it took and the link to the - session belong in it. A request card is never taken down anywhere β€” it - is the record of a decision, and it says on its face what became of it. + Nothing is taken down. A turn that has ended is edited to its final + state and stays where it was published β€” in a thread, at the channel + root or in a DM alike β€” as the record that the turn ran, how long it + took and where to open it. Deleting it at the channel root left a + reader scrolling back with none of that, and a request card was never + taken down anywhere for the same reason. Not `update_message`, which logs and returns. That is right for a status line nobody is waiting on and wrong here: a card that failed to @@ -1140,9 +1123,6 @@ async def update_rich( "location:message reference.", text=self.rich_fallback_text(content), ) - if message_ref in self._rich_retired: - return - try: target = await self._get_channel(int(channel_id)) except Exception as error: @@ -1160,59 +1140,8 @@ async def update_rich( text = self._render_rich( replace(content, notify_external_id=None), prefix=prefix ) - if self._is_flat(channel_id, message_ref) and _turn_has_ended(content): - await self._retire_rich(channel_id, message_ref, text, lobby=lobby) - return await self._edit_rich(channel_id, message_ref, text, lobby=lobby) - def _is_flat(self, channel_id: str, message_ref: str) -> bool: - """Whether a publication is sitting in the channel rather than a thread. - - A Discord thread is a channel of its own, so the location half of the - ref differs from the channel the turn belongs to exactly when the post - went into a thread. - """ - location_id, _ = self._parse_message_ref(message_ref) - return location_id == channel_id - - async def _retire_rich( - self, channel_id: str, message_ref: str, text: str, *, lobby: bool - ) -> None: - location_id, message_id = self._parse_message_ref(message_ref) - try: - if lobby: - target = await self._get_channel(int(location_id or channel_id)) - await target.get_partial_message(int(message_id)).delete() - else: - webhook = await self._publication_webhook(int(channel_id)) - await webhook.delete_message(int(message_id)) - except discord.NotFound: - pass - except Exception as error: - failure = _as_rich_failure( - error, - description=( - f"Discord refused to remove the finished status {message_ref} " - f"in channel {channel_id}" - ), - text=text, - ) - if failure is None: - raise - # Visibly degraded rather than quietly wrong: the status cannot be - # taken down, so it is left saying what actually happened instead - # of saying the turn is still running. - logger.warning( - "Could not remove the finished Discord status %s in channel %s " - "(%s); leaving its final state in the channel instead.", - message_ref, - channel_id, - error, - ) - await self._edit_rich(channel_id, message_ref, text, lobby=lobby) - return - self._retire_ref(message_ref) - async def _edit_rich( self, channel_id: str, message_ref: str, text: str, *, lobby: bool ) -> None: @@ -1532,12 +1461,6 @@ async def notify_working( e, ) - def _retire_ref(self, message_ref: str) -> None: - self._rich_retired[message_ref] = None - self._rich_retired.move_to_end(message_ref) - while len(self._rich_retired) > self._rich_retired_max: - self._rich_retired.popitem(last=False) - @staticmethod def _thread_channel_id(thread_root_ref: str) -> int | None: """The id of the thread rooted at this message ref. diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index f308174d9..d76522a47 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -599,7 +599,7 @@ async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> N ) -# ── Redrawing and retirement ───────────────────────────────────────────────── +# ── Redrawing ──────────────────────────────────────────────────────────────── async def test_a_running_turn_is_redrawn_in_place_inside_its_thread() -> None: @@ -625,18 +625,22 @@ async def test_a_thread_keeps_the_finished_turn_as_its_record() -> None: assert webhook.edits[0]["message_id"] == 901 -async def test_a_flat_channel_loses_the_status_when_the_turn_ends() -> None: +async def test_a_flat_channel_keeps_the_finished_turn_too() -> None: + """The channel root is where most turns are published and the one place a + finished status used to disappear from. What a reader scrolling back wants + is the same there as in a thread: that it ran, how long it took, and the + link to open it.""" adapter, _channel, _thread, webhook = _guild_setup() await adapter.update_rich( str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended(), None ) - assert webhook.edits == [] - assert webhook.deletes[0]["message_id"] == 901 + assert webhook.deletes == [] + assert webhook.edits[0]["message_id"] == 901 -async def test_a_dm_loses_it_too_and_never_asks_for_a_webhook() -> None: +async def test_a_dm_keeps_it_too_and_never_asks_for_a_webhook() -> None: dm = _DMChannel() adapter = _adapter({DM_CHANNEL_ID: dm}) dm.messages[501] = _Message(dm, 501) @@ -645,7 +649,8 @@ async def test_a_dm_loses_it_too_and_never_asks_for_a_webhook() -> None: str(DM_CHANNEL_ID), "my-agent", f"{DM_CHANNEL_ID}:501", _ended(), None ) - assert dm.deleted_ids == [501] + assert dm.deleted_ids == [] + assert dm.messages[501].edited is not None async def test_a_dm_redraw_writes_the_agent_name_back_into_the_body() -> None: @@ -688,22 +693,9 @@ async def test_a_settled_card_is_never_taken_down() -> None: assert webhook.edits[0]["message_id"] == 901 -async def test_a_status_that_cannot_be_removed_is_left_saying_what_happened( - caplog: pytest.LogCaptureFixture, -) -> None: - adapter, _channel, _thread, webhook = _guild_setup() - webhook.delete_error = _http_error(403) - - with caplog.at_level(logging.WARNING): - await adapter.update_rich( - str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended(), None - ) - - assert "leaving its final state" in caplog.text - assert webhook.edits[0]["message_id"] == 901 - - -async def test_redrawing_a_retired_status_is_not_reported_as_a_lost_message() -> None: +async def test_a_finished_status_is_still_redrawn_when_the_turn_says_more() -> None: + """Nothing is retired, so a late revision of a turn that has ended reaches + the channel rather than being dropped on the floor.""" adapter, _channel, _thread, webhook = _guild_setup() await adapter.update_rich( @@ -713,8 +705,8 @@ async def test_redrawing_a_retired_status_is_not_reported_as_a_lost_message() -> str(CHANNEL_ID), "my-agent", f"{CHANNEL_ID}:901", _ended(), None ) - assert len(webhook.deletes) == 1 - assert webhook.edits == [] + assert webhook.deletes == [] + assert len(webhook.edits) == 2 # ── Recovery ───────────────────────────────────────────────────────────────── From 1da0bef8087ddcaf6d039ef08ee0c59348427f85 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 14:49:39 +0100 Subject: [PATCH 027/120] Keep a finished Teams status in the conversation, in either layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A posts channel already kept it, because Teams leaves "This message has been deleted." behind and that is worse than the line it replaces. A chat, a group chat and a chat-layout channel deleted it, on the reasoning that a bot's own message goes from those without trace and a finished status is clutter. What went with it was the only account of the turn a reader scrolling back could find: that it ran, how long it took, and the link to open it. Both layouts now end the same way, edited in place. The layout question only ever decided how to retire, so `_retires` and `_retire_rich` go, and with them the degraded paths for a deletion refused, a deletion timed out, and a message already gone β€” none of which can arise when nothing is deleted. A Gone on the edit is still reported: it is a `BotConnectorRefused`, which `_edit_rich` turns into `RichContentFailed`. The shared descriptions of what a status becomes at the end of a turn are updated to match, on every platform. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 6 +- .../bridges/collaboration/session/outbound.py | 6 +- .../bridges/collaboration/teams/adapter.py | 123 ++---------------- .../collaboration/test_teams_sdk_only.py | 115 ++++------------ 4 files changed, 45 insertions(+), 205 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 092e1f1a9..ac248915c 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -48,9 +48,9 @@ def format_elapsed(seconds: float) -> str: "2m14s", "1h03m" β€” rather than as a precise duration nobody reads. Sub- second turns report "0s" instead of an empty string. - Lives here rather than beside one adapter because every platform that - retires a status line by editing it rather than deleting it wants the same - words on it. + Lives here rather than beside one adapter because every platform ends a + status line the same way β€” edited in place, left in the conversation β€” and + wants the same words on it. """ total = max(0, int(seconds)) if total < 60: diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 54c368ed6..e8de82b2c 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -204,8 +204,10 @@ def __init__(self, message: str, *, slot: str, abandoned_at: str) -> None: class SessionTurnActivity: """Publish SDK activity without exposing internal assistant narration. - Slack keeps the live status separate from the collapsible tool log. - When the turn ends, the log becomes the summary and the status is removed. + Slack keeps the live status separate from the collapsible tool log. When + the turn ends, the log becomes the summary and the status is edited to its + final state and left there β€” on every platform β€” as the record that the + turn ran, how long it took and where to open it. Production publishers use a durable journal to recover message anchors and uncertain deliveries after a restart. diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 96cead232..43990f6ae 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -59,7 +59,6 @@ from switch_core.bridges.collaboration.teams.connector import ( BotConnectorClient, BotConnectorConflict, - BotConnectorGone, BotConnectorRefused, BotConnectorThrottled, ) @@ -69,7 +68,6 @@ load_certificate_der_b64, ) from switch_core.bridges.collaboration.teams.graph import GraphClient -from switch_core.sessions.contract import TURN_ENDED logger = logging.getLogger(__name__) @@ -233,27 +231,6 @@ def code(self, text: str) -> str: _TEAMS_MARKUP = _TeamsMarkup() -def _retires(content: RichContent) -> bool: - """Whether this redraw is the end of something that should not stay. - - A status says work is happening, and once it is not the message has said - everything it had to say. Two things stay: a request card, which is the - record of a decision and shows on its face what became of it, and anything - still reporting a problem or a reader nobody reached, which is the message - somebody has to act on and outlives the turn that raised it. - - Whether retiring means removing it or writing its final state into it is - not decided here \u2014 in a Teams posts channel a deletion leaves wreckage - behind, and `_retire_rich` is where that is weighed. - """ - return ( - isinstance(content, TurnActivity) - and content.turn.status in TURN_ENDED - and not content.error_summary - and not content.notify_unreachable - ) - - # A Teams identifier standing where a person's name should be: a channel # account (`29:…`, `8:orgid:…`) or a bare Entra object id. _TEAMS_ID = re.compile( @@ -1616,7 +1593,16 @@ async def update_rich( content: RichContent, thread_root_id: str | None, ) -> None: - """Redraw a publication in place β€” or retire it, once its turn is over. + """Redraw a publication in place, including the last time. + + Nothing is taken down. A turn that has ended is edited to its final + state and stays in the conversation as the record that it ran, how long + it took and where to open it. A chat-layout channel used to delete it, + on the reasoning that a bot's own message goes there without trace and + a finished status is clutter; what went with it was the only account of + the turn anybody scrolling back could read. A posts channel already + kept it, because Teams leaves *"This message has been deleted."* behind + and that is worse than the line it replaces. Not `update_message`, for two reasons. That one replaces the whole activity with plain text, which would strip the agent's card off a @@ -1636,94 +1622,8 @@ async def update_rich( text=text, ) address = self._publication_address(channel_id, message_ref, thread_root_id) - if _retires(content): - await self._retire_rich(connector, agent_name, address, text) - return await self._edit_rich(connector, agent_name, address, text) - async def _retire_rich( - self, - connector: BotConnectorClient, - agent_name: str, - address: _Publication, - text: str, - ) -> None: - """Take a finished status out of the conversation, where that is clean. - - In a chat, a group chat, or a chat-layout channel, a bot's own message - goes without trace and a finished status is clutter: it says work is - happening about work that has stopped. A posts channel is the opposite - β€” Teams replaces a deleted message with *"This message has been - deleted."* and keeps it in the post β€” so there the status is left - showing what the turn came to, which is worth more than the line it - replaces. - - A deletion Teams refuses is not quietly treated as one that happened. - The message is still there, so it is written to its final state - instead and the refusal is logged. An outcome nobody knows is raised: - the publisher holds the anchor and can come back to it, and a status - recorded as cleaned up when it was not is one that never goes. - - Gone is the one refusal that is neither. At an address Teams itself - confirmed, a 404 says the message is not there β€” most often because an - earlier delete landed and its acknowledgement did not β€” so the cleanup - this exists to do is already done, and editing instead would ask Teams - to rewrite a message that does not exist and fail on that too, every - cycle, forever. At an address this rebuilt, the same 404 may only mean - the address was wrong, so it says nothing about whether the status is - still showing and the cleanup must stay outstanding. It is a refusal - all the same, and it leaves through the port as one: `RichContentFailed` - rather than the connector's own exception, which no caller of this port - is expecting. Logged at error, because a status may be sitting in the - conversation with no address left that Teams has confirmed. - """ - if await self._uses_post_layout( - address.channel_id, is_channel=address.in_a_channel - ): - await self._edit_rich(connector, agent_name, address, text) - return - try: - async with self._writes_to(address.conversation_id): - await connector.delete_activity( - service_url=address.service_url, - conversation_id=address.conversation_id, - activity_id=address.activity_id, - ) - except BotConnectorThrottled as error: - raise self._throttled(error, text) from error - except BotConnectorConflict as error: - raise self._conflicted(error, text) from error - except BotConnectorGone as error: - if not address.trusted: - logger.error( - "Teams has no activity %s in conversation %s, and that " - "conversation was rebuilt rather than confirmed, so " - "whether the finished status is still showing is unknown " - "and there is no other address to try.", - address.activity_id, - address.conversation_id, - ) - raise RichContentFailed( - f"Teams has no activity {address.activity_id} at the " - f"rebuilt conversation {address.conversation_id}: {error}", - text=text, - ) from error - logger.info( - "Teams has no activity %s in conversation %s; the finished " - "status is already gone, so its cleanup is complete.", - address.activity_id, - address.conversation_id, - ) - except BotConnectorRefused as error: - logger.warning( - "Teams would not remove the finished status %s in conversation " - "%s (%s); leaving its final state there instead.", - address.activity_id, - address.conversation_id, - error, - ) - await self._edit_rich(connector, agent_name, address, text) - async def _edit_rich( self, connector: BotConnectorClient, @@ -1770,8 +1670,7 @@ async def _apply_runtime_state( Superseded: `renders_legacy_runtime_state` is False, so nothing calls this. Kept until the legacy indicator is removed everywhere, because deleting one platform's copy ahead of the others makes the comparison - between them impossible to read. The layout rule below is what - `_retire_rich` now applies to an SDK status. + between them impossible to read. A "working on it…" card is posted (as the agent) while the agent works and edited in place as the activity detail changes; it stays up through diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py index c91bb67ed..b03bca87d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py @@ -456,10 +456,10 @@ def test_a_refused_edit_is_reported_rather_than_logged_and_forgotten() -> None: _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _activity(), ROOT)) -# ── Retiring a finished status, in each of the two layouts ─────────────────── +# ── A finished status stays, in both layouts ───────────────────────────────── -def test_a_finished_status_is_edited_rather_than_deleted_in_a_posts_channel() -> None: +def test_a_finished_status_is_edited_to_its_final_state_in_a_posts_channel() -> None: """Teams substitutes "This message has been deleted." and keeps it in the post, so deleting would leave one tombstone per turn per agent.""" adapter, connector = _teams("post") @@ -470,22 +470,28 @@ def test_a_finished_status_is_edited_rather_than_deleted_in_a_posts_channel() -> assert ENDED_LINE in _card_text(connector.updates[0]["activity"]) -def test_a_finished_status_is_removed_where_a_deletion_leaves_nothing() -> None: +def test_a_chat_layout_channel_keeps_the_finished_status_too() -> None: + """A bot's own message goes from a chat-layout channel without trace, which + is why the status used to be deleted there. What went with it was the + record of the turn: that it ran, how long it took, and the link to open + it.""" adapter, connector = _teams("chat") _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) - assert connector.updates == [] - assert connector.deletes[0]["activity_id"] == "MSG1" - assert connector.deletes[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + assert connector.deletes == [] + assert connector.updates[0]["activity_id"] == "MSG1" + assert connector.updates[0]["conversation_id"] == f"{CHANNEL};messageid={ROOT}" + assert ENDED_LINE in _card_text(connector.updates[0]["activity"]) -def test_a_finished_status_is_removed_from_a_chat() -> None: +def test_a_chat_keeps_it_as_well() -> None: adapter, connector = _teams(chat=True) _run(adapter.update_rich(CHAT, AGENT, "MSG1", _ended(), None)) - assert connector.deletes[0]["conversation_id"] == CHAT + assert connector.deletes == [] + assert connector.updates[0]["conversation_id"] == CHAT def test_a_request_card_is_never_taken_down() -> None: @@ -511,70 +517,16 @@ def test_a_status_still_reporting_a_problem_outlives_its_turn() -> None: assert "Disk full." in _card_text(connector.updates[0]["activity"]) -def test_a_refused_removal_leaves_the_final_state_instead_of_pretending( - caplog: pytest.LogCaptureFixture, -) -> None: - """A deletion Teams refused is not a deletion. Left as "Working…" the post - would report a turn that ended minutes ago as still running. - - A refusal, and not a 404: the message being absent is the one answer where - editing it instead cannot work, and that has its own handling below. - """ +def test_a_finished_status_is_still_redrawn_when_the_turn_says_more() -> None: + """Nothing is retired, so a late revision of a turn that has ended reaches + the conversation rather than being dropped on the floor.""" adapter, connector = _teams("chat") - connector.fail_delete = BotConnectorRefused( - "forbidden", status=403, retry_after=None - ) - - with caplog.at_level(logging.WARNING): - _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) - assert ENDED_LINE in _card_text(connector.updates[0]["activity"]) - assert "would not remove" in caplog.text - - -def test_a_removal_whose_outcome_is_unknown_is_not_recorded_as_done() -> None: - """The publisher holds the anchor and can come back to it. A status - recorded as cleaned up when it was not is one that never goes.""" - adapter, connector = _teams("chat") - connector.fail_delete = BotConnectorUnavailable( - "timeout", status=None, retry_after=None - ) - - with pytest.raises(BotConnectorUnavailable): - _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) - - assert connector.updates == [] - - -def test_confirmed_absence_finishes_the_cleanup_rather_than_editing_nothing() -> None: - """A delete that landed and whose acknowledgement did not is answered 404 - on the retry. Editing the missing message instead is refused too, so the - cleanup never settled and every later cycle tried it again.""" - adapter, connector = _teams(chat=True) - ref = _run(adapter.post_rich(CHAT, AGENT, _activity(), None)) - connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) - - _run(adapter.update_rich(CHAT, AGENT, ref, _ended(), None)) - - assert connector.updates == [] - - -def test_absence_at_an_address_this_rebuilt_is_not_taken_as_an_outcome() -> None: - """Without a stored address the 404 may only mean the address was wrong, - and a status wrongly recorded as removed is one that never goes. - - It reaches the caller as `RichContentFailed`, which is what this port - promises a refusal looks like, rather than as the connector's own exception - β€” `_edit` catches the former and nothing catches the latter. The cleanup - stays outstanding either way; that is the property, not any claim about - what is still on screen.""" - adapter, _connector = _teams("chat") - _connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) - - with pytest.raises(RichContentFailed): - _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) - assert _connector.updates == [] + assert connector.deletes == [] + assert len(connector.updates) == 2 # ── The address Teams confirmed is the one kept ────────────────────────────── @@ -663,17 +615,18 @@ def test_a_chat_redraw_after_a_restart_does_not_become_a_channel_thread() -> Non assert connector.updates[0]["conversation_id"] == CHAT -def test_a_chat_status_is_still_removed_rather_than_edited_after_a_restart() -> None: - """The same lost channel type decides whether a finished status is deleted - or left as a tombstone, so it has to come off the address too.""" +def test_a_chat_status_reaches_its_final_state_after_a_restart() -> None: + """The last redraw of a turn is the one that matters most, and a restart in + the middle of a turn is when the address is rebuilt rather than recalled.""" adapter, connector = _teams(chat=True) ref = _run(adapter.post_rich(CHAT, AGENT, _activity(), None)) _restart(adapter) _run(adapter.update_rich(CHAT, AGENT, ref, _ended(), None)) - assert connector.updates == [] - assert connector.deletes[0]["conversation_id"] == CHAT + assert connector.deletes == [] + assert connector.updates[0]["conversation_id"] == CHAT + assert ENDED_LINE in _card_text(connector.updates[0]["activity"]) def test_a_channel_reply_is_redrawn_in_its_post_after_a_restart() -> None: @@ -744,20 +697,6 @@ def test_a_transient_edit_conflict_backs_off_instead_of_reporting_failure() -> N assert raised.value.retry_after > 0 -def test_a_conflict_on_removal_is_retried_rather_than_left_as_final_state() -> None: - """The status is still there and still removable; writing its final state - instead would leave a line in a chat that clears itself.""" - adapter, connector = _teams("chat") - connector.fail_delete = BotConnectorConflict( - "changed", status=412, retry_after=None - ) - - with pytest.raises(RichContentThrottled): - _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) - - assert connector.updates == [] - - def test_writes_to_one_conversation_do_not_overlap() -> None: """Two publishers redrawing in the same conversation generate 412s against each other for as long as both keep retrying. One lock makes it a queue.""" From bdfdc8acae4939407facbd3092f0878308ee7435 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 15:20:15 +0100 Subject: [PATCH 028/120] Give a Teams card its line breaks from blocks, not from blank lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Adaptive Card TextBlock renders Markdown, where a lone newline is whitespace, so a multi-line body arrived as a run-on sentence. Doubling every lone newline fixed that by turning each line into its own paragraph: a six-line permission card read as six paragraphs, and the blank line between "1." and "2." also made Markdown render the options as a loose list, gapping them again. The body is now split into consecutive TextBlocks, a line to a block, carrying spacing "None" where a line simply follows the one above and "Small" where the body itself left a blank line. Spacing is a schema primitive with defined values in the host config rather than a guess at a Markdown renderer's newline handling. Consecutive list items stay in one block so the list keeps its numbering. Every agent message on Teams shares the builder, so ordinary prose gets the same treatment. The admin message is the one plain-text seam with no card to put blocks in, and keeps the doubling. Nothing was removed from a card: heading, handle, title, detail, numbered options, reply fallback and settled outcome are all present, in order. fallbackText and the activity summary now carry the body as written, which is what toasts and mobile read. Needs a Teams client check in both layouts, open and settled β€” the tests can only pin the JSON. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/teams/adapter.py | 35 +++++---- .../bridges/collaboration/teams/cards.py | 58 ++++++++++++-- .../test_teams_outbound_rendering.py | 76 +++++++++++++------ 3 files changed, 125 insertions(+), 44 deletions(-) diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 43990f6ae..8e1379fb4 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -286,14 +286,17 @@ def _is_usable_handle(name: str) -> bool: def _hard_wrap(text: str) -> str: - """Make single line breaks survive into Teams. - - An Adaptive Card TextBlock follows Markdown's rule that one newline is - whitespace, so a heading and the line under it arrive as one run-on - sentence. Doubling a lone newline gives the break back. Existing blank - lines are left alone β€” doubling those too would stretch every paragraph - gap β€” and list items keep their own lines, which is why this is done here - rather than by splitting the body into separate blocks. + """Make single line breaks survive into a plain-text Teams activity. + + Teams renders a bot's `text` as Markdown, where one newline is whitespace, + so a heading and the line under it arrive as one run-on sentence. Doubling + a lone newline gives the break back, at the cost of a paragraph gap in + place of every line break. Existing blank lines are left alone. + + Only the seam that has no card to work with β€” an admin message, which is + the platform speaking rather than an agent. An agent's message is an + Adaptive Card, and `cards.body_blocks` gives it the same line breaks + without the gaps. """ return _LONE_NEWLINE.sub("\n\n", text) @@ -1172,7 +1175,7 @@ async def admin_message( body = self.translate_outbound(content) thread_root_id = await self._post_to_answer_in(channel_id, thread_root_id) - activity: dict[str, Any] = {"type": "message", "text": body} + activity: dict[str, Any] = {"type": "message", "text": _hard_wrap(body)} mentions = self._mention_entities(body) if mentions: # A plain-text activity carries its mention entities directly; only @@ -1288,12 +1291,12 @@ def _draw( responder: str | None, notice: str | None, ) -> str: - """The body of a publication, as a card TextBlock will render it. + """The body of a publication, as a card will render it. - Hard-wrapped last, after the budget has been cut, because the doubled - newlines are display syntax rather than anything a reader spends their - attention on β€” and because what Teams will actually accept is measured - on the finished activity, not here. + Line breaks are the card's problem rather than this text's: the body is + written with one newline to a line and `cards.body_blocks` turns those + into blocks. So the budget here is measured on what a reader actually + reads, with no display syntax counted against it. """ escape = self._rich_escape limit = self.rich_fallback_limit() @@ -1334,7 +1337,7 @@ def _draw( unavailable_reason=content.unavailable_reason, ) drawn = f"{lead}{body}{tail}" - return _hard_wrap(drawn) + return drawn def _mention(self, external_id: str | None) -> str | None: """`` markup naming whoever holds this AAD id, or None. @@ -2027,7 +2030,7 @@ def _known_name_pattern(self) -> re.Pattern[str] | None: return self._mention_pattern def translate_outbound(self, content: str) -> str: - return _hard_wrap(self._mark_mentions(content)) + return self._mark_mentions(content) def escape_label_for_body(self, label: str) -> str: """Add the `` tag to what the base class already defuses. diff --git a/core/switch_core/bridges/collaboration/teams/cards.py b/core/switch_core/bridges/collaboration/teams/cards.py index 7de5e8f1c..664313616 100644 --- a/core/switch_core/bridges/collaboration/teams/cards.py +++ b/core/switch_core/bridges/collaboration/teams/cards.py @@ -1,11 +1,59 @@ from __future__ import annotations +import re from typing import Any from switch_core.bridges.collaboration.adapter import AgentRendering ADAPTIVE_CARD_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive" +# A line markdown would set as a list item: a bullet or a number, indented by +# less than the four spaces that would make it a code block instead. +_LIST_ITEM = re.compile(r"^ {0,3}(?:[-*+]|\d+[.)]) ") + + +def body_blocks(body: str) -> list[dict[str, Any]]: + """A message body as consecutive TextBlocks, a line to a block. + + One TextBlock renders markdown, where a lone newline is whitespace: a + heading and the line under it arrive as one run-on sentence. Doubling the + newline gives the break back but pays a full paragraph gap for it, so a + six-line card is read as six paragraphs. Separate blocks give the break + back and let `spacing` say what kind of break it is β€” `None` for a line + that simply follows the one above, `Small` where the body itself left a + blank line. + + Consecutive list items stay in one block, so they render as one list with + its numbering intact rather than as several lists of one item each. + Markdown already keeps those on their own lines. + """ + runs: list[tuple[str, list[str]]] = [] + # The first block sits against the card header, which is a gap of its own. + gap = True + for line in body.split("\n"): + if not line.strip(): + gap = True + continue + if ( + not gap + and runs + and _LIST_ITEM.match(line) + and _LIST_ITEM.match(runs[-1][1][-1]) + ): + runs[-1][1].append(line) + continue + runs.append(("Small" if gap else "None", [line])) + gap = False + return [ + { + "type": "TextBlock", + "text": "\n".join(lines), + "wrap": True, + "spacing": spacing, + } + for spacing, lines in runs + ] + def agent_message_card( agent: AgentRendering, @@ -26,6 +74,10 @@ def agent_message_card( is an ordinary TextBlock and so renders the markdown subset, while ``altText`` is read out verbatim by a screen reader. + The body becomes one TextBlock per line rather than one for the whole of it + β€” see ``body_blocks`` for why. ``fallbackText`` keeps the body as it came + in, because nothing renders it as a card. + ``mentions`` are Bot Framework mention entities matching ```` markup in ``body``. A card carries them under ``msteams`` rather than on the activity, and without them the markup renders as inert text and the person is never @@ -71,11 +123,7 @@ def agent_message_card( }, ], }, - { - "type": "TextBlock", - "text": body, - "wrap": True, - }, + *body_blocks(body), ], } if mentions: diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py b/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py index 1e7b8966c..adaaaf162 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py @@ -14,9 +14,14 @@ from switch_core.bridges.collaboration.teams.adapter import ( TeamsAdapter, TeamsConnectionConfig, + _hard_wrap, ) from switch_core.bridges.collaboration.teams.cards import agent_message_card +_RENDERING = AgentRendering( + field_label="james", body_label="james", icon_url="http://icon" +) + def _run(coro: Any) -> Any: loop = asyncio.new_event_loop() @@ -144,11 +149,7 @@ def test_the_card_carries_mentions_where_teams_looks_for_them() -> None: def test_a_card_with_no_mentions_carries_no_msteams_block() -> None: - agent = AgentRendering( - field_label="james", body_label="james", icon_url="http://icon" - ) - - assert "msteams" not in agent_message_card(agent, "hello", []) + assert "msteams" not in agent_message_card(_RENDERING, "hello", []) # ── the app's own handle ───────────────────────────────────────────────────── @@ -169,33 +170,62 @@ def test_the_app_is_named_in_words_not_in_slack_syntax() -> None: # ── line breaks ────────────────────────────────────────────────────────────── +def _lines(body: str) -> list[tuple[str, str]]: + """Every body block of the card, as (spacing, text).""" + card = agent_message_card(_RENDERING, body, []) + return [(str(block["spacing"]), str(block["text"])) for block in card["body"][1:]] + + def test_a_single_newline_survives() -> None: # Adaptive Cards follow Markdown: one newline is whitespace. This is the - # heading that ran into the sentence under it. - adapter = _adapter() + # heading that ran into the sentence under it. It gets its break from a + # block of its own, so the line beneath sits under it rather than a + # paragraph away. + assert _lines("**Heading:**\nbody") == [ + ("Small", "**Heading:**"), + ("None", "body"), + ] - assert adapter.translate_outbound("**Heading:**\nbody") == ("**Heading:**\n\nbody") +def test_a_gap_the_body_asked_for_is_the_only_gap_there_is() -> None: + assert _lines("one\ntwo\n\nthree") == [ + ("Small", "one"), + ("None", "two"), + ("Small", "three"), + ] -def test_an_existing_paragraph_gap_is_not_widened() -> None: - adapter = _adapter() - assert adapter.translate_outbound("one\n\ntwo") == "one\n\ntwo" +def test_list_items_stay_in_one_block_so_they_stay_one_list() -> None: + # Split a block apiece and each item is its own one-item list, which + # restarts the numbering and indents each one separately. + assert _lines("pick one:\n1. one\n2. two") == [ + ("Small", "pick one:"), + ("None", "1. one\n2. two"), + ] -def test_list_items_keep_their_own_lines() -> None: - adapter = _adapter() +def test_a_body_with_no_newlines_is_one_block() -> None: + assert _lines("just a sentence") == [("Small", "just a sentence")] - rendered = adapter.translate_outbound("- one\n- two") - assert rendered == "- one\n\n- two" - assert rendered.count("- ") == 2 +def test_the_text_itself_is_left_alone() -> None: + # The breaks are the card's doing, so nothing is written into the body to + # get them β€” and `fallbackText`, which no card renders, is the body as the + # agent wrote it. + adapter = _adapter() + body = "**Heading:**\nbody" + assert adapter.translate_outbound(body) == body + assert agent_message_card(_RENDERING, body, [])["fallbackText"].endswith(body) -def test_a_body_with_no_newlines_is_unchanged() -> None: - adapter = _adapter() - assert adapter.translate_outbound("just a sentence") == "just a sentence" +def test_the_plain_text_seam_still_doubles_because_it_has_no_blocks() -> None: + # An admin message is Teams speaking as itself: a plain-text activity, no + # Adaptive Card, and so nowhere to put a line break but the text. A + # paragraph gap per break is worse than a block and far better than the + # run-on sentence this started as. + assert _hard_wrap("**Heading:**\nbody") == "**Heading:**\n\nbody" + assert _hard_wrap("one\n\ntwo") == "one\n\ntwo" # ── translated once, not twice ─────────────────────────────────────────────── @@ -204,16 +234,16 @@ def test_a_body_with_no_newlines_is_unchanged() -> None: def test_send_message_does_not_translate_again() -> None: # Callers of send_message translate first, by documented contract. The # adapter used to translate a second time, which is invisible while - # translation is a no-op and corrupting the moment it is not β€” doubling - # every newline again, and re-marking text that was already marked. + # translation is a no-op and corrupting the moment it is not β€” re-marking + # text that was already marked. adapter = _adapter(alice="aad-alice") already = adapter.translate_outbound("hi @alice\nthere") activity = _run(adapter._message_activity("james", already)) + blocks = activity["attachments"][0]["content"]["body"][1:] - assert activity["attachments"][0]["content"]["body"][-1]["text"] == already + assert [block["text"] for block in blocks] == ["hi alice", "there"] assert "" not in already - assert "\n\n\n" not in already # ── commands ───────────────────────────────────────────────────────────────── From 6131b721336a80554eaa092c6d32a5665dba8317 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 15:58:26 +0100 Subject: [PATCH 029/120] R7: say a queued prompt is queued, and stop calling an unacknowledged one failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two strings, both in the shared vocabulary every renderer reads. "Received. Waiting for the agent…" said what Switch had done, not what the reader was waiting on. The Console already calls this state "Message queued…"; the channels now agree with it and say the turn has not started rather than that a message arrived. The second is the one that mattered. A command whose acknowledgement never came back is recorded as unknown β€” "The command was not confirmed. It will not be resent." β€” and a provisional receipt has no status to carry that as except an error. The channel then read that error off the status and told the reader the agent could not complete the request, which asserts something nobody here knows: it may have run in full. The sentence is now the truth and the fact they can act on, and a genuine refusal keeps the wording it had. A queued reaction on the platforms that support one is deliberately not here: it would be a second reaction lifecycle on top of a mark-claim path with two open findings against it. Co-Authored-By: Claude Opus 5 --- .../session/renderers/__init__.py | 2 +- core/switch_core/sessions/presentation.py | 14 +++- core/switch_core/sessions/publication.py | 13 +++- .../collaboration/test_session_activity.py | 2 +- .../test_session_turn_summary.py | 2 +- .../sessions/test_activity_durability.py | 72 ++++++++++++++++++- .../sessions/test_session_presentation.py | 22 +++++- 7 files changed, 117 insertions(+), 10 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index 9cb8f00fe..db32783f4 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -29,7 +29,7 @@ # which of the two a reader is looking at. Plain sentences, not markup, so # every renderer reads the same wording rather than each keeping its own copy. TURN_STATE = { - "queued": "Received. Waiting for the agent…", + "queued": "Queued. Waiting for the agent to start…", "running": "Working…", "completed": "Turn complete.", "interrupted": "Turn interrupted.", diff --git a/core/switch_core/sessions/presentation.py b/core/switch_core/sessions/presentation.py index fffcfe4ec..2f453cee4 100644 --- a/core/switch_core/sessions/presentation.py +++ b/core/switch_core/sessions/presentation.py @@ -89,9 +89,19 @@ async def initiator() -> str | None: def activity_error_summary( - turn: TurnUpsert, session: Session, *, online: bool + turn: TurnUpsert, session: Session, *, online: bool, unconfirmed: bool ) -> str | None: - """Describe state without leaking provider notices or session-private output.""" + """Describe state without leaking provider notices or session-private output. + + `unconfirmed` is the one state that is neither running nor finished: the + command left Switch and no acknowledgement came back, so whether the agent + ever saw it is unknown and stays unknown. It reads as a failure otherwise β€” + the turn is carried as an error for want of anywhere else to put it β€” and + saying the request could not be completed asserts something nobody here + knows. What the reader can act on is that Switch will not resend it. + """ + if unconfirmed: + return "Switch could not confirm the agent received this. It will not be resent; send it again if you still want it." if turn.status == "error": return "The agent could not complete this request. Open Switch Console for details." if turn.status not in {"queued", "running"}: diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 49a41411a..8893a454f 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -568,6 +568,9 @@ async def refresh_activity( turns = list(snapshot.turns) latest_turn_id = turns[-1].turn_id if turns else None known_commands = {turn.command_id for turn in turns} + # Turns carried as errors only because the command was never + # acknowledged, which is not the same thing as one that failed. + unconfirmed: set[str] = set() # A presentation-only queued turn acknowledges input this bridge itself # accepted, before the SDK reports a turn. Scoped to commands that came # in on `surface` because that is where the acknowledgement would go: a @@ -596,10 +599,13 @@ async def refresh_activity( status = pending_command.status["status"] if status not in ("accepted", "dispatched", "unknown", "rejected"): continue + turn_id = f"pending:{command.command_id}" + if status == "unknown": + unconfirmed.add(turn_id) turns.append( TurnUpsert( type="turn.upsert", - turn_id=f"pending:{command.command_id}", + turn_id=turn_id, command_id=command.command_id, status="queued" if status in ("accepted", "dispatched") @@ -614,7 +620,10 @@ async def refresh_activity( if turn.status == "running" and activity.redraws_for_elapsed_time: revisions += (int(time.monotonic() // 5),) error_summary = activity_error_summary( - turn, snapshot.session, online=online + turn, + snapshot.session, + online=online, + unconfirmed=turn.turn_id in unconfirmed, ) state = ( turn.status + (":" + error_summary if error_summary else ""), diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 89f014635..a69f4db04 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -285,7 +285,7 @@ async def test_the_last_line_says_whether_the_turn_is_still_moving() -> None: items = await _items() assert _state(items, _turn("running")) == "Working…" - assert _state(items, _turn("queued")) == "Received. Waiting for the agent…" + assert _state(items, _turn("queued")) == "Queued. Waiting for the agent to start…" async def test_a_turn_with_nothing_done_in_it_still_says_where_it_got_to() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_turn_summary.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_summary.py index d296455e8..4953774f6 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_turn_summary.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_turn_summary.py @@ -151,5 +151,5 @@ def test_nothing_said_and_no_tool_calls_is_still_just_the_state() -> None: assert ( turn_summary([], turn, escape=_identity, limit=10_000) - == "Received. Waiting for the agent…" + == "Queued. Waiting for the agent to start…" ) diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 1225dd162..066c448a0 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -21,7 +21,12 @@ SlackAdapter, SlackConnectionConfig, ) -from switch_core.db.models import SdkSession, SessionActivityPost, require_tenant_id +from switch_core.db.models import ( + SdkSession, + SdkSessionCommand, + SessionActivityPost, + require_tenant_id, +) from switch_core.sessions import publication from switch_core.sessions.publication import SessionPublisher @@ -31,7 +36,7 @@ from ..bridges.collaboration.test_mattermost_sdk_only import _http_error from ..bridges.collaboration.test_mattermost_sdk_only import _posts as mm_posts from ..bridges.collaboration.test_session_activity import _items, _turn -from .test_authority import host_event, opened, setup +from .test_authority import command, host_event, opened, setup from .test_publication import Platform from .test_publication_retries import cards_for @@ -849,6 +854,69 @@ async def test_pending_command_cannot_hide_recorded_completion_or_replay_stale_e assert platform.post_count == (10 if pending_status == "accepted" else 6) +@pytest.mark.parametrize( + "final_status,says,not_says", + [ + ("unknown", "could not confirm", "could not complete"), + ("rejected", "could not complete", "could not confirm"), + ], +) +async def test_an_unacknowledged_command_is_not_reported_as_one_the_agent_failed( + session_factory, final_status, says, not_says +): + """A queued prompt that loses its acknowledgement, and one the host refused. + + Both are carried as an error turn, because a provisional receipt has no + other status to be carried as, so the sentence in the channel cannot be + read off that status. A refusal did reach the host and came back no; an + unacknowledged command may have run in full. Telling the second as the + first asserts something nobody here knows, and buries the one fact the + reader can act on β€” that Switch will not send it again. + """ + service, epoch = await setup(session_factory) + await opened(service, epoch) + platform = ActivitySlack() + publisher = SessionPublisher( + session_factory, + "bridge", + cards_for(session_factory, Platform()), + activity(session_factory, platform), + ) + await publisher.publish_pending() + await service.submit( + command( + epoch, + "pending-command", + { + "type": "message.send", + "text": "Later message", + "attachments": [], + "delivery": "queue", + }, + actor="@owner:example.test", + surface="slack", + ), + user_id=None, + bridge_id="bridge", + ) + await publisher.publish_pending() + assert any( + "queued" in message.text.lower() for message in platform.messages.values() + ) + + async with session_factory() as db: + row = await db.get( + SdkSessionCommand, (require_tenant_id(), "session-demo", "pending-command") + ) + row.status = {**row.status, "status": final_status} + await db.commit() + await publisher.publish_pending() + + said = " ".join(message.text.lower() for message in platform.messages.values()) + assert says in said + assert not_says not in said + + async def test_reaction_failure_retries_without_blocking_log(session_factory): from unittest.mock import AsyncMock diff --git a/core/tests/switch_core/sessions/test_session_presentation.py b/core/tests/switch_core/sessions/test_session_presentation.py index cc38adf17..1942635de 100644 --- a/core/tests/switch_core/sessions/test_session_presentation.py +++ b/core/tests/switch_core/sessions/test_session_presentation.py @@ -163,7 +163,7 @@ def test_error_summary_uses_only_state(turn_status, session_status, online, expe type="turn.upsert", turn_id="turn", command_id="command", status=turn_status ) result = activity_error_summary( - turn, SimpleNamespace(status=session_status), online=online + turn, SimpleNamespace(status=session_status), online=online, unconfirmed=False ) if expected is None: assert result is None @@ -171,6 +171,26 @@ def test_error_summary_uses_only_state(turn_status, session_status, online, expe assert expected in result +def test_an_unacknowledged_command_is_not_reported_as_a_failed_one(): + """Nobody here knows whether the agent saw it, so nothing may claim it didn't. + + The turn is carried as an error because there is no other status to carry + it as, which is exactly why the sentence cannot be read off the status. + """ + turn = TurnUpsert( + type="turn.upsert", turn_id="turn", command_id="command", status="error" + ) + + summary = activity_error_summary( + turn, SimpleNamespace(status="ready"), online=True, unconfirmed=True + ) + + assert summary is not None + assert "could not complete" not in summary + assert "could not confirm" in summary + assert "not be resent" in summary + + @pytest.mark.parametrize( "surface,actor,thread,recipient", [ From 30fa0f9cd0aa5f1a108bfcef4eba365c85bbc442 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 16:18:47 +0100 Subject: [PATCH 030/120] Being asked to wait is not the destination refusing the card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card that cannot be posted is retried on a widening wait, and once that wait stretches to its ten-minute cap the destination is taken to be gone: the card is given up on and the request is skipped from then on. That bound exists for a deleted channel or a bot that has been removed. `RichContentThrottled` is a `RichContentFailed`, so `post` wrapped it as `CardRefused` along with everything else and a rate-limited channel spent the same bound. Seven 429s in a row and the permission card was never posted again until the bridge restarted β€” worse than the spin the bound replaced, and for a channel that was only ever going to say yes a moment later. A throttle now leaves `post` as itself, having released its handle exactly as a refusal does, and `refresh_cards` honours the platform's own Retry-After through `post_delayed` rather than counting the wait against a budget that decides a destination is gone. The remaining question β€” whether a destination that recovers after ten minutes should have to wait for a restart β€” is a recovery policy decision and is left where it belongs. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 10 ++++ core/switch_core/sessions/publication.py | 13 +++++ .../sessions/test_publication_retries.py | 51 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index e8de82b2c..08950cc17 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -170,6 +170,12 @@ class CardRefused(CardNotPosted): adds is that the destination itself answered: the reservation is gone and a later attempt starts again from nothing, which is what lets a caller bound how many times it is worth starting. + + Being asked to wait is not that answer, so a `RichContentThrottled` comes + back out as itself. It says the card may well be takeable and to come back + later; counting it here would spend the same bound that decides a + destination is gone, and a busy channel would end up permanently + undeliverable for being busy. """ @@ -1403,6 +1409,10 @@ async def post( ref = await self._adapter.post_rich( channel_id, agent_name, card, None ) + except RichContentThrottled: + await session.delete(post) + await session.commit() + raise except RichContentFailed as error: await session.delete(post) await session.commit() diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 8893a454f..453563849 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -103,6 +103,7 @@ async def refresh_cards( post_allowed: Callable[[str], bool] = _always_recover, post_succeeded: Callable[[str], None] = _ignore_recovery, post_spent: Callable[[str], bool] = _never_spent, + post_delayed: Callable[[str, float], None] = _ignore_delay, refresh_needed: Callable[[str, tuple[int, str]], bool] = _always_refresh, refreshed: Callable[[str, tuple[int, str]], None] = _ignore_refresh, ) -> None: @@ -129,6 +130,13 @@ async def refresh_cards( this session's publication on every later cycle. A caller that passes nothing keeps the old behaviour β€” every refusal raised, forever. + `post_delayed` carries the platform's own Retry-After back to that wait, + and is the reason being rate limited cannot end in `post_spent`. A throttle + says the channel is busy, not that it is gone; if it stretched the same + wait, a channel busy enough for long enough would be written off as + undeliverable for the crime of being busy, and the card would never be + posted again. + `refresh_needed` gates redrawing an already-confirmed card, per token and `(revision, state)`, and `refreshed` is told once one lands. Both parts of that pair matter: `request.submitting` moves a request from `open` to @@ -292,6 +300,10 @@ async def refresh_cards( notify_unreachable=unreachable, unavailable_reason=unavailable_reason, ) + except RichContentThrottled as throttled: + post_delayed(attempt, throttled.retry_after) + backed_off += 1 + continue except CardRefused as refusal: if not post_spent(attempt): raise @@ -1090,6 +1102,7 @@ async def publish_pending(self) -> None: post_allowed=self._card_post.allowed, post_succeeded=self._card_post.succeeded, post_spent=self._card_post.spent, + post_delayed=self._card_post.delay, refresh_needed=self._redraw.needed, refreshed=self._redraw.drawn, ) diff --git a/core/tests/switch_core/sessions/test_publication_retries.py b/core/tests/switch_core/sessions/test_publication_retries.py index 7996e3525..b5af18e29 100644 --- a/core/tests/switch_core/sessions/test_publication_retries.py +++ b/core/tests/switch_core/sessions/test_publication_retries.py @@ -15,7 +15,10 @@ get_collab_lifecycle, get_session_factory, ) -from switch_core.bridges.collaboration.adapter import RichContentFailed +from switch_core.bridges.collaboration.adapter import ( + RichContentFailed, + RichContentThrottled, +) from switch_core.bridges.collaboration.bridge_core import BridgeCore from switch_core.bridges.collaboration.session.outbound import ( CardNotPosted, @@ -794,6 +797,52 @@ def fake_monotonic() -> float: assert "card publication failed" not in caplog.text +async def test_a_channel_that_only_asks_us_to_slow_down_is_never_given_up_on( + session_factory, monkeypatch, caplog +): + """Being rate limited is not a destination refusing the card. + + A throttle and a deleted channel arrived here as the same exception, so a + busy channel spent the same bounded attempts a gone one does and was then + written off: the request would never be asked again, in a channel that was + only ever going to say yes a moment later. The platform's own Retry-After + is honoured instead, and the wait that decides a destination is gone is + left where it was.""" + clock = 0.0 + + def fake_monotonic() -> float: + return clock + + monkeypatch.setattr(publication.time, "monotonic", fake_monotonic) + service, epoch = await setup(session_factory) + await opened(service, epoch) + platform = RecoverablePlatform() + throttled = AsyncMock( + side_effect=RichContentThrottled(retry_after=120.0, text="please wait") + ) + monkeypatch.setattr(platform, "post_rich", throttled) + publisher = SessionPublisher( + session_factory, "bridge", cards_for(session_factory, platform) + ) + + await publisher.publish_pending() + assert throttled.await_count == 1 + async with session_factory() as db: + # Nothing was posted, so the handle goes back exactly as for a refusal. + assert (await db.scalars(select(SessionRequestPost))).all() == [] + + clock += 119.0 + await publisher.publish_pending() + assert throttled.await_count == 1 # the platform said 120 seconds + + for _ in range(12): + clock += 121.0 + await publisher.publish_pending() + + assert throttled.await_count == 13 + assert "Giving up posting the card" not in caplog.text + + # ── A confirmed card is only redrawn when something about it changed ──────── From 5618b32df45a359b6bc674e9d2c133fbd957aaf6 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 16:18:59 +0100 Subject: [PATCH 031/120] Teams: bound what a line-per-block body costs to say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting a card body a line to a TextBlock is what gives Teams its line breaks back without paying a paragraph gap for each one. It also costs about 140 bytes of JSON per line against the 64 KiB the connector allows an activity, and that is charged whether the line is one word or a hundred. So a thousand-character message written as five hundred short lines measured 85,950 bytes and was refused outright β€” for the shape it was drawn in rather than for anything the sender wrote. Every multi-line agent message shared this, not only permission cards. Past 8 KiB of structure the body is set as a single block with its breaks doubled instead. Every line survives, in order, on a line of its own; the gaps between them widen. A long message is judged on its own length again. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/teams/cards.py | 28 +++++++++++++++++ .../test_teams_outbound_rendering.py | 31 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/core/switch_core/bridges/collaboration/teams/cards.py b/core/switch_core/bridges/collaboration/teams/cards.py index 664313616..2dd7a37f5 100644 --- a/core/switch_core/bridges/collaboration/teams/cards.py +++ b/core/switch_core/bridges/collaboration/teams/cards.py @@ -11,6 +11,19 @@ # less than the four spaces that would make it a code block instead. _LIST_ITEM = re.compile(r"^ {0,3}(?:[-*+]|\d+[.)]) ") +# A TextBlock costs roughly this much JSON around whatever line it carries, +# measured the way Teams measures an activity β€” its keys are ASCII, so UTF-16 +# counts each of them twice. +_BLOCK_OVERHEAD = 140 + +# What all that structure may add up to. The connector refuses an activity over +# 64 KiB on the same metric, and a body split a line to a block spends the +# budget on punctuation: a thousand characters written as five hundred short +# lines cost seventy kilobytes of braces to say. Past this the body is set as +# one block again, so a long message is judged on its own length rather than on +# the shape it was drawn in. +_BLOCK_BUDGET = 8 * 1024 + def body_blocks(body: str) -> list[dict[str, Any]]: """A message body as consecutive TextBlocks, a line to a block. @@ -26,6 +39,12 @@ def body_blocks(body: str) -> list[dict[str, Any]]: Consecutive list items stay in one block, so they render as one list with its numbering intact rather than as several lists of one item each. Markdown already keeps those on their own lines. + + A body with more lines than `_BLOCK_BUDGET` pays for is set as a single + block with its breaks doubled instead. Every line survives, in order; what + changes is that each gets a paragraph's gap rather than a line's. That is + worth it against the alternative, which is the whole message refused for + being made of too many short lines. """ runs: list[tuple[str, list[str]]] = [] # The first block sits against the card header, which is a gap of its own. @@ -44,6 +63,15 @@ def body_blocks(body: str) -> list[dict[str, Any]]: continue runs.append(("Small" if gap else "None", [line])) gap = False + if len(runs) * _BLOCK_OVERHEAD > _BLOCK_BUDGET: + return [ + { + "type": "TextBlock", + "text": "\n\n".join("\n".join(lines) for _, lines in runs), + "wrap": True, + "spacing": "None", + } + ] return [ { "type": "TextBlock", diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py b/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py index adaaaf162..f47e8b1e2 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py @@ -16,7 +16,11 @@ TeamsConnectionConfig, _hard_wrap, ) -from switch_core.bridges.collaboration.teams.cards import agent_message_card +from switch_core.bridges.collaboration.teams.cards import ( + agent_message_card, + card_attachment, +) +from switch_core.bridges.collaboration.teams.connector import _payload _RENDERING = AgentRendering( field_label="james", body_label="james", icon_url="http://icon" @@ -208,6 +212,31 @@ def test_a_body_with_no_newlines_is_one_block() -> None: assert _lines("just a sentence") == [("Small", "just a sentence")] +def test_a_body_of_many_short_lines_is_not_refused_for_its_punctuation() -> None: + """Five hundred short lines is about a thousand characters of message. + + Set a line to a block it was seventy kilobytes of JSON braces, past the + connector's limit, and the whole message was refused β€” for the shape it + was drawn in rather than for anything the sender wrote. Every line is + still here and still on a line of its own; the gaps between them widen. + """ + body = "\n".join(f"line {n}" for n in range(500)) + card = agent_message_card(_RENDERING, body, []) + + assert len(card["body"][1:]) == 1 + text = str(card["body"][1]["text"]) + assert text.startswith("line 0\n\nline 1\n\n") + assert text.endswith("line 499") + _payload("send", {"type": "message", "attachments": [card_attachment(card)]}) + + +def test_a_body_short_enough_to_afford_its_blocks_still_gets_them() -> None: + body = "\n".join(f"line {n}" for n in range(20)) + + assert _lines(body)[:2] == [("Small", "line 0"), ("None", "line 1")] + assert len(_lines(body)) == 20 + + def test_the_text_itself_is_left_alone() -> None: # The breaks are the card's doing, so nothing is written into the body to # get them β€” and `fallbackText`, which no card renders, is the body as the From 7bf2aea560fe9aed9d938930f808f7b5d8300db8 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 16:35:28 +0100 Subject: [PATCH 032/120] Stop the neutral status reporting activity it is not reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact status line is the one thing a platform edits in place while a turn runs, so it is the one place where a wrong tense stays on screen. Two findings against it turn out to be the same thing: the tool log leaking into a line that is not the tool log. `current_tool` said only "draw the Now:/Last: line". Widen it to `tool_detail` β€” does this platform report tool activity at all β€” and thread it into `turn_state` as a required keyword-only argument, since every caller knows the answer and a default would decide it silently for the next platform added. A turn that ended no longer counts an unclosed call as present-tense activity. The item keeps the status the host gave it, because the host is the only thing that knows how the call ended; what goes is a tally saying "1 running" under a headline saying the turn is over. `turn_state` already reports what the turn left unfinished, in the tense that belongs to it. Where a platform declines tool detail, the healthy counts and the clean-completion total go with it. A call that failed or was declined stays: that is an outcome somebody has to act on, not chatter about progress. Nothing called `neutral.turn_status` in the tests, which is why the tense bug survived, so the new file covers the neutral renderer directly rather than through one adapter. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 2 +- .../collaboration/mattermost/adapter.py | 2 +- .../session/renderers/__init__.py | 18 +++- .../session/renderers/neutral.py | 41 ++++++--- .../collaboration/session/renderers/slack.py | 14 ++- .../bridges/collaboration/teams/adapter.py | 2 +- .../bridges/collaboration/telegram/adapter.py | 2 +- .../collaboration/test_session_activity.py | 14 +-- .../collaboration/test_session_turn_status.py | 91 +++++++++++++++++++ .../collaboration/test_telegram_sdk_only.py | 35 ++++++- 10 files changed, 186 insertions(+), 35 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_session_turn_status.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 1337c360c..fb26a145f 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -872,7 +872,7 @@ def _draw( session_url=content.session_url, mention=mention, error_summary=content.error_summary, - current_tool=True, + tool_detail=True, ) + tail ) diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 4dcee6440..07d1d8ac6 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -669,7 +669,7 @@ def _draw( session_url=content.session_url, mention=mention, error_summary=content.error_summary, - current_tool=True, + tool_detail=True, ) + tail ) diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index db32783f4..169b8bb03 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -38,7 +38,11 @@ def turn_state( - items: list[Item], turn: TurnUpsert, *, elapsed_seconds: float | None = None + items: list[Item], + turn: TurnUpsert, + *, + tool_detail: bool, + elapsed_seconds: float | None = None, ) -> str: """Where a turn got to, and what it left behind if it stopped. @@ -56,6 +60,12 @@ def turn_state( errored turn keeps its own phrase, since that is still worth knowing on its own, with the same account appended: the run still took the time it took either way. + + `tool_detail` says whether this platform reports tool activity at all. + Where it does not, how many calls a turn made is the detail it is not + reporting β€” the duration stays, because that is the turn's own. What a + turn left unfinished is not covered by it: that is an outcome, and it is + reported wherever the turn is. """ state = TURN_STATE[turn.status] if turn.status not in TURN_ENDED: @@ -63,7 +73,7 @@ def turn_state( return f"{state} {_format_duration(elapsed_seconds)}" return state if elapsed_seconds is not None: - worked = _worked_for(elapsed_seconds, items) + worked = _worked_for(elapsed_seconds, items, tool_detail=tool_detail) state = worked if turn.status == "completed" else f"{state} {worked}" unfinished = sum(1 for item in items if item.status == "in-progress") if not unfinished: @@ -72,11 +82,11 @@ def turn_state( return f"{state} {unfinished} {step} left unfinished." -def _worked_for(elapsed_seconds: float, items: list[Item]) -> str: +def _worked_for(elapsed_seconds: float, items: list[Item], *, tool_detail: bool) -> str: """How long a turn ran, and how much of that was tool calls.""" calls = sum(1 for item in items if item.kind == "tool-activity") worked = f"Worked for {_format_duration(elapsed_seconds)}." - if not calls: + if not calls or not tool_detail: return worked noun = "call" if calls == 1 else "calls" return f"{worked} {calls} tool {noun}." diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 7a09ef82b..49b95b0cf 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -140,7 +140,7 @@ def turn_summary( cut when the budget is tight is what was said, not whether the turn is still going. """ - state = turn_state(items, turn) + state = turn_state(items, turn, tool_detail=True) said = [item for item in items if item.kind == "assistant-message"] if not said: return _truncate(state, limit) @@ -169,7 +169,7 @@ def turn_status( session_url: str | None = None, mention: str | None = None, error_summary: str | None = None, - current_tool: bool, + tool_detail: bool, ) -> str: """A turn's progress, compact enough to live in one message that is edited. @@ -186,12 +186,15 @@ def turn_status( - what it is doing right now, while it is still doing something; - how the tool calls went, once there is more than one outcome to report. - `current_tool` is the middle one, and a platform can decline it. Where the - status shares the conversation with everything else that is said there, a - line naming the tool of the moment is the part that reads as noise: the - state and its link are the record, and what the turn is doing is in - Console. The outcome tally is not covered by it β€” a call that failed is - not chatter about progress. + `tool_detail` says whether this platform reports tool activity at all, and + a platform can decline it. Where the status shares the conversation with + everything else that is said there, the tool of the moment and a count of + healthy calls are both noise: the state, its duration and its link are the + record, and what the turn did is in Console. + + What survives declining it is what a reader has to act on β€” a call that + failed or was declined is an outcome, not chatter about progress, and it + is reported wherever the turn is. `error_summary` is the attention slot rather than the status: a distinct problem somebody has to act on, said in one sentence with the mention that @@ -208,7 +211,9 @@ def turn_status( ) budget = _room(limit, mention) - state = turn_state(items, turn, elapsed_seconds=elapsed_seconds) + state = turn_state( + items, turn, tool_detail=tool_detail, elapsed_seconds=elapsed_seconds + ) head = markup.bold(state) link = _link(_CONSOLE, session_url, markup) if link and len(head) + 3 + len(link) <= budget: @@ -218,7 +223,7 @@ def turn_status( spent = len(head) did = [item for item in items if item.kind == "tool-activity"] for line in _doing( - did, turn, escape=escape, budget=budget, current_tool=current_tool + did, turn, escape=escape, budget=budget, tool_detail=tool_detail ): if spent + len(line) + 1 > budget: break @@ -233,7 +238,7 @@ def _doing( *, escape: Callable[[str], str], budget: int, - current_tool: bool, + tool_detail: bool, ) -> list[str]: """The optional lines under the state: what is running, and how it is going. @@ -241,12 +246,18 @@ def _doing( gets its total from `turn_state` ("Worked for 2m 5s. 7 tool calls."), so the only count worth adding is one the total hides β€” a call that failed or was declined reads as a completed turn otherwise. + + Nothing here says a finished turn is running. A call the host never closed + keeps the status the host gave it, because the host is the only thing that + knows how it ended, but counting it as present-tense activity under a + headline saying the turn is over describes a turn nobody has. What the turn + left behind is `turn_state`'s to report, in the tense that belongs to it. """ if not did: return [] lines: list[str] = [] ended = turn.status in TURN_ENDED - if current_tool and not ended: + if tool_detail and not ended: current = next( (item for item in reversed(did) if item.status == "in-progress"), None ) @@ -259,8 +270,12 @@ def _doing( status: sum(1 for item in did if item.status == status) for status in _OUTCOME } unwell = counts["failed"] + counts["declined"] - if ended and not unwell: + if not unwell and (ended or not tool_detail): return lines + if ended or not tool_detail: + counts["in-progress"] = 0 + if not tool_detail: + counts["completed"] = 0 tally = " Β· ".join( f"{_OUTCOME[status]} {count} {_OUTCOME_WORDS[status]}" for status, count in counts.items() diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index aacf918d2..d1943b520 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -927,7 +927,9 @@ def render_activity( just said. """ if status_only: - state = turn_state(items, turn, elapsed_seconds=elapsed_seconds) + state = turn_state( + items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds + ) if turn.status not in TURN_ENDED: return SlackMessage( text=state, @@ -988,7 +990,9 @@ def render_activity( if did: blocks.append(_plan(items, did, turn, elapsed_seconds=elapsed_seconds)) else: - state = turn_state(items, turn, elapsed_seconds=elapsed_seconds) + state = turn_state( + items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds + ) blocks.append(_context(f"_{state}_")) return SlackMessage( text=render_activity_text(items, turn, elapsed_seconds=elapsed_seconds), @@ -1053,7 +1057,9 @@ def render_activity_text( lines.append(f"…{hidden} earlier in this turn, not shown.") lines += [_message_text(item) for item in said[len(said) - _MAX_MESSAGES :]] lines += _activity_lines(did) - lines.append(turn_state(items, turn, elapsed_seconds=elapsed_seconds)) + lines.append( + turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds) + ) return "\n".join(_within(lines, _MAX_TEXT)) @@ -1121,7 +1127,7 @@ def _plan( """ kept = did[len(did) - _MAX_PLAN_TASKS :] dropped = len(did) - len(kept) - title = turn_state(items, turn, elapsed_seconds=elapsed_seconds) + title = turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds) if dropped: step = "step" if dropped == 1 else "steps" title = f"{title} …{dropped} earlier {step}, not shown." diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 8e1379fb4..4a211fd57 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -1317,7 +1317,7 @@ def _draw( session_url=content.session_url, mention=mention, error_summary=content.error_summary, - current_tool=True, + tool_detail=True, ) + tail ) diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 2895e3c3b..491d3e6fa 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -1378,7 +1378,7 @@ def _draw( session_url=content.session_url, mention=mention, error_summary=content.error_summary, - current_tool=False, + tool_detail=False, ) return Drawn(text=f"{prefix}{body}{tail}", answerable=False) # The mention goes on its own line rather than in front of the heading: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index a69f4db04..ec2c69de2 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -407,7 +407,7 @@ async def test_a_turn_with_no_items_at_all_shows_its_state_too() -> None: async def test_a_completed_turn_shows_how_long_it_worked_instead_of_saying_so() -> None: - state = turn_state([], _turn("completed"), elapsed_seconds=80) + state = turn_state([], _turn("completed"), tool_detail=True, elapsed_seconds=80) assert state == "Worked for 1m 20s." @@ -415,13 +415,15 @@ async def test_a_completed_turn_shows_how_long_it_worked_instead_of_saying_so() async def test_the_worked_for_line_counts_its_tool_calls() -> None: did = [_item(itemId=f"c{n}") for n in range(20)] - state = turn_state(did, _turn("completed"), elapsed_seconds=80) + state = turn_state(did, _turn("completed"), tool_detail=True, elapsed_seconds=80) assert state == "Worked for 1m 20s. 20 tool calls." async def test_a_single_tool_call_is_not_pluralised() -> None: - state = turn_state([_item()], _turn("completed"), elapsed_seconds=5) + state = turn_state( + [_item()], _turn("completed"), tool_detail=True, elapsed_seconds=5 + ) assert state == "Worked for 5s. 1 tool call." @@ -429,14 +431,14 @@ async def test_a_single_tool_call_is_not_pluralised() -> None: async def test_an_interrupted_turn_keeps_its_own_phrase_and_says_how_long_too() -> None: """Worth knowing on its own, unlike "complete" β€” so this one is appended rather than replaced.""" - state = turn_state([], _turn("interrupted"), elapsed_seconds=45) + state = turn_state([], _turn("interrupted"), tool_detail=True, elapsed_seconds=45) assert state == "Turn interrupted. Worked for 45s." async def test_a_running_turn_shows_live_elapsed_seconds() -> None: """The publisher refreshes this duration while the turn is running.""" - state = turn_state([], _turn("running"), elapsed_seconds=80) + state = turn_state([], _turn("running"), tool_detail=True, elapsed_seconds=80) assert state == "Working… 1m 20s" @@ -444,7 +446,7 @@ async def test_a_running_turn_shows_live_elapsed_seconds() -> None: async def test_with_no_elapsed_seconds_a_completed_turn_says_only_that() -> None: """No timing to show is not the same as zero β€” the plain phrase stays rather than claiming a duration nobody measured.""" - state = turn_state([], _turn("completed"), elapsed_seconds=None) + state = turn_state([], _turn("completed"), tool_detail=True, elapsed_seconds=None) assert state == "Turn complete." diff --git a/core/tests/switch_core/bridges/collaboration/test_session_turn_status.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_status.py new file mode 100644 index 000000000..e49d73b4d --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_turn_status.py @@ -0,0 +1,91 @@ +"""The live status line every SDK-publishing platform edits in place. + +`test_session_turn_summary.py` covers the other neutral rendering β€” the one a +platform shows once, after the fact. This is the one that is rewritten while +the turn runs, so what it has to get right is tense: it must never describe a +turn that has ended as though it were still going, and on a platform that does +not report tool activity it must not report it here by the side door. +""" + +from __future__ import annotations + +from switch_core.bridges.collaboration.session.renderers import MARKDOWN +from switch_core.bridges.collaboration.session.renderers.neutral import turn_status +from switch_core.sessions.contract import Item + +from .test_session_activity import _item, _turn + + +def _identity(text: str) -> str: + return text + + +def _call(status: str, title: str = "Did a thing") -> Item: + return _item(kind="tool-activity", status=status, title=title) + + +def _status(items: list[Item], turn_state: str, *, tool_detail: bool) -> list[str]: + return turn_status( + items, + _turn(turn_state), + escape=_identity, + limit=10_000, + markup=MARKDOWN, + tool_detail=tool_detail, + ).splitlines() + + +# ── A turn that has ended is not still running ──────────────────────────────── + + +def test_a_call_the_host_never_closed_is_not_counted_as_running_after_the_end() -> None: + """The item keeps the status the host gave it; the tally stops repeating it. + + "β–Έ 1 running" under a headline saying the turn is over describes a turn + nobody has. What the turn left behind is the state line's to say, in the + tense that belongs to it. + """ + items = [_call("failed"), _call("in-progress")] + + lines = _status(items, "completed", tool_detail=True) + + assert lines == ["**Turn complete. 1 step left unfinished.**", "βœ— 1 failed"] + + +def test_an_ended_turn_with_nothing_wrong_reports_no_tally_at_all() -> None: + """The state line already gave the total, so a second count adds nothing.""" + items = [_call("completed"), _call("completed")] + + lines = _status(items, "completed", tool_detail=True) + + assert lines == ["**Turn complete.**"] + + +def test_a_running_turn_still_says_what_is_running() -> None: + """The present tense is only wrong once the turn is over.""" + items = [_call("completed"), _call("in-progress", title="Reading a file")] + + lines = _status(items, "running", tool_detail=True) + + assert lines == ["**Working…**", "Now: Reading a file", "β–Έ 1 running Β· βœ“ 1 done"] + + +# ── A platform that does not report tool activity ───────────────────────────── + + +def test_healthy_calls_are_not_reported_where_tool_activity_is_not() -> None: + """No line of the moment, and no count of calls that went fine.""" + items = [_call("completed"), _call("in-progress", title="Reading a file")] + + lines = _status(items, "running", tool_detail=False) + + assert lines == ["**Working…**"] + + +def test_a_call_that_failed_is_reported_even_there() -> None: + """An outcome somebody has to act on is not progress chatter.""" + items = [_call("completed"), _call("failed"), _call("declined")] + + lines = _status(items, "running", tool_detail=False) + + assert lines == ["**Working…**", "βœ— 1 failed Β· ⊘ 1 declined"] diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index fa7733694..617b32953 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -805,14 +805,41 @@ async def test_the_tool_log_is_not_drawn_into_the_chat_at_all() -> None: assert len(_bot(adapter).messages) == 1 -async def test_the_status_still_says_how_the_calls_went() -> None: - """Dropping the log is not dropping the outcome: nine done is one short - line, and it is the part a reader cannot get from the duration.""" +async def test_nine_healthy_calls_are_not_reported_at_all() -> None: + """Nine calls that worked is the activity detail this platform declines. + + The state, the duration and the Console link are the record. How many + tool calls a turn made while getting there is not something the reader + does anything with, and it took a line of a status that lives in the + conversation for good. + """ adapter = _adapter() await adapter.post_rich(CHANNEL, "my-agent", _tools(9), None) - assert "9 done" in _posted(adapter)["text"] + text = _posted(adapter)["text"] + assert "9 done" not in text + assert "running" not in text + assert "tool call" not in text + + +async def test_a_call_that_failed_is_still_reported() -> None: + """The outcome is not the detail. A failed call is the thing a reader has + to act on, and it survives everything else being dropped β€” without + dragging the healthy counts back in beside it.""" + adapter = _adapter() + items = [ + _item(itemId="item-0", title="Read file", status="completed"), + _item(itemId="item-1", title="Write file", status="failed"), + ] + + await adapter.post_rich( + CHANNEL, "my-agent", TurnActivity(items, _turn("running")), None + ) + + text = _posted(adapter)["text"] + assert "1 failed" in text + assert "1 done" not in text async def test_a_tool_title_cannot_reach_the_chat_as_markup() -> None: From f2ba390a0fe18ba20dd14d568a470bd75ac0c348 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 16:59:35 +0100 Subject: [PATCH 033/120] Lead a settled permission card with the answer, not the word "answered" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card is one message edited in place, so its settled drawing is what the channel keeps. "Permission answered" spent the most prominent line on a fact the sentence underneath already carried, and carried better: it said what the answer was. The chosen option becomes the heading and the attribution moves to the footer. The heading keeps its shape β€” bold from the first character, ending in the handle β€” because Discord finds a card again after a restart by scanning history for exactly that. Which means the heading is host text now, and a label with a newline in it would leave two lines that are each half a heading, and a card nothing can recover. Labels are folded onto one line before they are fitted. Where the host reports an answer without saying which option, there is no outcome to name and the generic heading stays; the heading and the footer come from one reading of the result so they cannot disagree about whether anything was decided. The scope of an option that lasts the session stays in the footer, in the words the open form already uses. `Markup` grows `command` beside `code`. They are spelled the same on the platforms that have a code span, but they are different intents: a handle is a literal to copy, a command is read. Teams, which renders no code span and draws `code` as bold, leaves a command plain rather than giving the card a third bold. A detail of more than one line is prose and is left alone. Nothing rendered a settled approval through the neutral renderer in any test β€” every assertion on the old wording was Slack's, which keeps its own copies and is unchanged. Co-Authored-By: Claude Opus 5 --- .../session/renderers/__init__.py | 27 ++- .../session/renderers/neutral.py | 73 +++++-- .../bridges/collaboration/teams/adapter.py | 7 + .../test_session_settled_cards.py | 188 ++++++++++++++++++ 4 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index 169b8bb03..5b588b9b6 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -312,14 +312,15 @@ class Drawn: class Markup: - """The three marks the neutral renderer makes, in one platform's spelling. + """The marks the neutral renderer makes, in one platform's spelling. - Emphasis, a literal a reader is meant to copy, and a link. Everything else - the renderer writes is plain text. They live behind this rather than being - written into the renderer because a platform that does not parse Markdown - is otherwise forced to choose between a renderer of its own β€” the whole of - the budget and faithfulness logic, copied and left to drift β€” and shipping - `**Working…**` to a reader as those characters. + Emphasis, a literal a reader is meant to copy, the command a card is asking + about, and a link. Everything else the renderer writes is plain text. They + live behind this rather than being written into the renderer because a + platform that does not parse Markdown is otherwise forced to choose between + a renderer of its own β€” the whole of the budget and faithfulness logic, + copied and left to drift β€” and shipping `**Working…**` to a reader as those + characters. Not an escaper. Host text is neutralised by the adapter's own escape before it reaches here, and what these produce is measured against the message @@ -333,6 +334,18 @@ def bold(self, text: str) -> str: def code(self, text: str) -> str: return f"`{text}`" + def command(self, text: str) -> str: + """The command a card is asking about, set apart from the prose. + + Most platforms spell this the same as `code`, but the two are asking + for different things and a platform may answer them differently. A + handle is a literal to copy and has to survive being marked some other + way; a command is read, not typed, so a platform with no code span is + better off leaving it alone than emphasising it into a third bold on a + card that already has two. + """ + return self.code(text) + def link(self, label: str, url: str) -> str: # A `)` inside the destination closes the link early and spills the # rest of the URL into the body as text. Percent-encoding is the one diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 49b95b0cf..b2784d170 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -33,6 +33,7 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass from switch_core.sessions.contract import ( TURN_ENDED, @@ -101,11 +102,14 @@ "declined": "declined", } +# What the heading says where the outcome cannot. A resolved card names the +# option that was chosen instead, so "Permission answered" is left for the one +# case that earns it: answered, but the host never said with what. _HEADINGS = { "open": "Permission needed", "submitting": "Permission needed", "resolved": "Permission answered", - "closed": "Permission request closed", + "closed": "Closed", } # How far an "accept for this session" option reaches. One copy, because the @@ -405,10 +409,17 @@ def _approval_form( ) -> tuple[list[str], list[str], str, bool]: fit = _Faithful(escape) handle = escape(reference.handle) - head = [f"{markup.bold(_HEADINGS[request.state])} Β· request {markup.code(handle)}"] + outcome = ( + _chosen(request, content, escape=escape, limit=limit) + if request.state == "resolved" + else None + ) + heading = outcome.label if outcome else _HEADINGS[request.state] + head = [f"{markup.bold(heading)} Β· request {markup.code(handle)}"] head.append(fit(content.title, _share(limit, 1500, 3))) if content.detail: - head.append(fit(content.detail, _share(limit, 1200, 4))) + detail = fit(content.detail, _share(limit, 1200, 4)) + head.append(markup.command(detail) if "\n" not in detail else detail) body: list[str] = [] if request.state == "open": @@ -429,6 +440,7 @@ def _approval_form( request, content, handle, + outcome, escape=escape, limit=limit, markup=markup, @@ -442,6 +454,7 @@ def _approval_footer( request: SnapshotRequest, content: ApprovalContent, handle: str, + outcome: _Chosen | None, *, escape: Callable[[str], str], limit: int, @@ -460,24 +473,36 @@ def _approval_footer( return _in_flight(request, responder=responder, limit=limit, escape=escape) if request.state == "resolved": return _approval_answer( - request, content, escape=escape, limit=limit, responder=responder + request, outcome, escape=escape, limit=limit, responder=responder ) return _closed(request, escape=escape, limit=limit, responder=responder) -def _approval_answer( +@dataclass(frozen=True) +class _Chosen: + """The option a resolved approval settled on, and how far it reaches.""" + + label: str + scope: str + + +def _chosen( request: SnapshotRequest, content: ApprovalContent, *, escape: Callable[[str], str], limit: int, - responder: str | None, -) -> str: +) -> _Chosen | None: + """What was decided, or None where the host never said. + + The heading and the footer both need this and have to agree: a heading + naming an outcome over a footer saying none was reported would be the card + contradicting itself, so it is settled once and passed to both. + """ settled = request.result result = settled.result if settled else None - by = _by(request.decided_by, responder=responder, limit=limit, escape=escape) if not isinstance(result, ApprovalResult): - return f"Answered{by}, but the host did not say which option was chosen." + return None chosen = next( (option for option in content.options if option.option_id == result.option_id), None, @@ -485,13 +510,31 @@ def _approval_answer( # An option the content never offered is still named rather than hidden: # the id is what the host said, and saying nothing would read as a plain # answer to a question that was not the one asked. - label = _fit( - chosen.label if chosen else result.option_id, - _share(limit, 150, 8), - escape=escape, + # + # Folded onto one line first. This label heads the card, and a heading is a + # line that begins bold and ends in the handle β€” a newline in the middle of + # it leaves two lines that are each only half of that, and Discord finds a + # card again after a restart by looking for exactly that shape. + named = " ".join((chosen.label if chosen else result.option_id).split()) + return _Chosen( + label=_fit(named, _share(limit, 150, 8), escape=escape), + scope=_scope(chosen) if chosen else "", ) - scope = _scope(chosen) if chosen else "" - return f"{label}{scope} β€” chosen{by}." if by else f"{label}{scope}." + + +def _approval_answer( + request: SnapshotRequest, + outcome: _Chosen | None, + *, + escape: Callable[[str], str], + limit: int, + responder: str | None, +) -> str: + """Who answered, the outcome itself having already been said in the heading.""" + by = _by(request.decided_by, responder=responder, limit=limit, escape=escape) + if outcome is None: + return f"Answered{by}, but the host did not say which option was chosen." + return f"Chosen{by}{outcome.scope}." if by else f"Answered{outcome.scope}." def _questions_form( diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 4a211fd57..a9ea93ae2 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -222,11 +222,18 @@ class _TeamsMarkup(Markup): as `` `R42` `` invites somebody to type the backticks with it. Bold is emphasis Teams does render, so the literal is marked with that and arrives as something to copy rather than something to decode. + + A command gets neither. It is read rather than typed, so the argument that + wins for the handle does not apply, and a third bold on a card whose + heading and handle are already bold marks nothing out at all. """ def code(self, text: str) -> str: return f"**{text}**" + def command(self, text: str) -> str: + return text + _TEAMS_MARKUP = _TeamsMarkup() diff --git a/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py b/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py new file mode 100644 index 000000000..09ac8e558 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py @@ -0,0 +1,188 @@ +"""A permission card after it has been answered, on the platforms without cards. + +The open form is covered in `test_session_neutral_forms.py`, which is about +whether a card may honestly ask for a number. This is the other half of the +same message β€” the card is edited in place, so the settled drawing is what a +reader sees for the rest of the conversation's life, and the thing it has to +say in the fewest words is what was decided. + +Slack keeps its own copies of this wording in `slack.py`; these are the four +platforms that share `neutral.py`. +""" + +from __future__ import annotations + +from switch_core.bridges.collaboration.session.renderers import MARKDOWN, Markup +from switch_core.bridges.collaboration.session.renderers.neutral import request_summary +from switch_core.bridges.collaboration.teams.adapter import _TEAMS_MARKUP +from switch_core.sessions.contract import ApprovalContent, SnapshotRequest + +from .test_session_neutral_forms import REFERENCE, _identity, _option + +ALLOW = _option("opt-allow", "Allow once") +ALWAYS = _option("opt-always", "Allow", decision="acceptForSession") +DENY = _option("opt-deny", "Deny") + + +def _settled( + *, + state: str = "resolved", + outcome: str = "answered", + option_id: str | None = "opt-allow", + actor: str | None = "actor-demo", + detail: str | None = "pnpm test", +) -> SnapshotRequest: + return SnapshotRequest.model_validate( + { + "requestId": "req-1", + "turnId": "turn-1", + "revision": 1, + "state": state, + "expiresAt": None, + "result": { + "type": "request.settled", + "requestId": "req-1", + "revision": 1, + "outcome": outcome, + "commandId": None, + "result": ( + {"kind": "approval", "optionId": option_id} + if option_id is not None + else None + ), + }, + "decidedBy": ( + {"actorId": actor, "surface": "mattermost", "commandId": "cmd-1"} + if actor is not None + else None + ), + "content": ApprovalContent( + kind="approval", + title="Run project tests", + detail=detail, + options=[ALLOW, ALWAYS, DENY], + ).model_dump(by_alias=True), + } + ) + + +def _lines(request: SnapshotRequest, markup: Markup = MARKDOWN) -> list[str]: + return request_summary( + request, REFERENCE, escape=_identity, limit=10_000, markup=markup + ).splitlines() + + +# ── The outcome is the heading ──────────────────────────────────────────────── + + +def test_the_answer_is_the_first_thing_the_settled_card_says() -> None: + """Not "Permission answered" over a sentence saying what the answer was. + + The generic word cost a line to say something the outcome says better. + """ + assert _lines(_settled()) == [ + "**Allow once** Β· request `R42`", + "Run project tests", + "`pnpm test`", + "Chosen by actor-demo from Mattermost.", + ] + + +def test_the_heading_keeps_the_shape_discord_recovers_a_card_by() -> None: + """`discord/adapter.py:_heads_a_card` scans history for exactly this. + + Bold from the first character, ending in the request handle. A settled + card that stops matching is a card a restart can no longer find. + """ + head = _lines(_settled())[0] + + assert head.startswith("**") + assert head.endswith(" Β· request `R42`") + + +def test_a_label_with_a_newline_in_it_cannot_split_the_heading() -> None: + """The label is host text and the heading is now made of it. + + Two half-headings would each fail Discord's scan, so a card whose option + happened to be labelled over two lines would be unrecoverable after a + restart. Folded onto one line before it is fitted. + """ + request = _settled(option_id="opt-multiline") + request.content.options.append(_option("opt-multiline", "Allow\nonce")) + + head = _lines(request)[0] + + assert head == "**Allow once** Β· request `R42`" + + +def test_an_option_that_lasts_the_session_says_so_in_the_footer() -> None: + """The scope followed the label off the footer, but not into the heading: + bolding a parenthetical makes a long heading out of a short answer.""" + lines = _lines(_settled(option_id="opt-always")) + + assert lines[0] == "**Allow** Β· request `R42`" + assert lines[-1] == ( + "Chosen by actor-demo from Mattermost (applies for the rest of this session)." + ) + + +def test_an_answer_from_nobody_in_particular_still_names_the_outcome() -> None: + """A host may settle a request without saying who did it.""" + lines = _lines(_settled(actor=None)) + + assert lines[0] == "**Allow once** Β· request `R42`" + assert lines[-1] == "Answered." + + +def test_an_option_the_card_never_offered_is_named_rather_than_hidden() -> None: + lines = _lines(_settled(option_id="opt-from-nowhere")) + + assert lines[0] == "**opt-from-nowhere** Β· request `R42`" + + +def test_an_answer_with_no_option_at_all_keeps_the_generic_heading() -> None: + """The one case "Permission answered" still earns: the host said it was + answered and never said with what. The heading must not invent one.""" + lines = _lines(_settled(option_id=None)) + + assert lines[0] == "**Permission answered** Β· request `R42`" + assert lines[-1] == ( + "Answered by actor-demo from Mattermost, " + "but the host did not say which option was chosen." + ) + + +def test_a_card_closed_without_an_answer_says_closed_and_why() -> None: + lines = _lines(_settled(state="closed", outcome="cancelled")) + + assert lines[0] == "**Closed** Β· request `R42`" + assert lines[-1] == ( + "Cancelled before it was answered. Decided by actor-demo from Mattermost." + ) + + +# ── The command it is asking about ──────────────────────────────────────────── + + +def test_the_command_is_set_apart_from_the_prose_around_it() -> None: + """Two lines of host text in a row, one a sentence and one a command, read + as one paragraph without it.""" + assert "`pnpm test`" in _lines(_settled()) + + +def test_teams_leaves_the_command_alone_having_no_code_span() -> None: + """A TextBlock renders no code span, and bold would be the card's third β€” + after the heading and the handle β€” which marks out nothing at all.""" + lines = _lines(_settled(), markup=_TEAMS_MARKUP) + + assert lines[0] == "**Allow once** Β· request **R42**" + assert lines[2] == "pnpm test" + + +def test_a_detail_of_several_lines_is_not_a_command_and_is_left_alone() -> None: + """A code span drawn around a paragraph is a rendering accident on every + platform here; a detail that runs to more than one line is prose.""" + lines = _lines(_settled(detail="It will:\nrun the suite")) + + assert "`" not in lines[2] + assert lines[2:4] == ["It will:", "run the suite"] From 5c58a2c8358580eb8c576adc938a968686275237 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 17:13:15 +0100 Subject: [PATCH 034/120] Stop a Telegram card printing the options its buttons already offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open permission card drew a numbered option list in the body and then a button per option under it. On a phone the second copy is what pushes the instruction, and often the command being asked about, off the first screen. The neutral renderer is now told how much of a label a control beside the card will show, and drops the line for any option the control carries whole. Telegram passes its button budget; the platforms with no controls of their own pass nothing and print the list as before, and Slack has its own renderer and is untouched. Nothing an option says is lost. A label too long for a button keeps its line, because the button is showing a cut version of it and the body is then the only place it exists in full. So does an option that reaches past this turn: the scope note sits beside the label and a button has no room for it. Kept lines keep their own numbers, so a typed answer and a press still mean the same thing by the same number. Every line is still fitted against the message budget whether or not it is kept β€” a label the card had to cut is a form that cannot honestly ask for a number, and that does not stop being true because a button was going to say it instead. Co-Authored-By: Claude Opus 5 --- .../session/renderers/neutral.py | 47 ++++++- .../bridges/collaboration/telegram/adapter.py | 5 +- .../test_session_neutral_forms.py | 127 +++++++++++++++++- .../collaboration/test_telegram_sdk_only.py | 17 ++- 4 files changed, 190 insertions(+), 6 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index b2784d170..6b151cb38 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -304,7 +304,8 @@ def request_summary( A platform with no controls of its own has nothing to do with the rest of the result: the body already says how to answer, or says it cannot be - answered here. + answered here. Nothing else carries the options either, which is why the + body here always prints them. """ return render_request( request, @@ -314,6 +315,7 @@ def request_summary( markup=markup, responder=responder, unavailable_reason=unavailable_reason, + control_label_limit=None, ).text @@ -326,6 +328,7 @@ def render_request( markup: Markup, responder: str | None, unavailable_reason: str | None, + control_label_limit: int | None, ) -> Drawn: """The text form of a request: the question, the options, and how to answer. @@ -345,6 +348,14 @@ def render_request( holds. Without one the card falls back to the Switch identity, which is correct but not a name anybody in the channel recognises. + `control_label_limit` is how much of an option's label a control beside + this card will show, or None where the platform draws no controls. Where a + control carries the whole of an option, the body stops repeating it: the + same choices printed under the buttons offering them are the screen the + instruction needed, on the platform where the screen is a phone. What a + control cannot show β€” a label too long for a button, a scope the button + has no room for β€” keeps its line, so no option is ever only half visible. + `unavailable_reason` replaces the instruction rather than joining it. It exists for a card that cannot be answered where it is showing, and leaving "Reply with `R42 1`" underneath would invite exactly the answer that is @@ -372,6 +383,7 @@ def render_request( limit=limit, markup=markup, responder=responder, + control_label_limit=control_label_limit, ) else: head, body, footer, invites_answer = _questions_form( @@ -406,6 +418,7 @@ def _approval_form( limit: int, markup: Markup, responder: str | None, + control_label_limit: int | None, ) -> tuple[list[str], list[str], str, bool]: fit = _Faithful(escape) handle = escape(reference.handle) @@ -424,10 +437,15 @@ def _approval_form( body: list[str] = [] if request.state == "open": budget = _label_budget(limit) - body = [ - f"{index}. {fit(option.label, budget)}{_scope(option)}" + numbered = [ + (option, f"{index}. {fit(option.label, budget)}{_scope(option)}") for index, option in enumerate(content.options, start=1) ] + body = [ + line + for option, line in numbered + if not _carried(option, control_label_limit) + ] # The one state whose footer is an instruction. `_approval_footer` says # why the others are not: nothing to choose, or already answered. invites_answer = request.state == "open" and bool(content.options) @@ -880,6 +898,29 @@ def _scope(option: ApprovalOption) -> str: return _FOR_SESSION if option.decision == "acceptForSession" else "" +def _carried(option: ApprovalOption, control_label_limit: int | None) -> bool: + """Whether a control beside the card already shows the whole of this option. + + An option a reader can press, spelled out again underneath, is the same + choice twice β€” and on a phone the second copy is what pushes the rest of + the card off the screen. It is only the same choice if the control shows + all of it, which is two things: a label short enough that the button did + not have to cut it, and nothing said beside the label that a button has no + room for. A scope is exactly that, so an option reaching past this turn + keeps its line while the ones a button says in full lose theirs. + + Every line is fitted before this is asked, kept or not. A label too long + for the card is a form that cannot honestly ask for a number, and that is + as true of a label on a button as of one in the body. + + None where the platform draws no controls, which is most of them: nothing + but the body is carrying the choices there. + """ + if control_label_limit is None or _scope(option): + return False + return len(option.label.strip()) <= control_label_limit + + def _share(limit: int, most: int, denominator: int) -> int: """One value's budget: `most` characters, or a share of a tighter limit. diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 491d3e6fa..df4e92363 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -102,7 +102,9 @@ # A button's label is one line on a phone, and Telegram truncates the middle of # an over-long one rather than wrapping it. Cut here instead, at the end, where -# the reader can tell something was cut. +# the reader can tell something was cut. The renderer is given the same number, +# because an option the button says in full is one the body stops repeating and +# an option the button had to cut is one the body has to keep. _MAX_BUTTON_LABEL = 48 # Telegram's own limit on the text of a reply to a press. @@ -1394,6 +1396,7 @@ def _draw( markup=markup, responder=responder, unavailable_reason=content.unavailable_reason, + control_label_limit=_MAX_BUTTON_LABEL, ) return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") diff --git a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py index cea614907..77b22b2ea 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py @@ -13,7 +13,10 @@ MARKDOWN, RequestReference, ) -from switch_core.bridges.collaboration.session.renderers.neutral import request_summary +from switch_core.bridges.collaboration.session.renderers.neutral import ( + render_request, + request_summary, +) from switch_core.sessions.contract import ( ApprovalContent, ApprovalOption, @@ -273,3 +276,125 @@ def test_a_prompt_cut_short_is_not_answered_by_number_either(): assert "Reply with" not in text assert "Switch Console" in text + + +# ── What the buttons already say, where there are buttons ───────────────────── +# +# Telegram is the one platform here that draws controls beside the body. An +# option printed under the button offering it is the same choice twice, and on +# a phone the second copy is what pushes the rest of the card off the screen. +# These are the cases where the button says the whole of it and the cases where +# it cannot. + +BUTTON = 48 + + +def _pressable(request: SnapshotRequest, *, limit: int = 4000) -> list[str]: + """The card as a platform with `BUTTON`-wide controls would draw it.""" + return render_request( + request, + REFERENCE, + escape=_identity, + limit=limit, + markup=MARKDOWN, + responder=None, + unavailable_reason=None, + control_label_limit=BUTTON, + ).text.splitlines() + + +def test_an_option_its_button_says_in_full_is_not_printed_under_it(): + lines = _pressable( + _approval( + _option("yes", "Allow once"), + _option("no", "Decline", decision="decline"), + detail="pnpm test", + ) + ) + + assert lines == [ + "**Permission needed** Β· request `R42`", + "Run a command?", + "`pnpm test`", + "Reply with `R42 1`.", + ] + + +def test_the_same_card_still_lists_its_options_where_nothing_else_carries_them(): + """The platforms sharing this renderer mostly have no controls at all, and + dropping the list there would be dropping the choices.""" + request = _approval( + _option("yes", "Allow once"), _option("no", "Decline", decision="decline") + ) + + assert "1. Allow once" in _render(request) + + +def test_a_label_too_long_for_a_button_keeps_the_line_that_shows_it_whole(): + """Telegram cuts an over-long button label. The body is then the only + place the option exists in full, so it stays.""" + label = "Allow running " + "x" * BUTTON + lines = _pressable( + _approval(_option("yes", label), _option("no", "Decline", decision="decline")) + ) + + assert f"1. {label}" in lines + assert "2. Decline" not in lines + + +def test_an_option_that_outlasts_the_turn_keeps_the_line_saying_so(): + """A button carries the label and nothing beside it, and how far an + approval reaches is beside it.""" + lines = _pressable( + _approval( + _option("once", "Allow once"), + _option("always", "Allow for this session", decision="acceptForSession"), + _option("no", "Decline", decision="decline"), + ) + ) + + assert lines[-2:] == [ + "2. Allow for this session (applies for the rest of this session)", + "Reply with `R42 1`.", + ] + + +def test_a_kept_line_keeps_the_number_its_button_was_given(): + """Pressing and typing have to mean the same thing by the same number, so + the surviving lines are not renumbered around the dropped ones.""" + lines = _pressable( + _approval( + _option("once", "Allow once"), + _option("no", "Decline", decision="decline"), + _option("always", "Allow from now on", decision="acceptForSession"), + ) + ) + + assert "3. Allow from now on (applies for the rest of this session)" in lines + + +def test_a_card_with_buttons_still_refuses_a_label_it_could_not_fit(): + """The line is dropped for being said elsewhere, not for being short + enough. A label the card itself had to cut is still a form that cannot + honestly ask for a number β€” and the button shows even less of it.""" + shared = "Allow access to " + "x" * 4000 + lines = _pressable( + _approval(_option("one", f"{shared} once"), _option("two", f"{shared} always")) + ) + + assert "Reply with" not in "\n".join(lines) + assert "Switch Console" in "\n".join(lines) + + +def test_a_dropped_line_is_still_measured_against_what_the_card_can_hold(): + """A short message gives a label less room than a button does, so an + option can fit the button and not the card. It is dropped from the body + either way β€” but the card has still failed to show it whole, and a form + that cannot show what it is asking does not ask for a number.""" + label = "Allow the deployment to proceed" + assert len(label) <= BUTTON + + lines = _pressable(_approval(_option("yes", label)), limit=90) + + assert "Reply with" not in "\n".join(lines) + assert "Too long to show in full here" in "\n".join(lines) diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 617b32953..628c794ea 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -1001,7 +1001,8 @@ async def record(interaction: InboundInteraction) -> None: async def test_an_open_card_offers_a_button_for_every_option_it_lists() -> None: - """Numbered the way the body numbers them, so pressing and typing agree.""" + """Numbered the way a typed answer numbers them, so pressing and typing + agree.""" adapter = _adapter() await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) @@ -1012,6 +1013,20 @@ async def test_an_open_card_offers_a_button_for_every_option_it_lists() -> None: ] +async def test_the_body_does_not_repeat_what_the_buttons_already_say() -> None: + """A phone shows a few lines at a time, and the options printed above the + buttons offering them are the lines that push the rest of the card off the + screen. Typing still answers it β€” the numbers are on the buttons.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + text = _posted(adapter)["text"] + assert "1. Allow once" not in text + assert "2. Deny" not in text + assert "Reply with R7 1." in text + + async def test_a_press_carries_the_request_and_where_the_control_was() -> None: """And nothing else. The option's own id never goes into the payload: it is unbounded text the host chose, and 64 bytes is the whole budget.""" From 41b3246e7bd6577b580e27544704d0416b5d9c19 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 17:27:34 +0100 Subject: [PATCH 035/120] Stop a reaction removal erasing what its holder wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a πŸ‘€ could be left on a message with nobody able to take it off. `forget_mark` read a holder's row, edited the copy in Python and wrote the whole thing back. The holder is another turn and nothing serialises the two: the remover holds its own turn's advisory lock, not that holder's. Between the read and the write the holder can save a newer ask, a delivery reservation or the end of its turn, and all of it went. Worse, the row then said the mark was gone while the holder's second ask had really put one on the message. Each row is now tested and cleared by one statement, fenced on the ask the removal was issued against, dropping the two keys from whatever the row holds when it runs. Compaction kept a finished turn's mark β€” another turn may still be waiting to take it off β€” but dropped the stamp beside it, rewriting a stamped claim as an unstamped one. No removal issued against the real ask could then clear it. The stamp now goes with the mark. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/activity_journal.py | 48 +++-- .../bridges/collaboration/session/outbound.py | 19 +- .../sessions/test_activity_durability.py | 168 +++++++++++++++++- 3 files changed, 210 insertions(+), 25 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index fc2c3b887..e7cd8506b 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -15,7 +15,8 @@ from contextlib import asynccontextmanager from dataclasses import dataclass -from sqlalchemy import select, text +from sqlalchemy import Text, cast, func, select, text, update +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from switch_core.db.models import SessionActivityPost, require_tenant_id @@ -179,29 +180,38 @@ async def forget_mark( its own, or for one of these turns asking again β€” and that mark really is on the message. So a row is cleared only while it still carries the ask the removal was issued against. + + Each row is tested and cleared by one statement, and the two keys are + dropped from whatever the row holds at the moment it runs rather than + from a copy read earlier. Nothing serialises this against the holder + itself: the remover holds its own turn's advisory lock and not that + holder's, so between a read and a write the holder can have saved a + newer attempt, a delivery reservation or the end of its turn. Writing + back a whole edited copy would erase all of it β€” and the stale attempt + it carried would reinstate a claim this removal never answered for. """ if not holders: return + held = func.coalesce(SessionActivityPost.data["mark_attempt"].astext, "") + forgotten = ( + SessionActivityPost.data.op("-")(cast("mark", Text)) + .op("-")(cast("mark_attempt", Text)) + .cast(JSONB) + ) async with sessions() as db: - rows = await db.scalars( - select(SessionActivityPost).where( - SessionActivityPost.tenant_id == require_tenant_id(), - SessionActivityPost.bridge_id == self.bridge_id, - SessionActivityPost.data.contains({"mark": mark}), - ) - ) - for row in rows: - held = ( - row.session_id, - row.command_id, - row.data.get("mark_attempt", ""), + for session_id, command_id, attempt in holders: + await db.execute( + update(SessionActivityPost) + .where( + SessionActivityPost.tenant_id == require_tenant_id(), + SessionActivityPost.bridge_id == self.bridge_id, + SessionActivityPost.session_id == session_id, + SessionActivityPost.command_id == command_id, + SessionActivityPost.data.contains({"mark": mark}), + held == attempt, + ) + .values(data=forgotten) ) - if held not in holders: - continue - data = dict(row.data) - data.pop("mark", None) - data.pop("mark_attempt", None) - row.data = data await db.commit() @asynccontextmanager diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 08950cc17..41b8b5295 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -425,11 +425,20 @@ async def draw() -> bool: # discard delivery reservations and reaction/log anchors. # An outstanding mark is not this turn's to discard: the # holder that takes it off may be another turn entirely, - # and it needs to know the mark is there. - mark = record.data.get("mark") - record.data = {"turn_id": turn.turn_id, "ended": True} - if mark is not None: - record.data["mark"] = mark + # and it needs to know the mark is there. The stamp goes + # with it β€” a claim is named by the ask that made it, and + # one reduced to an unstamped claim is one no removal + # issued against the real ask can ever clear. + claim = { + field: record.data[field] + for field in ("mark", "mark_attempt") + if field in record.data + } + record.data = { + "turn_id": turn.turn_id, + "ended": True, + **claim, + } record.data["completed"] = True await record.save() return drawn diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 066c448a0..fa166ae1a 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -6,7 +6,7 @@ import pytest from mattermostdriver.exceptions import NotEnoughPermissions -from sqlalchemy import select +from sqlalchemy import select, text, update from switch_core.bridges.collaboration.adapter import ( ActivityMarkRefused, @@ -1587,3 +1587,169 @@ async def test_an_addition_whose_answer_was_lost_survives_a_refused_retry( platform.refuse_remove = False assert await publish(renderer, "completed") assert not chat["reactions"] + + +# ── One removal, one holder, and no lock between them ──────────────────────── +# +# `forget_mark` clears rows belonging to turns other than the one running it, +# and holds only its own turn's advisory lock. Whatever it does to a holder's +# row it does while that holder is free to be writing to it. + +MARK = {"channel_id": "channel-demo", "reaction_ref": "channel-demo:question"} + + +async def _row(sessions): + async with sessions() as db: + row = await db.get( + SessionActivityPost, + (require_tenant_id(), "bridge", "session-demo", "message-demo"), + ) + return dict(row.data) if row is not None else None + + +async def _claim(sessions, data): + async with sessions() as db: + db.add( + SessionActivityPost( + tenant_id=require_tenant_id(), + bridge_id="bridge", + session_id="session-demo", + command_id="message-demo", + data=data, + ) + ) + await db.commit() + + +async def _waiting_on_a_lock(sessions): + """Block until some backend is stuck behind another's row lock.""" + for _ in range(200): + async with sessions() as db: + blocked = await db.scalar( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE wait_event_type = 'Lock'" + ) + ) + if blocked: + return + await asyncio.sleep(0.05) + raise AssertionError("no backend ever blocked on a row lock") + + +async def test_a_removal_does_not_write_back_over_what_its_holder_saved( + session_factory, +): + """The window between reading a holder's row and writing it back. + + The holder is another turn, running in another task or another process, + and nothing serialises the two. It asks for the mark again and saves the + new stamp; the removal, having read the row before that, writes back an + edited copy of what it saw. The holder's stamp goes, and with it every + other thing the holder had written down β€” the anchor it needs to redraw, + the fact that its turn ended. + + Worse than losing the fields: the row it writes says the mark is gone, and + the mark the holder's second ask put there really is on the message. + """ + await setup(session_factory) + journal = ActivityJournal(session_factory, "bridge") + await _claim( + session_factory, + { + "turn_id": "turn-1", + "ended": False, + "mark": MARK, + "mark_attempt": "first-ask", + }, + ) + + holder = session_factory() + await holder.execute( + update(SessionActivityPost) + .where(SessionActivityPost.command_id == "message-demo") + .values( + data={ + "turn_id": "turn-1", + "ended": True, + "mark": MARK, + "mark_attempt": "second-ask", + "anchor": {"channel_id": "channel-demo"}, + } + ) + ) + + removal = asyncio.create_task( + journal.forget_mark( + MARK, + holders={("session-demo", "message-demo", "first-ask")}, + sessions=session_factory, + ) + ) + try: + await _waiting_on_a_lock(session_factory) + assert not removal.done() + await holder.commit() + finally: + await holder.close() + await asyncio.wait_for(removal, 5) + + assert await _row(session_factory) == { + "turn_id": "turn-1", + "ended": True, + "mark": MARK, + "mark_attempt": "second-ask", + "anchor": {"channel_id": "channel-demo"}, + } + + +async def test_a_removal_still_clears_the_ask_it_was_issued_against( + session_factory, +): + """The same statement, where nothing has moved underneath it. Only the two + keys go; the rest of the row is the holder's and is left alone.""" + await setup(session_factory) + journal = ActivityJournal(session_factory, "bridge") + await _claim( + session_factory, + { + "turn_id": "turn-1", + "ended": True, + "mark": MARK, + "mark_attempt": "first-ask", + "anchor": {"channel_id": "channel-demo"}, + }, + ) + + await journal.forget_mark( + MARK, + holders={("session-demo", "message-demo", "first-ask")}, + sessions=session_factory, + ) + + assert await _row(session_factory) == { + "turn_id": "turn-1", + "ended": True, + "anchor": {"channel_id": "channel-demo"}, + } + + +async def test_a_turn_that_ends_holding_the_mark_keeps_the_ask_it_made( + session_factory, +): + """Compaction reduces a finished turn to a receipt and keeps the mark, + because another turn may still be waiting to take it off. The stamp has to + go with it: a claim is named by the ask that made it, and one reduced to an + unstamped claim is one no removal issued against the real ask can clear β€” + so the row keeps the mark for good and a later turn on that message waits + on a reaction nobody will ever remove.""" + await setup(session_factory) + platform = ActivitySlack() + await publish(activity(session_factory, platform)) + await publish(activity(session_factory, platform), agent="Other", command="other") + + await publish(activity(session_factory, platform), "completed") + + receipt = await _row(session_factory) + assert receipt["mark"] == MARK | {"agent_name": ""} + assert receipt["mark_attempt"] From 8cb00266d3b099f5d17637fdfbaf535467b347d1 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 17:53:31 +0100 Subject: [PATCH 036/120] Name the reaction a turn is asking for, rather than assuming one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mark_activity` took a boolean called `working`, which made the mark itself implicit: there was one reaction a bridge could put on a message and the only question was whether it was on. A queued turn needs to say something different β€” it is holding the message, not reading it β€” and a boolean has nowhere to say it. So the adapters now take `mark` and `on`: which indicator, and whether it is there. Slack, Mattermost and Discord keep a reaction per mark and declare `supports_queue_reaction`; Telegram does not, because a bot gets exactly one reaction on a message there and a second mark could only go on by taking the first off β€” a queued prompt saying nothing is better than a running one that has stopped saying it is being read. No behaviour changes here: every call site asks for the working mark. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 29 ++++++++- .../bridges/collaboration/discord/adapter.py | 46 +++++++------- .../collaboration/mattermost/adapter.py | 52 ++++++++++------ .../bridges/collaboration/session/outbound.py | 3 +- .../bridges/collaboration/slack/adapter.py | 61 +++++++++++++------ .../bridges/collaboration/telegram/adapter.py | 18 ++++-- .../sessions/test_activity_durability.py | 24 ++++---- 7 files changed, 153 insertions(+), 80 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index ac248915c..0bb289dd3 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace from datetime import datetime -from typing import ClassVar +from typing import ClassVar, Literal from switch_core.agent_display_name import defuse_label_markup from switch_core.agent_icon import default_icon_url @@ -240,6 +240,15 @@ class ActivityMarkRefused(RuntimeError): """ +#: Which indicator a message is carrying, where a platform can carry one. +#: +#: "working" is the agent reading and acting on the message. "queued" is the +#: agent holding it behind something else and not started, which is a different +#: thing to be told and is why it is a mark of its own rather than a second +#: meaning for the first. +ActivityMark = Literal["working", "queued"] + + class CollaborationAdapter(ABC): # Platforms opt in only when their SDK request and activity rendering is ready. publishes_sdk_sessions: ClassVar[bool] = False @@ -283,6 +292,16 @@ class CollaborationAdapter(ABC): supports_activity_reactions: ClassVar[bool] = False + #: Whether a prompt still waiting its turn can be marked as well. + #: + #: A second reaction beside the working one, saying the agent has the + #: prompt but has not started on it. Separate from + #: `supports_activity_reactions` because it asks more of the platform: not + #: that a bot can react, but that it can hold two reactions on one message + #: at once. Telegram allows a bot exactly one, so the queued state is + #: carried by its status text alone and never by a mark. + supports_queue_reaction: ClassVar[bool] = False + #: Whether the work reaction belongs to the agent that added it. #: #: True where each agent posts as its own bot, so two agents working on one @@ -884,7 +903,8 @@ async def mark_activity( message_ref: str, *, agent_name: str, - working: bool, + mark: ActivityMark, + on: bool, force: bool = False, ) -> None: """Update a platform work indicator when the adapter supports one. @@ -895,6 +915,11 @@ async def mark_activity( platform with one shared bot ignores it β€” there is one reaction between every agent there β€” but a caller that could not supply it would be a caller that cannot serve the per-agent platforms at all. + + `mark` says which indicator, and the two are independent: a prompt can + be queued and not yet worked on, and the same message can carry another + turn's working mark at the same time. An adapter that declares no + `supports_queue_reaction` is never asked for the queued one. """ async def notify_working( diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index fb26a145f..3e5b9682f 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -19,6 +19,7 @@ from switch_core.bridges.agent.commands import COMMANDS_BY_NAME from switch_core.bridges.agent.commands import Command as InRoomCommand from switch_core.bridges.collaboration.adapter import ( + ActivityMark, ActivityMarkRefused, CollaborationAdapter, LiveRuntimeIndicator, @@ -69,7 +70,7 @@ _READY_TIMEOUT = 30.0 # Put on the message an agent is working on for as long as its turn lasts. -_WORKING_REACTION = "πŸ‘€" +_REACTION: dict[ActivityMark, str] = {"working": "πŸ‘€", "queued": "⏳"} # Discord's error code for "Maximum number of guild roles reached" (250). _MAX_GUILD_ROLES_CODE = 30005 @@ -287,6 +288,7 @@ class DiscordAdapter(CollaborationAdapter): redraws_for_elapsed_time: ClassVar[bool] = False supports_activity_reactions: ClassVar[bool] = True + supports_queue_reaction: ClassVar[bool] = True # Every agent posts through one bot application, so there is one πŸ‘€ between # them: the first turn to want it adds it and the last to finish removes it. @@ -331,7 +333,7 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: # Message refs currently carrying the "being worked on" reaction, and # per agent the set it has marked β€” a turn ends once but may have # marked several messages. - self._eyes: set[str] = set() + self._marked: set[tuple[str, ActivityMark]] = set() self._agent_eyes: dict[tuple[str, str], set[str]] = {} # Set once Discord has told us it will not host agent roles, so the # bridge stops asking and says so only once. @@ -1392,10 +1394,11 @@ async def mark_activity( message_ref: str, *, agent_name: str, - working: bool, + mark: ActivityMark, + on: bool, force: bool = False, ) -> None: - """Put πŸ‘€ on the message being worked on, or take it off. + """Put a mark on the message being worked on, or take it off. One mark between every agent, because every agent posts through one bot application here and a reaction belongs to whoever added it. The @@ -1415,13 +1418,13 @@ async def mark_activity( _, message_id = self._parse_message_ref(message_ref) if not message_id: logger.warning( - "Cannot mark %s as being worked on: not a Discord message reference.", + "Cannot mark %s: not a Discord message reference.", message_ref, ) return - if not force and working == (message_ref in self._eyes): + if not force and on == ((message_ref, mark) in self._marked): return - await self._react(message_ref, working=working) + await self._react(message_ref, mark=mark, on=on) async def notify_working( self, channel_id: str, agent_name: str, thread_root_id: str | None @@ -1578,18 +1581,18 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: and no reaction, rather than a mark that is not there. This path has no durable record, so it answers the refused-removal - question from `self._eyes` β€” which is sound only because it will not + question from `self._marked` β€” which is sound only because it will not attempt a removal at all unless this process put the mark there. The reaction is then known to be outstanding, and is reported as such. """ _, message_id = self._parse_message_ref(message_ref) if not message_id or self._client is None: return - if working == (message_ref in self._eyes): + if working == ((message_ref, "working") in self._marked): return try: - await self._react(message_ref, working=working) + await self._react(message_ref, mark="working", on=working) except ActivityMarkRefused as refusal: if working: logger.warning("%s", refusal) @@ -1606,8 +1609,8 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: e, ) - async def _react(self, message_ref: str, *, working: bool) -> None: - """Add or remove πŸ‘€, letting through whatever another attempt might fix. + async def _react(self, message_ref: str, *, mark: ActivityMark, on: bool) -> None: + """Add or remove a mark, letting through whatever another attempt might fix. Two endings are final rather than worth retrying: the message is gone, or this guild will never allow the reaction. Everything else is left to @@ -1624,30 +1627,31 @@ async def _react(self, message_ref: str, *, working: bool) -> None: """ location_id, message_id = self._parse_message_ref(message_ref) client = self._require_client() + key = (message_ref, mark) try: channel = await self._get_channel(int(location_id)) message = channel.get_partial_message(int(message_id)) - if working: - await message.add_reaction(_WORKING_REACTION) - self._eyes.add(message_ref) + if on: + await message.add_reaction(_REACTION[mark]) + self._marked.add(key) else: - await message.remove_reaction(_WORKING_REACTION, client.user) - self._eyes.discard(message_ref) + await message.remove_reaction(_REACTION[mark], client.user) + self._marked.discard(key) except discord.NotFound: # The message (or the reaction) is gone; the end state is what was # wanted either way. - self._eyes.discard(message_ref) + self._marked.discard(key) except discord.Forbidden as error: - if working: + if on: raise ActivityMarkRefused( - f"Discord refused the working reaction on {message_ref} β€” the " + f"Discord refused the {mark} reaction on {message_ref} β€” the " f"bot is missing the Add Reactions permission here. Turns still " f"show their status message; only the mark on the message being " f"answered is missing. Re-invite the bot with the permissions " f"in DISCORD_SETUP.md." ) from error raise ActivityMarkRefused( - f"Discord refused to take the working reaction off {message_ref}. " + f"Discord refused to take the {mark} reaction off {message_ref}. " f"Removing our own reaction needs no permission of its own, so this " f"is the bot's access to the channel rather than the reaction: check " f"it can still see {message_ref}." diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 07d1d8ac6..dde349282 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -29,6 +29,7 @@ from switch_core.agent_icon import default_icon_url from switch_core.bridges.collaboration.adapter import ( + ActivityMark, CollaborationAdapter, LiveRuntimeIndicator, RequestCard, @@ -71,7 +72,10 @@ _JOINABLE_MM_CHANNEL_TYPES = frozenset({"O", "P"}) # Emoji marking the message an agent is currently working on. -_WORKING_REACTION = "eyes" +_REACTION: dict[ActivityMark, str] = { + "working": "eyes", + "queued": "hourglass_flowing_sand", +} # The post property carrying a publication's recovery marker. Props are part of # the post but not part of what anyone reads, which is exactly what this needs @@ -206,6 +210,7 @@ class MattermostAdapter(CollaborationAdapter): redraws_for_elapsed_time: ClassVar[bool] = False supports_activity_reactions: ClassVar[bool] = True + supports_queue_reaction: ClassVar[bool] = True #: Each agent posts and reacts as its own bot here, so two agents working #: on one message leave two independent πŸ‘€ and each is claimed and removed @@ -273,7 +278,7 @@ def __init__(self, *, config: MattermostConnectionConfig) -> None: # (agent_name, post_id) currently carrying the working reaction. A # reaction belongs to the bot that added it, so two agents on the same # post are two independent marks. - self._eyes: set[tuple[str, str]] = set() + self._marked: set[tuple[str, str, ActivityMark]] = set() # (channel_id, agent_name) -> the posts that agent has marked. An agent # asked two things at once works on both, and the turn ends once β€” so @@ -924,10 +929,11 @@ async def mark_activity( message_ref: str, *, agent_name: str, - working: bool, + mark: ActivityMark, + on: bool, force: bool = False, ) -> None: - """Put this agent's πŸ‘€ on the message it is working on, or take it off. + """Put this agent's mark on the message, or take it off. Per agent, because the reaction belongs to the bot that added it: two agents on one message are two marks, and one finishing leaves the @@ -940,7 +946,7 @@ async def mark_activity( only once the channel actually shows what it says it shows. """ await self._react_or_raise( - agent_name, message_ref, working=working, force=force + agent_name, message_ref, mark=mark, on=on, force=force ) async def notify_working( @@ -1202,7 +1208,7 @@ async def _mark_being_read( """ try: await self._react_or_raise( - agent_name, post_id, working=working, force=force + agent_name, post_id, mark="working", on=working, force=force ) except Exception as e: logger.warning( @@ -1213,7 +1219,13 @@ async def _mark_being_read( ) async def _react_or_raise( - self, agent_name: str, post_id: str, *, working: bool, force: bool + self, + agent_name: str, + post_id: str, + *, + mark: ActivityMark, + on: bool, + force: bool, ) -> None: """Put πŸ‘€ on the post an agent is working on, and take it off after. @@ -1223,19 +1235,21 @@ async def _react_or_raise( unlike the status post it needs no thread, and unlike the typing indicator it does not expire. - `self._eyes` is this process's memory of what it has already done, and - a restart empties it while the reactions stay in the channel. `force` - is for the caller that knows better from the journal: skipping the - call because the set is empty would strand a πŸ‘€ on a turn that ended - while the bridge was down. + `self._marked` is this process's memory of what it has already done, + and a restart empties it while the reactions stay in the channel. + `force` is for the caller that knows better from the journal: skipping + the call because the set is empty would strand a πŸ‘€ on a turn that + ended while the bridge was down. It is remembered per reaction: a + queued prompt that starts running loses the hourglass and keeps the + eyes, so one cannot answer for the other. A failure leaves that memory alone, so the next attempt is a real attempt rather than one the record talks out of trying. Removing a reaction Mattermost says is not there is the exception: the channel is already in the state being asked for, and there is nothing to retry. """ - key = (agent_name, post_id) - if not force and working == (key in self._eyes): + key = (agent_name, post_id, mark) + if not force and on == (key in self._marked): return bot_info = self._agent_bots.get(agent_name) @@ -1245,17 +1259,17 @@ async def _react_or_raise( raise RuntimeError(f"no connected bot for {agent_name!r}") user_id = bot_info["user_id"] - if working: + if on: await loop.run_in_executor( None, driver.reactions.create_reaction, { "user_id": user_id, "post_id": post_id, - "emoji_name": _WORKING_REACTION, + "emoji_name": _REACTION[mark], }, ) - self._eyes.add(key) + self._marked.add(key) return try: await loop.run_in_executor( @@ -1263,11 +1277,11 @@ async def _react_or_raise( driver.reactions.delete_reaction, user_id, post_id, - _WORKING_REACTION, + _REACTION[mark], ) except ResourceNotFound: pass - self._eyes.discard(key) + self._marked.discard(key) async def _reposition_runtime_state( self, channel_id: str, agent_name: str, thread_root_id: str | None diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 41b8b5295..7701dbaa6 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -1040,7 +1040,8 @@ async def _mark_thread( anchor.channel_id, anchor.reaction_ref, agent_name=anchor.agent_name, - working=working, + mark="working", + on=working, **({"force": True} if self._journal else {}), ) except ActivityMarkRefused as refusal: diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 01ccc5ef0..79d468e9b 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -21,6 +21,7 @@ from slack_sdk.web.async_client import AsyncWebClient from switch_core.bridges.collaboration.adapter import ( + ActivityMark, CollaborationAdapter, RequestCard, RichContent, @@ -122,8 +123,12 @@ def _group_description(agent_description: str) -> str: return full[: _GROUP_DESCRIPTION_MAX - 1].rstrip() + "…" -# Reaction on the message an SDK turn is handling. -_WORKING_REACTION = "eyes" +# Reactions on the message an SDK turn is handling: one for work under way, +# one for a prompt the agent is holding behind something else. +_REACTION: dict[ActivityMark, str] = { + "working": "eyes", + "queued": "hourglass_flowing_sand", +} def _retry_after_seconds(error: SlackApiError) -> int: @@ -165,6 +170,7 @@ class SlackAdapter(CollaborationAdapter): separate_attention_slot: ClassVar[bool] = True redraws_for_elapsed_time: ClassVar[bool] = True supports_activity_reactions: ClassVar[bool] = True + supports_queue_reaction: ClassVar[bool] = True renders_legacy_runtime_state: ClassVar[bool] = False recovers_uncertain_posts: ClassVar[bool] = True @@ -223,8 +229,8 @@ def __init__(self, *, config: SlackConnectionConfig) -> None: # Set to Slack's error code once the workspace has told us it cannot # host user groups, so the bridge stops asking and says so only once. self._agent_usergroups_off_reason: str | None = None - # (channel_id, ts) currently carrying the "being worked on" reaction. - self._eyes: set[tuple[str, str]] = set() + # (channel_id, ts, mark) currently carrying that reaction. + self._marked: set[tuple[str, str, ActivityMark]] = set() # (channel_id, ts) Slack says it cannot find. Retrying it on every # progress report of a long turn is how one unmarkable message became # a warning a second for as long as the agent worked. @@ -1026,48 +1032,58 @@ async def _mark_being_read( channel_id: str, thread_ts: str | None, *, - working: bool, + mark: ActivityMark, + on: bool, force: bool = False, ) -> None: - """Mark the asking message and cache expected Slack reaction refusals.""" + """Mark the asking message and cache expected Slack reaction refusals. + + The memory of what is already there is per reaction, because the two + are independent: a queued prompt that starts running loses one mark and + keeps the other, and a message can carry another turn's working mark + while this one is still waiting. A message Slack says does not exist is + not per reaction β€” there is nothing there to carry either. + """ ts = thread_ts if not ts or not self._web_client: return - key = (channel_id, ts) - if not force and working == (key in self._eyes): + message = (channel_id, ts) + key = (channel_id, ts, mark) + if not force and on == (key in self._marked): return - if key in self._unmarkable: + if message in self._unmarkable: return try: - if working: + if on: await self._web_client.reactions_add( - channel=channel_id, timestamp=ts, name=_WORKING_REACTION + channel=channel_id, timestamp=ts, name=_REACTION[mark] ) - self._eyes.add(key) + self._marked.add(key) else: await self._web_client.reactions_remove( - channel=channel_id, timestamp=ts, name=_WORKING_REACTION + channel=channel_id, timestamp=ts, name=_REACTION[mark] ) - self._eyes.discard(key) + self._marked.discard(key) except SlackApiError as e: error = e.response.get("error", "") # Already there, or already gone: the end state is what was wanted, # so record it and say nothing. if error in ("already_reacted", "no_reaction"): - self._eyes.add(key) if working else self._eyes.discard(key) + self._marked.add(key) if on else self._marked.discard(key) return if error == "message_not_found": # There is no message to mark, and there will not be one later. - self._unmarkable[key] = None + self._unmarkable[message] = None if len(self._unmarkable) > self._unmarkable_max: self._unmarkable.popitem(last=False) return if force: raise logger.warning( - "Could not %s the working reaction on %s in %s: %s", - "add" if working else "remove", + "Could not %s the %s reaction on %s in %s: %s", + "add" if on else "remove", + mark, ts, channel_id, error or e, @@ -1079,7 +1095,8 @@ async def mark_activity( message_ref: str, *, agent_name: str, - working: bool, + mark: ActivityMark, + on: bool, force: bool = False, ) -> None: """Mark the asking message, accepting either a timestamp or channel:ts. @@ -1093,7 +1110,11 @@ async def mark_activity( still running would both be the same mark. """ await self._mark_being_read( - channel_id, self._thread_ts_of(message_ref), working=working, force=force + channel_id, + self._thread_ts_of(message_ref), + mark=mark, + on=on, + force=force, ) @staticmethod diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index df4e92363..f1dfd55e1 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -36,6 +36,7 @@ from switch_core.bridges.agent.commands import COMMANDS, COMMANDS_BY_NAME, CommandArg from switch_core.bridges.collaboration.adapter import ( + ActivityMark, ActivityMarkRefused, CollaborationAdapter, LiveRuntimeIndicator, @@ -1718,7 +1719,8 @@ async def mark_activity( message_ref: str, *, agent_name: str, - working: bool, + mark: ActivityMark, + on: bool, force: bool = False, ) -> None: """Put πŸ‘€ on the message being worked on, or take it off. @@ -1728,6 +1730,12 @@ async def mark_activity( message. The publisher counts the turns holding it, so the first to want it adds it and the last to finish removes it. + That single reaction is also why `supports_queue_reaction` is false + here and `mark` is only ever the working one: a second mark could only + be put on by taking this one off, and a queued prompt saying nothing is + better than a running one that has stopped saying it is being read. + Telegram carries the queued state in its status text instead. + `force` is the durable publisher reconciling after a restart, when this process's record of what is already on the message is empty and wrong rather than empty and right. @@ -1755,20 +1763,20 @@ async def mark_activity( ) return key = (channel_id, message_id) - if not force and working == (key in self._reacted): + if not force and on == (key in self._reacted): return try: await self._require_bot().set_message_reaction( chat_id=self._chat_id(channel_id), message_id=int(message_id), - reaction=[ReactionTypeEmoji(_WORKING_REACTION)] if working else [], + reaction=[ReactionTypeEmoji(_WORKING_REACTION)] if on else [], ) except (BadRequest, Forbidden) as error: raise ActivityMarkRefused( - f"Telegram will not {'add' if working else 'remove'} the working " + f"Telegram will not {'add' if on else 'remove'} the working " f"reaction on {message_id} in chat {channel_id} ({error})." ) from error - if working: + if on: self._reacted.add(key) else: self._reacted.discard(key) diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index fa166ae1a..90b363a9c 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -79,8 +79,8 @@ async def find_request_card(self, channel, thread, token, created_at, handle): return ref return None - async def mark_activity(self, channel, ref, *, agent_name, working, force=False): - if working: + async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): + if on: self.reactions.add(ref) else: self.reactions.discard(ref) @@ -136,8 +136,8 @@ def __init__(self): super().__init__() self.reactions = set() - async def mark_activity(self, channel, ref, *, agent_name, working, force=False): - if working: + async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): + if on: self.reactions.add((agent_name, ref)) else: self.reactions.discard((agent_name, ref)) @@ -1185,8 +1185,8 @@ def __init__(self, chat, *, refuse_add=False, refuse_remove=False): self.refuse_add = refuse_add self.refuse_remove = refuse_remove - async def mark_activity(self, channel, ref, *, agent_name, working, force=False): - if working: + async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): + if on: if self.refuse_add: raise ActivityMarkRefused("reactions are switched off in this chat") self.reactions.add(ref) @@ -1380,11 +1380,11 @@ def __init__(self, chat, *, while_unacknowledged): super().__init__(chat) self.while_unacknowledged = while_unacknowledged - async def mark_activity(self, channel, ref, *, agent_name, working, force=False): + async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): await super().mark_activity( - channel, ref, agent_name=agent_name, working=working, force=force + channel, ref, agent_name=agent_name, mark=mark, on=on, force=force ) - if not working: + if not on: await self.while_unacknowledged() @@ -1550,11 +1550,11 @@ def __init__(self, chat): super().__init__(chat) self.lose_the_answer = True - async def mark_activity(self, channel, ref, *, agent_name, working, force=False): + async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): await super().mark_activity( - channel, ref, agent_name=agent_name, working=working, force=force + channel, ref, agent_name=agent_name, mark=mark, on=on, force=force ) - if working and self.lose_the_answer: + if on and self.lose_the_answer: raise TimeoutError("the answer to the reaction never came back") From 085d3b99378fa0add3570929dff582dc1f77bc3f Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 18:19:57 +0100 Subject: [PATCH 037/120] Stop a finished turn putting back a mark another turn took off A turn's receipt is assembled before it is written, and a platform call sits in the gap. Two turns share one mark, so the turn that takes it off is routinely another one: it can clear the claim in that gap, and the whole-document save that follows writes the claim straight back from a copy read before any of it happened. Nothing answers for the claim after that. The reaction is off the message, so no removal is coming, and a later turn on that message asks whether a mark may still be there, is told yes by a row describing a reaction that is not, and never reports itself finished. So a save no longer writes the claim at all. The two claim keys are carried across from the row as the statement runs, and change only through `claim` and `disclaim`, which name the attempt they speak for. Both are single statements, leaving no moment between a read and a write for a removal to fall into. Also takes the rows of a multi-holder removal in a fixed order, so two removals clearing an overlapping set cannot each hold a row the other is waiting for. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/activity_journal.py | 154 ++++++++++++++++-- .../bridges/collaboration/session/outbound.py | 38 ++--- .../sessions/test_activity_durability.py | 83 ++++++++++ 3 files changed, 233 insertions(+), 42 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index e7cd8506b..e342eed68 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -14,14 +14,43 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass +from typing import Any -from sqlalchemy import Text, cast, func, select, text, update +from sqlalchemy import Text, cast, func, literal, select, text, update from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker +from sqlalchemy.sql.elements import ColumnElement from switch_core.db.models import SessionActivityPost, require_tenant_id +def _without_claim(document: Any) -> ColumnElement: + """The document with the reaction claim taken out of it.""" + stripped: ColumnElement = ( + document.op("-")(cast("mark", Text)) + .op("-")(cast("mark_attempt", Text)) + .cast(JSONB) + ) + return stripped + + +def _claim_in(document: Any) -> ColumnElement: + """Just the reaction claim, as the row holds it at this moment. + + Empty where the row carries none: an absent key reads as SQL NULL, becomes + a JSON null in the object built from it, and is stripped back out. + """ + return func.jsonb_strip_nulls( + func.jsonb_build_object( + "mark", + document["mark"], + "mark_attempt", + document["mark_attempt"], + ) + ) + + @dataclass class ActivityRecord: sessions: async_sessionmaker[AsyncSession] @@ -29,20 +58,110 @@ class ActivityRecord: data: dict async def save(self) -> None: + """Write this turn's own state, leaving the shared claim where it is. + + Everything in `data` belongs to this turn and is written whole β€” the + anchor it redraws from, the delivery reservation, the end of the turn. + The reaction claim does not. Turns share one mark, so the turn that + takes it off is routinely another one, and it clears the claim holding + no lock this turn holds. The copy in `data` was read when the record + was opened, which may be long before this write: a platform call sits + in between. Writing it back is how a reaction genuinely taken off the + message returns as an expectation nothing will ever answer for, and a + later turn on that message waits forever on it. + + So the two claim keys are carried across from the row as it stands when + the statement runs, and are changed only by `claim` and `disclaim`, + which say which attempt they speak for. The row is written in one + statement for the same reason: there is no moment between reading it + and writing it for a removal to fall into. + """ + insert = pg_insert(SessionActivityPost).values( + tenant_id=self.key[0], + bridge_id=self.key[1], + session_id=self.key[2], + command_id=self.key[3], + data=self.data.copy(), + ) + async with self.sessions() as db: + await db.execute( + insert.on_conflict_do_update( + index_elements=[ + SessionActivityPost.tenant_id, + SessionActivityPost.bridge_id, + SessionActivityPost.session_id, + SessionActivityPost.command_id, + ], + set_={ + "data": _without_claim(insert.excluded.data).op("||")( + _claim_in(SessionActivityPost.data) + ) + }, + ) + ) + await db.commit() + + async def claim(self, mark: dict[str, str], attempt: str) -> None: + """Take the reaction claim for this attempt, whatever the row held. + + Its own statement rather than part of the next `save`, because the two + answer to different owners: the rest of the row is this turn's and the + claim is shared with whichever turn eventually takes the mark off. A + fresh attempt supersedes what was there unconditionally β€” this turn is + asking for the reaction now, and that is true whoever asked before. + """ async with self.sessions() as db: - row = await db.get(SessionActivityPost, self.key) - if row is None: - row = SessionActivityPost( - tenant_id=self.key[0], - bridge_id=self.key[1], - session_id=self.key[2], - command_id=self.key[3], - data=self.data.copy(), + await db.execute( + update(SessionActivityPost) + .where(*self._row()) + .values( + data=SessionActivityPost.data.op("||")( + literal({"mark": mark, "mark_attempt": attempt}, JSONB) + ) ) - db.add(row) - else: - row.data = self.data.copy() + ) + await db.commit() + self.data["mark"] = mark + self.data["mark_attempt"] = attempt + + async def disclaim(self, attempt: str, *, renewed: str | None) -> None: + """Give the claim up, or put it back to the attempt this one renewed. + + Only while the row still names the attempt being given up. A refusal + speaks for the attempt it answers and for nothing that happened after + it: a later ask is a claim in its own right, and a removal issued + against an earlier one has already cleared what it was entitled to. + """ + held = func.coalesce(SessionActivityPost.data["mark_attempt"].astext, "") + forgotten = ( + _without_claim(SessionActivityPost.data) + if renewed is None + else SessionActivityPost.data.op("||")( + literal({"mark_attempt": renewed}, JSONB) + ) + ) + async with self.sessions() as db: + await db.execute( + update(SessionActivityPost) + .where(*self._row(), held == attempt) + .values(data=forgotten) + ) await db.commit() + if self.data.get("mark_attempt") != attempt: + return + if renewed is None: + self.data.pop("mark", None) + self.data.pop("mark_attempt", None) + else: + self.data["mark_attempt"] = renewed + + def _row(self) -> tuple[ColumnElement, ...]: + return ( + SessionActivityPost.tenant_id == self.key[0], + SessionActivityPost.bridge_id == self.key[1], + SessionActivityPost.session_id == self.key[2], + SessionActivityPost.command_id == self.key[3], + ) class ActivityJournal: @@ -189,17 +308,16 @@ async def forget_mark( newer attempt, a delivery reservation or the end of its turn. Writing back a whole edited copy would erase all of it β€” and the stale attempt it carried would reinstate a claim this removal never answered for. + + Rows are taken in a fixed order so that two removals clearing an + overlapping set cannot each hold a row the other is waiting for. """ if not holders: return held = func.coalesce(SessionActivityPost.data["mark_attempt"].astext, "") - forgotten = ( - SessionActivityPost.data.op("-")(cast("mark", Text)) - .op("-")(cast("mark_attempt", Text)) - .cast(JSONB) - ) + forgotten = _without_claim(SessionActivityPost.data) async with sessions() as db: - for session_id, command_id, attempt in holders: + for session_id, command_id, attempt in sorted(holders): await db.execute( update(SessionActivityPost) .where( diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 7701dbaa6..01d3dd356 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -425,19 +425,18 @@ async def draw() -> bool: # discard delivery reservations and reaction/log anchors. # An outstanding mark is not this turn's to discard: the # holder that takes it off may be another turn entirely, - # and it needs to know the mark is there. The stamp goes - # with it β€” a claim is named by the ask that made it, and - # one reduced to an unstamped claim is one no removal - # issued against the real ask can ever clear. - claim = { - field: record.data[field] - for field in ("mark", "mark_attempt") - if field in record.data - } + # and it needs to know the mark is there. The row keeps + # it either way β€” a save cannot write the claim β€” so + # this carries it across to keep the copy here honest + # about what the row still says. record.data = { "turn_id": turn.turn_id, "ended": True, - **claim, + **{ + field: record.data[field] + for field in ("mark", "mark_attempt") + if field in record.data + }, } record.data["completed"] = True await record.save() @@ -1118,9 +1117,7 @@ async def _expect_mark( attempt = _MarkAttempt(secrets.token_urlsafe(16), renewed) expecting[key] = attempt.token if record is not None: - record.data["mark"] = mark - record.data["mark_attempt"] = attempt.token - await record.save() + await record.claim(mark, attempt.token) return attempt async def _retract_attempt( @@ -1148,14 +1145,8 @@ async def _retract_attempt( else: expecting[key] = attempt.renewed record = self._record.get() - if record is None or record.data.get("mark_attempt") != attempt.token: - return - if attempt.renewed is None: - record.data.pop("mark", None) - record.data.pop("mark_attempt", None) - else: - record.data["mark_attempt"] = attempt.renewed - await record.save() + if record is not None: + await record.disclaim(attempt.token, renewed=attempt.renewed) async def _mark_taken_off( self, @@ -1187,9 +1178,8 @@ async def _mark_taken_off( record is not None and (*key, record.data.get("mark_attempt", "")) in holders ): - if record.data.pop("mark", None) is not None: - record.data.pop("mark_attempt", None) - await record.save() + record.data.pop("mark", None) + record.data.pop("mark_attempt", None) if self._journal is not None: await self._journal.forget_mark( mark, diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 90b363a9c..e1fbbfc54 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -2,6 +2,7 @@ import asyncio import logging +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta import pytest @@ -1753,3 +1754,85 @@ async def test_a_turn_that_ends_holding_the_mark_keeps_the_ask_it_made( receipt = await _row(session_factory) assert receipt["mark"] == MARK | {"agent_name": ""} assert receipt["mark_attempt"] + + +class PausedReceipt(ActivityJournal): + """Holds one turn's receipt at the moment before it is written. + + The turn has finished and its receipt is assembled; the write has not + happened. That gap is real β€” the platform calls of an ending turn sit in + it β€” and it is where another turn gets to take the shared mark off. + """ + + def __init__(self, sessions, bridge_id, *, command, meanwhile): + super().__init__(sessions, bridge_id) + self.command = command + self.meanwhile = meanwhile + self.paused = False + + @asynccontextmanager + async def open(self, session_id, command_id): + async with super().open(session_id, command_id) as record: + if record is None or command_id != self.command: + yield record + return + write = record.save + + async def save(): + if record.data.get("completed") and not self.paused: + self.paused = True + await self.meanwhile() + await write() + + record.save = save + yield record + + +async def test_a_late_receipt_does_not_put_back_a_mark_another_turn_took_off( + session_factory, +): + """The same race the other way round: the removal lands first. + + Two turns share the mark. The first ends while the second is still running, + so it leaves the mark alone and settles down to write its receipt. The + second ends in that gap, takes the mark off the message and clears both + claims. The first then writes a receipt built from a row it read before any + of that, and a whole-document write puts the claim back. + + Nothing will ever answer for it. The reaction is off the message, so no + removal is coming, and the claim outlives its turn: a later turn on that + message, in a chat that has since stopped letting the bot react, asks + whether a mark may still be there, is told yes by a row describing a + reaction that is not, and never reports itself finished. + """ + await setup(session_factory) + chat = {"reactions": set(), "messages": {}} + platform = RefusingPlatform(chat) + second = activity(session_factory, platform) + + async def the_other_turn_ends(): + assert await publish(second, "completed", command="second") + assert not chat["reactions"] + + first = SessionTurnActivity( + platform, + journal=PausedReceipt( + session_factory, "bridge", command="first", meanwhile=the_other_turn_ends + ), + ) + assert await publish(first, command="first") + assert await publish(second, command="second") + assert chat["reactions"] == {"channel-demo:question"} + + assert await publish(first, "completed", command="first") + + assert not chat["reactions"] + journal = ActivityJournal(session_factory, "bridge") + assert not await journal.mark_expected( + MARK | {"agent_name": ""}, sessions=session_factory + ) + + refusing = RefusingPlatform(chat, refuse_add=True, refuse_remove=True) + later = activity(session_factory, refusing) + assert await publish(later, command="third") + assert await publish(later, "completed", command="third") From 82ad1b92b50d5f5e4d5cb370399fdd1baecf8cd6 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 18:27:04 +0100 Subject: [PATCH 038/120] Ask the adapters for the working mark by its name in the tests too The rename of `mark_activity`'s boolean to `mark` and `on` reached the adapters and the durability tests but not the per-platform suites, which kept calling `working=` and reading the old `_eyes` set. Twenty-two tests failed on the signature rather than on anything they were testing. Co-Authored-By: Claude Opus 5 --- .../collaboration/test_discord_sdk_only.py | 20 +++++----- .../collaboration/test_mattermost_sdk_only.py | 38 +++++++++++++------ .../test_mattermost_working_reaction.py | 2 +- .../test_session_turn_messages.py | 2 +- .../collaboration/test_slack_sdk_only.py | 15 +++++--- .../collaboration/test_telegram_sdk_only.py | 23 ++++++----- 6 files changed, 62 insertions(+), 38 deletions(-) diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index d76522a47..c795f88a2 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -914,13 +914,13 @@ async def test_one_mark_is_shared_between_agents_and_not_added_twice() -> None: ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" await adapter.mark_activity( - str(CHANNEL_ID), ref, agent_name="my-agent", working=True + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True ) await adapter.mark_activity( - str(CHANNEL_ID), ref, agent_name="other-agent", working=True + str(CHANNEL_ID), ref, agent_name="other-agent", mark="working", on=True ) await adapter.mark_activity( - str(CHANNEL_ID), ref, agent_name="my-agent", working=False + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=False ) assert channel.reactions == [("πŸ‘€", True), ("πŸ‘€", False)] @@ -931,10 +931,10 @@ async def test_force_marks_again_because_the_record_may_be_empty_and_wrong() -> ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" await adapter.mark_activity( - str(CHANNEL_ID), ref, agent_name="my-agent", working=True + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True ) await adapter.mark_activity( - str(CHANNEL_ID), ref, agent_name="my-agent", working=True, force=True + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True, force=True ) assert channel.reactions == [("πŸ‘€", True), ("πŸ‘€", True)] @@ -949,7 +949,8 @@ async def test_a_missing_permission_is_refused_rather_than_swallowed() -> None: str(CHANNEL_ID), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", agent_name="my-agent", - working=True, + mark="working", + on=True, ) @@ -964,13 +965,13 @@ async def test_a_mark_that_cannot_be_taken_off_is_refused_not_shrugged_away() -> adapter, channel, _thread, _webhook = _guild_setup() ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" await adapter.mark_activity( - str(CHANNEL_ID), ref, agent_name="my-agent", working=True + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True ) channel.reaction_error = discord.Forbidden(_Response(), "cannot see the channel") # type: ignore[arg-type] with pytest.raises(ActivityMarkRefused, match="still see"): await adapter.mark_activity( - str(CHANNEL_ID), ref, agent_name="my-agent", working=False + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=False ) @@ -1012,7 +1013,8 @@ async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> str(CHANNEL_ID), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", agent_name="my-agent", - working=True, + mark="working", + on=True, ) diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index dea568926..ef6dbd410 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -529,9 +529,15 @@ async def test_a_search_that_could_not_run_is_not_found_rather_than_a_guess() -> async def test_two_agents_on_one_message_are_two_independent_marks() -> None: adapter = _adapter("worker", "other") - await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=True) - await adapter.mark_activity("chan-1", "post-1", agent_name="other", working=True) - await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=False) + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", mark="working", on=True + ) + await adapter.mark_activity( + "chan-1", "post-1", agent_name="other", mark="working", on=True + ) + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", mark="working", on=False + ) worker: Any = adapter._bot_drivers["worker"] other: Any = adapter._bot_drivers["other"] @@ -551,7 +557,7 @@ async def test_a_mark_that_did_not_happen_is_raised_rather_than_swallowed() -> N with pytest.raises(ConnectionError): await adapter.mark_activity( - "chan-1", "post-1", agent_name="worker", working=True + "chan-1", "post-1", agent_name="worker", mark="working", on=True ) @@ -560,23 +566,25 @@ async def test_an_agent_with_no_bot_cannot_mark_and_says_so() -> None: with pytest.raises(RuntimeError): await adapter.mark_activity( - "chan-1", "post-1", agent_name="ghost", working=True + "chan-1", "post-1", agent_name="ghost", mark="working", on=True ) async def test_a_failed_mark_is_tried_again_rather_than_recorded_as_done() -> None: - """`self._eyes` is this process's memory of what it has already done. A + """`self._marked` is this process's memory of what it has already done. A failure recorded there would talk the retry out of trying.""" adapter = _adapter() driver: Any = adapter._bot_drivers["worker"] driver.reactions.create_error = ConnectionError("temporary network failure") with pytest.raises(ConnectionError): await adapter.mark_activity( - "chan-1", "post-1", agent_name="worker", working=True + "chan-1", "post-1", agent_name="worker", mark="working", on=True ) driver.reactions.create_error = None - await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=True) + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", mark="working", on=True + ) assert driver.reactions.calls == [("add", "bot-worker", "post-1", "eyes")] @@ -585,14 +593,20 @@ async def test_clearing_a_mark_mattermost_says_is_gone_is_not_a_failure() -> Non """The channel is already in the state being asked for, so there is nothing for the caller to retry.""" adapter = _adapter() - await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=True) + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", mark="working", on=True + ) driver: Any = adapter._bot_drivers["worker"] driver.reactions.delete_error = ResourceNotFound("404 reaction not found") - await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=False) + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", mark="working", on=False + ) driver.reactions.delete_error = None - await adapter.mark_activity("chan-1", "post-1", agent_name="worker", working=True) + await adapter.mark_activity( + "chan-1", "post-1", agent_name="worker", mark="working", on=True + ) assert driver.reactions.calls == [ ("add", "bot-worker", "post-1", "eyes"), ("add", "bot-worker", "post-1", "eyes"), @@ -620,7 +634,7 @@ async def test_a_mark_left_over_from_before_a_restart_is_still_cleared() -> None adapter = _adapter() await adapter.mark_activity( - "chan-1", "post-1", agent_name="worker", working=False, force=True + "chan-1", "post-1", agent_name="worker", mark="working", on=False, force=True ) driver: Any = adapter._bot_drivers["worker"] diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py index b672885f6..cc669bfe3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py @@ -228,7 +228,7 @@ def test_a_failed_reaction_does_not_break_the_turn( with caplog.at_level(logging.WARNING): _run(adapter, ("working", "post-1")) - assert ("worker", "post-1") not in adapter._eyes + assert ("worker", "post-1", "working") not in adapter._marked assert any("working reaction" in r.getMessage() for r in caplog.records) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py index 7a0cfcafd..d73bfbaf7 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py @@ -457,7 +457,7 @@ async def test_releasing_a_claim_this_turn_never_made_still_clears_a_live_reacti `:eyes:` for good.""" client = FakeWebClient() adapter = _adapter(client) - adapter._eyes.add((CHANNEL, "parent-1")) + adapter._marked.add((CHANNEL, "parent-1", "working")) activity = SessionTurnActivity(adapter) await _publish(activity, [_item()], _turn("completed"), thread_root_id="parent-1") diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py index d3efaddff..651ddcafa 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py @@ -72,9 +72,11 @@ async def test_native_stop_event_is_acknowledged_without_interrupting_an_sdk_tur async def test_reaction_cache_handles_expected_slack_refusals_quietly(error, caplog): slack, client = adapter() client.reaction_error = error - await slack.mark_activity("C1", "C1:1.0", agent_name="worker", working=True) + await slack.mark_activity( + "C1", "C1:1.0", agent_name="worker", mark="working", on=True + ) client.reaction_error = None - await slack.mark_activity("C1", "1.0", agent_name="worker", working=True) + await slack.mark_activity("C1", "1.0", agent_name="worker", mark="working", on=True) assert not client.reactions assert not caplog.records @@ -82,7 +84,7 @@ async def test_reaction_cache_handles_expected_slack_refusals_quietly(error, cap async def test_reaction_force_reconciles_after_restart(): slack, client = adapter() await slack.mark_activity( - "C1", "C1:1.0", agent_name="worker", working=False, force=True + "C1", "C1:1.0", agent_name="worker", mark="working", on=False, force=True ) assert client.reactions == [("remove", "1.0", "eyes")] @@ -120,9 +122,10 @@ async def test_activity_layout_is_an_adapter_capability_not_a_slack_type_check() ("worker", "C1:status"), ("worker", "C1:log"), ] - assert [ - call.kwargs["working"] for call in platform.mark_activity.call_args_list - ] == [True, False] + assert [call.kwargs["on"] for call in platform.mark_activity.call_args_list] == [ + True, + False, + ] async def test_typed_interrupt_still_routes_to_the_global_command(): diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 628c794ea..8b113739b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -865,10 +865,10 @@ async def test_one_mark_is_shared_between_agents_and_not_added_twice() -> None: adapter = _adapter() await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True ) await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="two", working=True + CHANNEL, f"{CHAT_ID}:55", agent_name="two", mark="working", on=True ) assert len(_bot(adapter).reactions) == 1 @@ -880,10 +880,10 @@ async def test_force_marks_again_because_the_record_may_be_empty_and_wrong() -> adapter = _adapter() await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True, force=True + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True, force=True ) await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True, force=True + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True, force=True ) assert len(_bot(adapter).reactions) == 2 @@ -897,7 +897,7 @@ async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> with pytest.raises(TimedOut): await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True ) @@ -909,7 +909,7 @@ async def test_a_chat_with_reactions_off_says_so_rather_than_swallowing_it() -> with pytest.raises(ActivityMarkRefused): await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True ) @@ -923,13 +923,13 @@ async def test_a_refused_removal_is_reported_whatever_this_process_remembers() - """ adapter = _adapter() await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=True + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True ) _bot(adapter).reaction_error = Forbidden("the bot may no longer react here") with pytest.raises(ActivityMarkRefused): await adapter.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=False + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=False ) fresh = _adapter() @@ -937,7 +937,12 @@ async def test_a_refused_removal_is_reported_whatever_this_process_remembers() - with pytest.raises(ActivityMarkRefused): await fresh.mark_activity( - CHANNEL, f"{CHAT_ID}:55", agent_name="one", working=False, force=True + CHANNEL, + f"{CHAT_ID}:55", + agent_name="one", + mark="working", + on=False, + force=True, ) From 1fb1ca2664463c1c9f86c8d6872d2864a8a6a583 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 19:00:50 +0100 Subject: [PATCH 039/120] Put the command in the code span as the command, not as prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card's command was escaped for prose and then wrapped in backticks, so `cat my_file.txt` reached a Discord reader with a backslash in front of the underscore. Nothing between backticks is read as syntax: the escape buys nothing there and the reader cannot tell whether the backslash is part of the command. Which escape a literal needs is a property of the spelling, not of the renderer, so `Markup.literal` now names it. Markdown's span takes its content verbatim; Telegram's `` still reads `&` and `<`, and Teams has no span at all and draws both literals as prose, so those two keep the adapter's escape. The handle takes the same route, which is no change today β€” handles are alphanumeric β€” but stops the next one. The fence is now a run of backticks the content cannot close, rather than always one. A command containing a backtick used to end its own span and spill the rest of itself into the body. Budget and faithfulness follow the same spelling: a command is measured and cut as its span will really carry it, instead of reserving room for backslashes the reader never sees. A detail spanning lines stays prose, decided on the detail rather than on what survived fitting. Co-Authored-By: Claude Opus 5 --- .../session/renderers/__init__.py | 40 ++++++- .../session/renderers/neutral.py | 31 ++++-- .../bridges/collaboration/teams/adapter.py | 9 ++ .../bridges/collaboration/telegram/adapter.py | 10 ++ .../test_session_settled_cards.py | 101 ++++++++++++++++++ 5 files changed, 179 insertions(+), 12 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index 5b588b9b6..f60a08567 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -11,6 +11,8 @@ from __future__ import annotations +import re +from collections.abc import Callable from dataclasses import dataclass from switch_core.sessions.contract import ( @@ -322,17 +324,45 @@ class Markup: copied and left to drift β€” and shipping `**Working…**` to a reader as those characters. - Not an escaper. Host text is neutralised by the adapter's own escape before - it reaches here, and what these produce is measured against the message - budget like anything else, so a spelling that costs more characters costs - them out of the same allowance. + Not an escaper, with one exception it cannot delegate: `literal` says how + text must be spelled to survive this markup's own code span, because only + the spelling knows whether its content is read as syntax. Everything else + is neutralised by the adapter's own escape before it reaches here, and what + these produce is measured against the message budget like anything else, so + a spelling that costs more characters costs them out of the same allowance. """ def bold(self, text: str) -> str: return f"**{text}**" + def literal(self, escape: Callable[[str], str]) -> Callable[[str], str]: + """The escape that gets text intact through `code` and `command`. + + A code span is not prose and the prose escape is wrong inside it: + Markdown reads nothing between the backticks as syntax, so a `_` + defused to `\\_` for the body arrives with the backslash showing, in + the one place on the card that is meant to be read literally. The + identity here is the whole point β€” a platform whose span does read its + content, as an HTML one reads `&` and `<`, overrides this with the + escape that makes it safe. + """ + return lambda text: text + def code(self, text: str) -> str: - return f"`{text}`" + """A literal, fenced by a run of backticks the content cannot close. + + A single backtick is the common case and the only one before this: a + command containing one ended the span early and spilled the rest of + itself into the body as prose. CommonMark closes a span on a run of + exactly the length that opened it, so a fence one longer than the + longest run inside is always safe. The padding spaces are stripped by + the same rule, and are there so content that starts or ends with a + backtick does not fuse with its own fence. + """ + longest = max((len(run) for run in re.findall(r"`+", text)), default=0) + fence = "`" * (longest + 1) + pad = " " if text.startswith("`") or text.endswith("`") else "" + return f"{fence}{pad}{text}{pad}{fence}" def command(self, text: str) -> str: """The command a card is asking about, set apart from the prose. diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 6b151cb38..ebff3ecc0 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -421,7 +421,8 @@ def _approval_form( control_label_limit: int | None, ) -> tuple[list[str], list[str], str, bool]: fit = _Faithful(escape) - handle = escape(reference.handle) + literal = markup.literal(escape) + handle = literal(reference.handle) outcome = ( _chosen(request, content, escape=escape, limit=limit) if request.state == "resolved" @@ -431,8 +432,15 @@ def _approval_form( head = [f"{markup.bold(heading)} Β· request {markup.code(handle)}"] head.append(fit(content.title, _share(limit, 1500, 3))) if content.detail: - detail = fit(content.detail, _share(limit, 1200, 4)) - head.append(markup.command(detail) if "\n" not in detail else detail) + # A command spanning lines is left as prose: a code span is one line, + # and the alternative is a fenced block nothing else on the card uses. + multiline = "\n" in content.detail + detail = fit( + content.detail, + _share(limit, 1200, 4), + escape=None if multiline else literal, + ) + head.append(detail if multiline else markup.command(detail)) body: list[str] = [] if request.state == "open": @@ -566,7 +574,7 @@ def _questions_form( responder: str | None, ) -> tuple[list[str], list[str], str, bool]: fit = _Faithful(escape) - handle = escape(reference.handle) + handle = markup.literal(escape)(reference.handle) head = [ f"{markup.bold(_QUESTION_HEADINGS[request.state])} Β· request " f"{markup.code(handle)}", @@ -883,9 +891,18 @@ def __init__(self, escape: Callable[[str], str]) -> None: self._escape = escape self.whole = True - def __call__(self, text: str, limit: int) -> str: - self.whole = self.whole and len(self._escape(text)) <= limit - return _fit(text, limit, escape=self._escape) + def __call__( + self, text: str, limit: int, escape: Callable[[str], str] | None = None + ) -> str: + """`escape` overrides the prose one for text bound somewhere else. + + A command is measured and cut as the code span will actually carry it. + Fitting it as prose would budget for backslashes the reader never sees + and cut the command short to make room for them. + """ + spell = escape or self._escape + self.whole = self.whole and len(spell(text)) <= limit + return _fit(text, limit, escape=spell) def _scope(option: ApprovalOption) -> str: diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index a9ea93ae2..7b87f6106 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -228,6 +228,15 @@ class _TeamsMarkup(Markup): heading and handle are already bold marks nothing out at all. """ + def literal(self, escape: Callable[[str], str]) -> Callable[[str], str]: + """The ordinary escape: neither spelling here is a code span. + + A handle becomes emphasis and a command stays plain, so both land in + prose that a TextBlock parses as Markdown. The Markdown default exists + for content nothing will read; this content is read. + """ + return escape + def code(self, text: str) -> str: return f"**{text}**" diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index f1dfd55e1..bbc338552 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -292,6 +292,16 @@ class _TelegramMarkup(Markup): def bold(self, text: str) -> str: return f"{text}" + def literal(self, escape: Callable[[str], str]) -> Callable[[str], str]: + """The ordinary escape: `` is still HTML inside. + + Telegram's span reads its content, so an `&` or a `<` in a command + needs defusing exactly as it would in the body. The Markdown default + passes text through untouched, which here would break the message + rather than merely litter it. + """ + return escape + def code(self, text: str) -> str: return f"{text}" diff --git a/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py b/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py index 09ac8e558..77a65428e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py @@ -12,11 +12,17 @@ from __future__ import annotations +import html + +from markdown_it import MarkdownIt + from switch_core.bridges.collaboration.session.renderers import MARKDOWN, Markup from switch_core.bridges.collaboration.session.renderers.neutral import request_summary from switch_core.bridges.collaboration.teams.adapter import _TEAMS_MARKUP +from switch_core.bridges.collaboration.telegram.adapter import TELEGRAM_HTML from switch_core.sessions.contract import ApprovalContent, SnapshotRequest +from .test_discord_sdk_only import _adapter as _discord_adapter from .test_session_neutral_forms import REFERENCE, _identity, _option ALLOW = _option("opt-allow", "Allow once") @@ -186,3 +192,98 @@ def test_a_detail_of_several_lines_is_not_a_command_and_is_left_alone() -> None: assert "`" not in lines[2] assert lines[2:4] == ["It will:", "run the suite"] + + +# ── What the code span actually carries ─────────────────────────────────────── + + +def _discord_escape(text: str) -> str: + return _discord_adapter({}).escape_label_for_body(text) + + +def _spans(text: str) -> list[str]: + """The literal content of every code span, as a Markdown reader sees it.""" + return [ + child.content + for block in MarkdownIt().parse(text) + for child in (block.children or []) + if child.type == "code_inline" + ] + + +def test_a_command_reaches_the_reader_as_the_command_it_is() -> None: + """The prose escape must not run inside a code span. + + Nothing between backticks is read as syntax, so `my_file.txt` needs no + defusing there β€” and defusing it anyway puts a backslash in front of the + underscore in the one place on the card meant to be read literally. The + reader cannot tell whether the backslash is part of the command. + """ + text = request_summary( + _settled(detail="cat my_file.txt"), + REFERENCE, + escape=_discord_escape, + limit=10_000, + markup=MARKDOWN, + ) + + assert "cat my_file.txt" in _spans(text) + assert "\\_" not in text + + +def test_the_handle_is_a_literal_too() -> None: + """Same span, same rule. A handle is alphanumeric today, so this pins the + reasoning rather than a live defect.""" + text = request_summary( + _settled(), + REFERENCE, + escape=_discord_escape, + limit=10_000, + markup=MARKDOWN, + ) + + assert "R42" in _spans(text) + + +def test_a_command_containing_a_backtick_does_not_end_its_own_span() -> None: + """A single backtick closed the span early and spilled the rest of the + command into the body as prose.""" + command = "echo `date`" + + assert command in _spans(_line(_settled(detail=command))) + + +def test_a_command_starting_and_ending_in_a_backtick_keeps_them() -> None: + """The padding spaces a fence needs here are stripped by the reader, so + they cost the card a little width and the reader nothing.""" + command = "`quoted`" + + assert command in _spans(_line(_settled(detail=command))) + + +def test_a_backslash_in_a_command_is_the_reader_s_backslash() -> None: + """Not an escape introduced by us, and not one removed.""" + command = r"grep '\d+' log.txt" + + assert command in _spans(_line(_settled(detail=command))) + + +def test_telegram_still_defuses_html_inside_its_code_span() -> None: + """Telegram's span reads its content, so it keeps the adapter's escape + where the Markdown platforms must drop it. Dropping it here would not + litter the card β€” it would break the message.""" + text = request_summary( + _settled(detail="grep file"), + REFERENCE, + escape=lambda body: html.escape(body, quote=False), + limit=10_000, + markup=TELEGRAM_HTML, + ) + + assert "grep <name> file" in text.splitlines() + + +def _line(request: SnapshotRequest) -> str: + return request_summary( + request, REFERENCE, escape=_identity, limit=10_000, markup=MARKDOWN + ) From e1223c5dacf4bc4734f54e594c5eb9269c39c686 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 19:09:22 +0100 Subject: [PATCH 040/120] Draw Telegram's fallback text with the options its buttons carried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Telegram card leaves its options out of the body: the inline keyboard beside it spells them out, and repeating them costs a line each. The text attached to a `RichContentFailed` was that same drawing β€” but the caller's answer to a refused post or edit is to republish it as a plain message with no keyboard under it, so what the reader got was a card ending "Reply with `R7 1`" that had never printed a 1. `_draw` now takes `controls`, and `rich_fallback_text` draws with it off, so the options are in the body wherever there is nothing else to carry them. Every site that raises a refusal takes its text from there rather than from the buttoned drawing. `render_request` decides `answerable` against the body it actually produced, so a fallback too long to list the options no longer claims a number can be replied with. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/telegram/adapter.py | 45 ++++++++++++++----- .../collaboration/test_telegram_sdk_only.py | 34 ++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index bbc338552..f6014a613 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -1356,13 +1356,22 @@ def rich_markup(self) -> Markup: return TELEGRAM_HTML def rich_fallback_text(self, content: RichContent) -> str: - """The same drawing `post_rich` sends, without the agent's name on it. + """The drawing `post_rich` sends, minus the agent's name and the buttons. Only the text an error carries, so there is nothing here to attribute: - it is what a caller logs or shows in the Console when the post did not - happen, not something anyone reads in the chat. + it is what a caller republishes, logs or shows in the Console when the + publication did not happen, not something anyone reads under a + keyboard. + + Which is why it is drawn with `controls=False`. A card that is going to + carry buttons leaves the options they spell out of its body, and this + text is for the places that have no buttons β€” republished on its own, a + card drawn the other way invites a numbered answer without printing the + numbers. """ - return self._draw(content, mention=None, responder=None, prefix="").text + return self._draw( + content, mention=None, responder=None, prefix="", controls=False + ).text def _draw( self, @@ -1371,6 +1380,7 @@ def _draw( mention: str | None, responder: str | None, prefix: str, + controls: bool, ) -> Drawn: escape = self._rich_escape limit = max(1, self.rich_fallback_limit() - len(prefix)) @@ -1407,7 +1417,7 @@ def _draw( markup=markup, responder=responder, unavailable_reason=content.unavailable_reason, - control_label_limit=_MAX_BUTTON_LABEL, + control_label_limit=_MAX_BUTTON_LABEL if controls else None, ) return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") @@ -1449,7 +1459,7 @@ def _controls( f"Cannot put a button on request {content.request.request_id} in " f"Telegram: its press would carry {len(data.encode())} bytes and " f"Telegram allows {_MAX_CALLBACK_BYTES}.", - text=drawn.text, + text=self.rich_fallback_text(content), ) rows.append( [InlineKeyboardButton(text=_button_label(control), callback_data=data)] @@ -1480,6 +1490,7 @@ async def _render_rich(self, content: RichContent, agent_name: str) -> Drawn: mention=self._mention(content.notify_external_id), responder=responder, prefix=prefix, + controls=True, ) def _mention(self, external_user_id: str | None) -> str | None: @@ -1564,7 +1575,9 @@ async def post_rich( ) except Exception as error: raise self._rich_failure( - error, f"Telegram refused the post in chat {channel_id}", text + error, + f"Telegram refused the post in chat {channel_id}", + self.rich_fallback_text(content), ) from error ref = self._ref(sent) self._note_publication(channel_id) @@ -1614,7 +1627,11 @@ async def update_rich( self._refuse_while_throttled(drawn.text) self._pace_publication(channel_id, content, drawn.text) await self._edit_rich( - channel_id, message_ref, drawn.text, self._controls(content, drawn) + channel_id, + message_ref, + drawn.text, + self._controls(content, drawn), + content=content, ) async def _edit_rich( @@ -1623,6 +1640,8 @@ async def _edit_rich( message_ref: str, text: str, controls: InlineKeyboardMarkup | None, + *, + content: RichContent, ) -> None: """Rewrite a publication, reporting a refusal rather than logging it. @@ -1630,6 +1649,12 @@ async def _edit_rich( addition to what it offered before: Telegram replaces the keyboard with what an edit carries, so passing none is how a settled card's buttons come off. + + `content` is here for the refusal rather than the edit. What a + `RichContentFailed` carries is redrawn from it without a keyboard, + because the caller's answer to a refused edit is to republish the card + as plain text β€” and `text` is a drawing that left its options to the + buttons about to go with it. """ chat_id, message_id = self._parse_message_ref(message_ref) try: @@ -1650,13 +1675,13 @@ async def _edit_rich( raise self._rich_failure( error, f"Telegram refused the edit to {message_ref} in chat {channel_id}", - text, + self.rich_fallback_text(content), ) from error except Exception as error: raise self._rich_failure( error, f"Telegram refused the edit to {message_ref} in chat {channel_id}", - text, + self.rich_fallback_text(content), ) from error self._note_publication(channel_id) diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 8b113739b..1f70e5992 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -513,6 +513,40 @@ async def test_a_failed_edit_is_reported_rather_than_logged_and_forgotten() -> N await adapter.update_rich(CHANNEL, "my-agent", ref, await _card(), None) +async def test_what_a_refusal_carries_is_answerable_without_the_buttons() -> None: + """The publisher republishes `error.text` as a plain message with no + keyboard under it, so the options cannot be left to one. + + A posted card omits them β€” the buttons beside it spell them out, and + repeating them costs a line each. That drawing republished on its own ends + "Reply with `R7 1`" over a card that never printed a 1. + """ + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + assert "1. Allow once" not in _posted(adapter)["text"] + _bot(adapter).edit_error = BadRequest("message to edit not found") + + with pytest.raises(RichContentFailed) as caught: + await adapter.update_rich(CHANNEL, "my-agent", ref, await _card(), None) + + lines = caught.value.text.splitlines() + assert "1. Allow once" in lines + assert "2. Deny" in lines + assert lines[-1] == "Reply with R7 1." + + +async def test_a_refused_post_carries_the_same_buttonless_drawing() -> None: + """The card never reached the chat at all, so there is even less chance of + a keyboard wherever its text is shown instead.""" + adapter = _adapter() + _bot(adapter).send_message_error = BadRequest("chat not found") + + with pytest.raises(RichContentFailed) as caught: + await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + assert "1. Allow once" in caught.value.text.splitlines() + + async def test_an_edit_telegram_calls_unchanged_is_not_a_failure() -> None: """ "message is not modified" means the chat already shows what was asked for, which is the outcome the caller wanted.""" From 6586a51502269b6ce408e2f4a401b758159515cb Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 19:30:39 +0100 Subject: [PATCH 041/120] Mark a queued prompt as waiting rather than as read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ‘€ says the agent picked the message up. A prompt sitting behind another turn has not been picked up, and the reader could not tell the two apart. Slack, Mattermost and Discord now show ⏳ while a turn is queued and swap it for πŸ‘€ the moment it starts. Telegram is unchanged: a bot gets one reaction per message there, so the queued state stays in the status text. A turn holds one mark at a time and a message can show both, because two turns can be anchored to the same asking message in different states. The mark is part of the durable claim, so the two are cleaned up independently and neither can answer for the other. `reaction_held` learns the difference: a live turn anchored here wants πŸ‘€ unless it is claiming ⏳, and ⏳ is wanted only by a turn that claims it, because whether a turn is still waiting is not otherwise in the row. A turn moves off the hourglass before it claims the eyes β€” a row carries one claim, and the other order would overwrite the only record of who is to take the hourglass off. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/activity_journal.py | 21 ++- .../bridges/collaboration/session/outbound.py | 170 ++++++++++++++---- .../sessions/test_activity_durability.py | 154 +++++++++++++++- 3 files changed, 302 insertions(+), 43 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index e342eed68..6a11acb10 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -188,6 +188,8 @@ async def reaction_held( ref: str, *, agent_name: str | None = None, + claiming: dict[str, str] | None = None, + not_claiming: dict[str, str] | None = None, sessions: async_sessionmaker[AsyncSession], ) -> bool: """Whether another live turn still wants the mark on this message. @@ -197,16 +199,33 @@ async def reaction_held( its own reaction there says nothing about whether this one's should come off. Left None where every agent shares a bot and there is a single reaction between them. + + `claiming` and `not_claiming` say what the mark is evidenced by, and + the two marks differ. Being anchored here is enough to want the working + mark β€” every live turn does, whether or not its own attempt has landed, + which is why narrowing that one to its claimants would take the + reaction off under a turn whose claim was refused. A turn waiting to + start is the exception, because it wants the hourglass *instead*: it is + named by `not_claiming` and does not hold the eyes. The hourglass + itself runs the other way β€” only a turn still waiting wants it, its + status is not in the row and its claim is, so `claiming` makes the + claim the whole of the evidence. """ anchor: dict[str, str] = {"channel_id": channel, "reaction_ref": ref} if agent_name is not None: anchor["agent_name"] = agent_name + held: dict[str, Any] = {"anchor": anchor} + if claiming is not None: + held["mark"] = claiming + criteria = [SessionActivityPost.data.contains(held)] + if not_claiming is not None: + criteria.append(~SessionActivityPost.data.contains({"mark": not_claiming})) async with sessions() as db: rows = await db.scalars( select(SessionActivityPost).where( SessionActivityPost.tenant_id == require_tenant_id(), SessionActivityPost.bridge_id == self.bridge_id, - SessionActivityPost.data.contains({"anchor": anchor}), + *criteria, ) ) return any( diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 01d3dd356..50ab7766b 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -45,6 +45,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from switch_core.bridges.collaboration.adapter import ( + ActivityMark, ActivityMarkRefused, CollaborationAdapter, RequestCard, @@ -142,9 +143,24 @@ def _violates(error: IntegrityError, constraint: str) -> bool: return f'"{constraint}"' in str(error.orig) -def _mark_id(mark: dict[str, str]) -> tuple[str, str, str]: +#: The marks a message can carry, in the order a turn passes through them. +#: +#: A turn holds exactly one at a time β€” a prompt is either waiting to start or +#: being worked on, never both β€” but a message can show both at once, because +#: two turns can be anchored to the same asking message and be in different +#: states. Platforms that cannot hold two reactions on one message carry only +#: the working one and say the rest in the status text. +_MARKS: tuple[ActivityMark, ...] = ("queued", "working") + + +def _mark_id(mark: dict[str, str]) -> tuple[str, str, str, str]: """The same reaction as a key this process can look it up by.""" - return (mark["channel_id"], mark["reaction_ref"], mark["agent_name"]) + return ( + mark["channel_id"], + mark["reaction_ref"], + mark["agent_name"], + mark["mark"], + ) class CardNotPosted(RuntimeError): @@ -235,13 +251,16 @@ def __init__( self._reactions_per_agent = getattr( adapter, "activity_reactions_per_agent", False ) + self._queue_reaction = getattr(adapter, "supports_queue_reaction", False) self._timer_redraws = getattr(adapter, "redraws_for_elapsed_time", False) self._only_mentions_notify = getattr(adapter, "notifies_only_by_mention", False) self._recovers_posts = getattr(adapter, "recovers_uncertain_posts", False) self._marks_publications = getattr(adapter, "carries_publication_marker", False) self._anchors: OrderedDict[tuple[str, str], _Anchor] = OrderedDict() - self._thread_turns: dict[tuple[str, str, str], set[tuple[str, str]]] = {} - self._expecting: dict[tuple[str, str, str], dict[tuple[str, str], str]] = {} + self._thread_turns: dict[tuple[str, str, str, str], set[tuple[str, str]]] = {} + self._expecting: dict[ + tuple[str, str, str, str], dict[tuple[str, str], str] + ] = {} self._attention: OrderedDict[tuple[str, str], tuple[str, str]] = OrderedDict() @property @@ -417,7 +436,7 @@ async def draw() -> bool: ) self._anchors[key] = anchor if turn.status in TURN_ENDED: - await self._release_thread(key, anchor) + await self._release_marks(key, anchor) drawn = await draw() if drawn and turn.status in TURN_ENDED: if not turn.turn_id.startswith("pending:"): @@ -713,7 +732,7 @@ async def _publish( self._anchors[key] = anchor await self._save_anchor(anchor) if not ended: - await self._claim_thread(key, anchor) + await self._hold_mark(key, anchor, turn) drawn = ( await self._edit( anchor, @@ -729,7 +748,7 @@ async def _publish( else: anchor.session_url = session_url if not ended: - await self._claim_thread(key, anchor) + await self._hold_mark(key, anchor, turn) drawn = await self._edit( anchor, items, @@ -747,7 +766,7 @@ async def _publish( if record and drawn: record.data["ended"] = True await record.save() - drawn = await self._release_thread(key, anchor) and drawn + drawn = await self._release_marks(key, anchor) and drawn if not drawn or turn.turn_id.startswith("pending:"): self._anchors[key] = anchor self._anchors.move_to_end(key) @@ -925,8 +944,60 @@ async def _draw_log( anchor.log_state = state return True - async def _claim_thread(self, key: tuple[str, str], anchor: _Anchor) -> None: - """Add this turn to the set of turns holding the reaction on + def _wanted_mark(self, turn: TurnUpsert) -> ActivityMark: + """Which mark this turn's current state earns. + + A prompt the agent has but has not started on is waiting, not being + read, and saying so is the whole point of the second reaction. Where + the platform has no room for it the queued state is carried by the + status text and the working mark goes on as before, which is what a + reader there has always seen. + """ + if self._queue_reaction and turn.status == "queued": + return "queued" + return "working" + + async def _hold_mark( + self, key: tuple[str, str], anchor: _Anchor, turn: TurnUpsert + ) -> None: + """Move this turn onto the one mark its state earns, and off the other. + + Off first. A row carries one claim, so claiming the working mark + overwrites the evidence that this turn ever asked for the hourglass β€” + and an hourglass nobody is recorded as holding is one nothing will take + off. If the platform will not remove it the turn keeps waiting for its + eyes rather than stranding the reaction it already has; the next + redraw tries again. + """ + wanted = self._wanted_mark(turn) + for mark in _MARKS: + if mark == wanted or not self._holds(key, anchor, mark): + continue + if not await self._release_thread(key, anchor, mark): + return + await self._claim_thread(key, anchor, wanted) + + def _holds(self, key: tuple[str, str], anchor: _Anchor, mark: ActivityMark) -> bool: + """Whether this turn's own ask may have put `mark` on the message. + + Both halves, for the same reason `_claimants` reads both: this + process's memory is empty after a restart and the row's claim is not, + and a publisher with no journal has only the memory. A turn the second + to want a mark has no claim of its own β€” the first turn's covers the + reaction they share β€” and is answered by the memory alone, which is + where its place among the holders is kept. + """ + if key in self._thread_turns.get(self._thread_key(anchor, mark), frozenset()): + return True + record = self._record.get() + return record is not None and record.data.get("mark") == self._mark_key( + anchor, mark + ) + + async def _claim_thread( + self, key: tuple[str, str], anchor: _Anchor, mark: ActivityMark + ) -> None: + """Add this turn to the set of turns holding `mark` on `anchor.reaction_ref`, switching it on only if this turn is the first to want it. @@ -938,14 +1009,36 @@ async def _claim_thread(self, key: tuple[str, str], anchor: _Anchor) -> None: """ if anchor.reaction_ref is None: return - turns = self._thread_turns.setdefault(self._thread_key(anchor), set()) + turns = self._thread_turns.setdefault(self._thread_key(anchor, mark), set()) first = not turns - if not first or await self._mark_thread(key, anchor, working=True): + if not first or await self._mark_thread(key, anchor, mark=mark, on=True): turns.add(key) - async def _release_thread(self, key: tuple[str, str], anchor: _Anchor) -> bool: + async def _release_marks(self, key: tuple[str, str], anchor: _Anchor) -> bool: + """Take this turn off every mark it could be holding. + + A turn ends from whichever state it was in, and a queued one that is + cancelled before it starts never passes through the working mark at + all β€” so which one it was holding cannot be assumed from the fact that + it ended. + + The working mark is asked for unconditionally, as it always has been: a + turn whose claim was refused, or made in a process that has since + restarted, still has to ask. The hourglass is asked for only where this + turn may have been the one to put it there, because a turn that was + never queued taking it off would be taking it off whoever is. + """ + done = True + for mark in _MARKS: + if mark == "working" or self._holds(key, anchor, mark): + done = await self._release_thread(key, anchor, mark) and done + return done + + async def _release_thread( + self, key: tuple[str, str], anchor: _Anchor, mark: ActivityMark + ) -> bool: """The inverse of `_claim_thread`: drop this turn from the holders of - `anchor.reaction_ref`, switching the reaction off once none are left. + `mark` on `anchor.reaction_ref`, switching it off once none are left. A turn that never claimed it still reaches this β€” published already ended, or one whose claim this process never recorded at all (a @@ -961,7 +1054,7 @@ async def _release_thread(self, key: tuple[str, str], anchor: _Anchor) -> bool: """ if anchor.reaction_ref is None: return True - thread_key = self._thread_key(anchor) + thread_key = self._thread_key(anchor, mark) turns = self._thread_turns.get(thread_key) if turns is not None: turns.discard(key) @@ -969,17 +1062,22 @@ async def _release_thread(self, key: tuple[str, str], anchor: _Anchor) -> bool: return True del self._thread_turns[thread_key] record = self._record.get() + waiting = self._mark_key(anchor, "queued") if self._queue_reaction else None if self._journal and await self._journal.reaction_held( key, anchor.channel_id, anchor.reaction_ref, agent_name=anchor.agent_name if self._reactions_per_agent else None, + claiming=waiting if mark == "queued" else None, + not_claiming=waiting if mark == "working" else None, sessions=record.sessions if record else self._journal.sessions, ): return True - return await self._mark_thread(key, anchor, working=False) + return await self._mark_thread(key, anchor, mark=mark, on=False) - def _thread_key(self, anchor: _Anchor) -> tuple[str, str, str]: + def _thread_key( + self, anchor: _Anchor, mark: ActivityMark + ) -> tuple[str, str, str, str]: """Who is holding what, keyed by whose reaction it actually is. Where each agent reacts as its own bot the marks are independent, so @@ -991,12 +1089,12 @@ def _thread_key(self, anchor: _Anchor) -> tuple[str, str, str]: wants. """ agent = anchor.agent_name if self._reactions_per_agent else "" - return (anchor.channel_id, anchor.reaction_ref or "", agent) + return (anchor.channel_id, anchor.reaction_ref or "", agent, mark) async def _mark_thread( - self, key: tuple[str, str], anchor: _Anchor, *, working: bool + self, key: tuple[str, str], anchor: _Anchor, *, mark: ActivityMark, on: bool ) -> bool: - """Put `:eyes:` on the message that actually asked, or take it off. + """Put `mark` on the message that actually asked, or take it off. Not necessarily the thread root β€” a turn threaded under a reply deep in the thread reacts to that reply, resolved once by `_begin` and @@ -1027,29 +1125,29 @@ async def _mark_thread( self._adapter, "supports_activity_reactions", False ): return True - mark = self._mark_key(anchor) + held = self._mark_key(anchor, mark) removing: set[tuple[str, str, str]] = set() attempt: _MarkAttempt | None = None - if working: - attempt = await self._expect_mark(key, mark) + if on: + attempt = await self._expect_mark(key, held) else: - removing = await self._claimants(mark) + removing = await self._claimants(held) try: await self._adapter.mark_activity( anchor.channel_id, anchor.reaction_ref, agent_name=anchor.agent_name, - mark="working", - on=working, + mark=mark, + on=on, **({"force": True} if self._journal else {}), ) except ActivityMarkRefused as refusal: - if working: + if on: if attempt is not None: - await self._retract_attempt(key, mark, attempt) + await self._retract_attempt(key, held, attempt) logger.warning("%s The turn goes on without the mark.", refusal) return True - if not await self._mark_may_be_there(mark): + if not await self._mark_may_be_there(held): logger.warning( "%s Nothing was ever put on it, so there is nothing to take off.", refusal, @@ -1063,18 +1161,19 @@ async def _mark_thread( return False except Exception: logger.warning( - "Could not %s the activity reaction on %s in %s.", - "add" if working else "remove", + "Could not %s the %s reaction on %s in %s.", + "add" if on else "remove", + mark, anchor.reaction_ref, anchor.channel_id, exc_info=True, ) return False - if not working: - await self._mark_taken_off(key, mark, removing) + if not on: + await self._mark_taken_off(key, held, removing) return True - def _mark_key(self, anchor: _Anchor) -> dict[str, str]: + def _mark_key(self, anchor: _Anchor, mark: ActivityMark) -> dict[str, str]: """Identify the reaction itself, which several turns can share. The same shape as `_thread_key` and for the same reason: where every @@ -1088,6 +1187,7 @@ def _mark_key(self, anchor: _Anchor) -> dict[str, str]: "channel_id": anchor.channel_id, "reaction_ref": anchor.reaction_ref or "", "agent_name": anchor.agent_name if self._reactions_per_agent else "", + "mark": mark, } async def _expect_mark( @@ -1253,7 +1353,7 @@ async def _forget_the_oldest(self) -> None: session_id, ) if self._journal is None: - await self._release_thread(key, anchor) + await self._release_marks(key, anchor) class SessionRequestCards: diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index e1fbbfc54..9e92fbafd 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -54,6 +54,7 @@ def __init__(self): self.edit_refs = [] self.edit_threads = [] self.reactions = set() + self.hourglass = set() self.fail_after_post = False async def post_rich(self, channel, agent, content, thread): @@ -81,10 +82,11 @@ async def find_request_card(self, channel, thread, token, created_at, handle): return None async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): + marked = self.reactions if mark == "working" else self.hourglass if on: - self.reactions.add(ref) + marked.add(ref) else: - self.reactions.discard(ref) + marked.discard(ref) class UnsearchablePlatform(ActivitySlack): @@ -136,12 +138,14 @@ class PerAgentSlack(ActivitySlack): def __init__(self): super().__init__() self.reactions = set() + self.hourglass = set() async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): + marked = self.reactions if mark == "working" else self.hourglass if on: - self.reactions.add((agent_name, ref)) + marked.add((agent_name, ref)) else: - self.reactions.discard((agent_name, ref)) + marked.discard((agent_name, ref)) OUTBOUND_LOGGER = "switch_core.bridges.collaboration.session.outbound" @@ -1019,6 +1023,136 @@ async def test_a_shared_bots_single_mark_survives_one_of_two_turns_ending( assert platform.reactions == {"channel-demo:question"} +# ── The hourglass, for a prompt the agent has but has not started ──────────── + + +class OneReactionPlatform(ActivitySlack): + """Telegram's shape: one reaction per message, so no room for a second. + + A bot gets a single reaction there, and a queued mark could only go on by + taking the working one off β€” a prompt saying nothing beats a running one + that has stopped saying it is being read. The queued state is carried by + the status text instead. + """ + + supports_queue_reaction = False + + +async def test_a_queued_prompt_is_marked_as_waiting_rather_than_as_read( + session_factory, +): + """πŸ‘€ says the agent picked the message up. A prompt behind another turn + has not been picked up, and saying so was the whole of R7.""" + await setup(session_factory) + platform = ActivitySlack() + + await publish(activity(session_factory, platform), "queued") + + assert platform.hourglass == {"channel-demo:question"} + assert not platform.reactions + + +async def test_a_prompt_that_starts_loses_the_hourglass_and_gains_the_eyes( + session_factory, +): + """The transition is the point: an hourglass left on a running turn says + the agent is still waiting to start, which is no longer true.""" + await setup(session_factory) + platform = ActivitySlack() + renderer = activity(session_factory, platform) + await publish(renderer, "queued") + + await publish(renderer, "running") + + assert platform.reactions == {"channel-demo:question"} + assert not platform.hourglass + + +async def test_a_restarted_publisher_still_takes_the_hourglass_off(session_factory): + """A row carries one claim, so claiming the eyes overwrites the evidence + that this turn ever asked for the hourglass. Read before it is overwritten + or the reaction is one nothing will ever remove.""" + await setup(session_factory) + platform = ActivitySlack() + await publish(activity(session_factory, platform), "queued") + + await publish(activity(session_factory, platform), "running") + + assert platform.reactions == {"channel-demo:question"} + assert not platform.hourglass + + +async def test_a_prompt_cancelled_before_it_started_takes_its_hourglass_with_it( + session_factory, +): + """A turn ends from whichever state it was in, and this one never passed + through the working mark at all.""" + await setup(session_factory) + platform = ActivitySlack() + renderer = activity(session_factory, platform) + await publish(renderer, "queued") + + await publish(renderer, "interrupted") + + assert not platform.hourglass + assert not platform.reactions + + +async def test_one_turn_starting_leaves_another_turns_hourglass_alone( + session_factory, +): + """Two prompts can be queued behind the same asking message, and the first + to start must not clear the second's mark on the way past. + + The evidence is the claim rather than the anchor: both turns are anchored + here and only one of them is still waiting, and which is which is a + property of the turn's state that the row does not record. + """ + await setup(session_factory) + platform = ActivitySlack() + await publish(activity(session_factory, platform), "queued") + await publish( + activity(session_factory, platform), "queued", agent="Other", command="other" + ) + + await publish(activity(session_factory, platform), "running") + + assert platform.hourglass == {"channel-demo:question"} + assert platform.reactions == {"channel-demo:question"} + + +async def test_a_turn_that_ends_holding_the_eyes_leaves_a_queued_ones_hourglass( + session_factory, +): + """The two marks are cleaned up independently, so a running turn finishing + says nothing about the prompt still waiting behind it.""" + await setup(session_factory) + platform = ActivitySlack() + await publish(activity(session_factory, platform), "running") + await publish( + activity(session_factory, platform), "queued", agent="Other", command="other" + ) + + await publish(activity(session_factory, platform), "completed") + + assert not platform.reactions + assert platform.hourglass == {"channel-demo:question"} + + +async def test_a_platform_with_one_reaction_marks_a_queued_prompt_as_it_always_did( + session_factory, +): + """Nothing changes where there is no room for a second mark: the working + reaction goes on as before and the status text carries the rest.""" + await setup(session_factory) + platform = OneReactionPlatform() + + await publish(activity(session_factory, platform), "queued") + + assert platform.reactions == {"channel-demo:question"} + assert not platform.hourglass + + async def test_busy_journal_is_skipped_until_next_sweep(session_factory): await setup(session_factory) journal = ActivityJournal(session_factory, "bridge") @@ -1182,19 +1316,21 @@ class RefusingPlatform(ActivitySlack): def __init__(self, chat, *, refuse_add=False, refuse_remove=False): super().__init__() self.reactions = chat["reactions"] + self.hourglass = chat.setdefault("hourglass", set()) self.messages = chat["messages"] self.refuse_add = refuse_add self.refuse_remove = refuse_remove async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): + marked = self.reactions if mark == "working" else self.hourglass if on: if self.refuse_add: raise ActivityMarkRefused("reactions are switched off in this chat") - self.reactions.add(ref) + marked.add(ref) elif self.refuse_remove: raise ActivityMarkRefused("the bot may no longer react here") else: - self.reactions.discard(ref) + marked.discard(ref) async def test_a_mark_left_on_the_message_after_a_restart_is_not_reported_as_cleaned_up( @@ -1596,7 +1732,11 @@ async def test_an_addition_whose_answer_was_lost_survives_a_refused_retry( # and holds only its own turn's advisory lock. Whatever it does to a holder's # row it does while that holder is free to be writing to it. -MARK = {"channel_id": "channel-demo", "reaction_ref": "channel-demo:question"} +MARK = { + "channel_id": "channel-demo", + "reaction_ref": "channel-demo:question", + "mark": "working", +} async def _row(sessions): From 6144e3d5b42371660f13733032a8d417641418fc Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 19:40:46 +0100 Subject: [PATCH 042/120] Say so when the gateway origin only resolves on the Switch host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A loopback GATEWAY_PUBLIC_URL builds links that are clickable and dead: the deeplink redirect points at a host only the Switch machine can reach, and the `server=` the deeplink carries tells Switch Console to reach the same place. Nothing said so β€” the reader just got a link that did nothing. Each bridge now warns at startup when it finds one. The existing "not set" warning moves alongside it into `deeplinks.py`, so the choice of message is pure and testable and the caller only logs what it is given. Co-Authored-By: Claude Opus 5 --- .../collaboration/lifecycle_service.py | 17 ++--- core/switch_core/deeplinks.py | 50 +++++++++++++ .../test_lifecycle_tenant_binding.py | 4 +- core/tests/switch_core/test_deeplinks.py | 70 +++++++++++++++++++ docs/old/bridges/README.md | 5 +- 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/core/switch_core/bridges/collaboration/lifecycle_service.py b/core/switch_core/bridges/collaboration/lifecycle_service.py index 28e226461..76d89cc97 100644 --- a/core/switch_core/bridges/collaboration/lifecycle_service.py +++ b/core/switch_core/bridges/collaboration/lifecycle_service.py @@ -24,6 +24,7 @@ from switch_core.db.stores.room_store import RoomStore from switch_core.db.stores.session_request_post_store import SessionRequestPostStore from switch_core.db.tenant_lookup import all_tenant_ids, tenant_of_collaboration_bridge +from switch_core.deeplinks import gateway_url_warning from switch_core.provisioning import Provisioning from switch_core.tenant_context import current_tenant_id, no_tenant @@ -482,18 +483,12 @@ async def start(self, bridge_id: str) -> None: ) adapter.set_max_attachment_bytes(self._config.agent_media_max_bytes) - if ( - not adapter_cls.renders_custom_url_schemes - and not self._config.gateway_public_url - ): + gateway_warning = gateway_url_warning( + self._config.gateway_public_url, adapter_cls.renders_custom_url_schemes + ) + if gateway_warning: logger.warning( - "GATEWAY_PUBLIC_URL is not set and %s only renders http(s) links, " - "so the 'Open in Switch Console' deeplink cannot be clickable on " - "bridge %s β€” it is posted as copyable text instead. Set " - "GATEWAY_PUBLIC_URL to the Switch API's public origin (scheme + " - "host, no path) to turn it into a real link", - bridge.type, - bridge_id, + "%s (bridge %s, %s)", gateway_warning, bridge_id, bridge.type ) async with tenant_session(self._session_factory, tenant_id) as session: diff --git a/core/switch_core/deeplinks.py b/core/switch_core/deeplinks.py index ff2034eb0..6644febe2 100644 --- a/core/switch_core/deeplinks.py +++ b/core/switch_core/deeplinks.py @@ -1,5 +1,6 @@ from __future__ import annotations +from ipaddress import ip_address from urllib.parse import urlsplit # Scheme + host of the Switch Console session deeplink Switch Console reports with its @@ -60,6 +61,55 @@ def deeplink_for_platform( return switchdash_to_gateway(deeplink_url, gateway_public_url) or deeplink_url +def gateway_url_is_loopback(gateway_public_url: str) -> bool: + """Whether a gateway origin only resolves on the machine that serves it. + + Names are judged as well as literals: RFC 6761 reserves `localhost`, and + every name under it, for the loopback interface. + """ + host = urlsplit(gateway_public_url).hostname + if host is None: + return False + host = host.rstrip(".") + if host == "localhost" or host.endswith(".localhost"): + return True + try: + return ip_address(host).is_loopback + except ValueError: + return False + + +def gateway_url_warning( + gateway_public_url: str | None, platform_renders_custom_schemes: bool +) -> str | None: + """What is wrong with the gateway origin a bridge is about to post links from. + + Two ways a reader ends up unable to open a session from a message, both + silent at click time and neither worth refusing to start a bridge over. + Returns the sentence to log, or None when the origin will serve. + """ + if not gateway_public_url: + if platform_renders_custom_schemes: + return None + return ( + "GATEWAY_PUBLIC_URL is not set and this platform only renders http(s) " + "links, so the 'Open in Switch Console' deeplink cannot be clickable β€” " + "it is posted as copyable text instead. Set GATEWAY_PUBLIC_URL to the " + "Switch API's public origin (scheme + host, no path) to turn it into a " + "real link" + ) + if gateway_url_is_loopback(gateway_public_url): + return ( + f"GATEWAY_PUBLIC_URL is {gateway_public_url!r}, a loopback address, so " + "every link built from it β€” the deeplink redirect, and the server the " + "'Open in Switch Console' deeplink tells Switch Console to reach β€” " + "resolves only on the machine running Switch. A reader on any other " + "device gets a link that goes nowhere, with nothing to say so. Set " + "GATEWAY_PUBLIC_URL to an origin reachable from where people read" + ) + return None + + def gateway_query_to_switchdash(query: str) -> str: """Reconstruct the `switchdash://session?…` deeplink the redirect targets. diff --git a/core/tests/switch_core/bridges/collaboration/test_lifecycle_tenant_binding.py b/core/tests/switch_core/bridges/collaboration/test_lifecycle_tenant_binding.py index 08ec447fe..00bd52d91 100644 --- a/core/tests/switch_core/bridges/collaboration/test_lifecycle_tenant_binding.py +++ b/core/tests/switch_core/bridges/collaboration/test_lifecycle_tenant_binding.py @@ -48,6 +48,8 @@ def _service( session_factory: async_sessionmaker[AsyncSession], ) -> CollaborationBridgeLifecycleService: + config = MagicMock() + config.gateway_public_url = "https://gw.example" return CollaborationBridgeLifecycleService( bridge_store=CollaborationBridgeStore(), external_user_store=MagicMock(), @@ -60,7 +62,7 @@ def _service( room_service=MagicMock(), matrix_admin=MagicMock(), session_factory=session_factory, - config=MagicMock(), + config=config, client_factory=MagicMock(), ) diff --git a/core/tests/switch_core/test_deeplinks.py b/core/tests/switch_core/test_deeplinks.py index dfed30833..b0c0a8b09 100644 --- a/core/tests/switch_core/test_deeplinks.py +++ b/core/tests/switch_core/test_deeplinks.py @@ -1,9 +1,13 @@ from __future__ import annotations +import pytest + from switch_core.deeplinks import ( DEEPLINK_REDIRECT_PATH, deeplink_for_platform, gateway_query_to_switchdash, + gateway_url_is_loopback, + gateway_url_warning, switchdash_to_gateway, ) @@ -71,6 +75,72 @@ def test_wrong_host_returns_none(self) -> None: ) +class TestGatewayUrlIsLoopback: + """A configured origin that only the Switch host can reach. + + A locally managed server hands Switch Console's own `http://localhost:` + to GATEWAY_PUBLIC_URL, which makes every posted link clickable and useless to + anyone reading from a phone. + """ + + @pytest.mark.parametrize( + "url", + [ + "http://localhost:8000", + "http://LOCALHOST", + "http://localhost.:8000", + "http://switch.localhost", + "http://127.0.0.1:54212", + "http://127.1.2.3", + "http://[::1]:8000", + ], + ) + def test_an_origin_only_this_machine_can_reach_is_loopback(self, url: str) -> None: + assert gateway_url_is_loopback(url) is True + + @pytest.mark.parametrize( + "url", + [ + "https://gw.example", + "https://localhost.example", + "http://10.0.0.1:8000", + "http://0.0.0.0:8000", + "https://[2001:db8::1]", + ], + ) + def test_an_origin_someone_else_could_reach_is_not(self, url: str) -> None: + assert gateway_url_is_loopback(url) is False + + +class TestGatewayUrlWarning: + def test_a_loopback_origin_is_announced_rather_than_left_silent(self) -> None: + warning = gateway_url_warning("http://localhost:54212", False) + assert warning is not None + assert "http://localhost:54212" in warning + assert "loopback" in warning + + def test_a_loopback_origin_is_announced_even_where_no_rewrite_happens( + self, + ) -> None: + # The deeplink is posted raw here, but it still carries the origin as + # the server Switch Console is told to reach. + assert gateway_url_warning("http://127.0.0.1:54212", True) is not None + + def test_an_unset_origin_costs_a_clickable_link_on_an_http_only_platform( + self, + ) -> None: + warning = gateway_url_warning(None, False) + assert warning is not None + assert "not set" in warning + + def test_an_unset_origin_costs_nothing_where_the_scheme_renders(self) -> None: + assert gateway_url_warning(None, True) is None + + @pytest.mark.parametrize("renders", [True, False]) + def test_a_reachable_origin_warns_about_nothing(self, renders: bool) -> None: + assert gateway_url_warning("https://gw.example", renders) is None + + class TestGatewayQueryToSwitchdash: def test_reconstructs_deeplink_from_query(self) -> None: query = "server=https%3A%2F%2Fs&agent=a&room=r&session=x" diff --git a/docs/old/bridges/README.md b/docs/old/bridges/README.md index fffa6b0bf..a6767d7b0 100644 --- a/docs/old/bridges/README.md +++ b/docs/old/bridges/README.md @@ -131,7 +131,10 @@ are deployment-level environment config on switch-core: disclosed fallback). Applies to every platform, and is **required** on Discord, Telegram and Teams, which render only http(s) links β€” Teams goes further and strips a link on any other scheme entirely, label included, so - without this the deeplink renders as empty brackets. + without this the deeplink renders as empty brackets. It must be reachable from + where people *read* the message, not from the Switch host β€” a loopback origin + builds links that work only on the machine running Switch, so each bridge warns + at startup when it finds one. - **Teams** additionally needs public HTTPS ingress to the bridge's listener, on its own port β€” it is the only bridge Switch does not reach outbound. See [`TEAMS_SETUP.md`](TEAMS_SETUP.md) for the bridge side, and the Helm chart's From 5bf6da05e23494f71fb4d5ead7cb1c25be765f38 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 19:52:45 +0100 Subject: [PATCH 043/120] Say the handle and the choice are two things, not one string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Reply with `R6 1`" reads as one fixed string to type, so the number β€” the entire decision β€” looks like part of the incantation. The handle now stands on its own and the whole thing is offered as an example: Reply with `R6` and your choice, e.g. `R6 1`. Forms say "your answer(s)" for the same reason, and keep the note that every question needs one. Slack keeps "or press a button". Every platform that draws a card gets it: Slack from its own renderer, the rest from the neutral one. Three tests read the footer's first code span as the example. The example is now the last span, and the first is the handle, which they assert instead. Co-Authored-By: Claude Opus 5 --- .../session/renderers/neutral.py | 23 +++++++++--- .../collaboration/session/renderers/slack.py | 26 +++++++++---- .../collaboration/test_mattermost_sdk_only.py | 2 +- .../collaboration/test_rich_content_port.py | 2 +- .../test_session_neutral_forms.py | 10 ++--- .../test_session_questions_cards.py | 37 +++++++++++-------- .../test_session_slack_requests.py | 19 ++++++---- .../collaboration/test_telegram_sdk_only.py | 10 +++-- 8 files changed, 83 insertions(+), 46 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index ebff3ecc0..95ddbdecc 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -490,11 +490,18 @@ def _approval_footer( if request.state == "open": if not content.options: return NO_OPTIONS - # A code span, because the reader is meant to copy this and quote marks - # around it are not part of the answer: `"R42 1"` parses as a handle of - # `"R42`, which resolves to nothing and changes nothing on the card. + # Code spans, because the reader is meant to copy these and quote marks + # around them are not part of the answer: `"R42 1"` parses as a handle + # of `"R42`, which resolves to nothing and changes nothing on the card. # The grammar strips the backticks the span is drawn from. - return f"Reply with {markup.code(f'{handle} 1')}." + # + # The handle and the example are both shown because `R42 1` on its own + # reads as one fixed string to type, and the number in it is the whole + # decision. + return ( + f"Reply with {markup.code(handle)} and your choice, " + f"e.g. {markup.code(f'{handle} 1')}." + ) if request.state == "submitting": return _in_flight(request, responder=responder, limit=limit, escape=escape) if request.state == "resolved": @@ -647,9 +654,13 @@ def _questions_footer( if stuck is not None: return stuck example = markup.code(_example(handle, content.questions)) + start = f"Reply with {markup.code(handle)}" if len(content.questions) > 1: - return f"Reply with {example} β€” every question needs an answer." - return f"Reply with {example}." + return ( + f"{start} and your answers, e.g. {example} " + "β€” every question needs an answer." + ) + return f"{start} and your answer, e.g. {example}." if request.state == "submitting": return _in_flight(request, responder=responder, limit=limit, escape=escape) if request.state == "resolved": diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index d1943b520..ce600e609 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -455,11 +455,19 @@ def _footer( # length, and the schema is still the wrong place to add one β€” see # `unanswerable`, which refuses the same defect a question apart. return NO_OPTIONS - # A code span, because the reader is meant to copy this and quote marks - # around it are not part of the answer: `"R42 1"` parses as a handle of - # `"R42`, which resolves to nothing and changes nothing on the card. + # Code spans, because the reader is meant to copy these and quote marks + # around them are not part of the answer: `"R42 1"` parses as a handle + # of `"R42`, which resolves to nothing and changes nothing on the card. # Slack draws a span from the backticks and the grammar strips them. - return f"Reply with `{escape_mrkdwn(reference.handle)} 1`, or press a button." + # + # The handle and the example are both shown because `R42 1` on its own + # reads as one fixed string to type, and the number in it is the whole + # decision. + handle = escape_mrkdwn(reference.handle) + return ( + f"Reply with `{handle}` and your choice, " + f"e.g. `{handle} 1`, or press a button." + ) if request.state == "submitting": if request.decided_by is None: return "An answer is on its way." @@ -744,11 +752,15 @@ def _questions_footer( if stuck is not None: return stuck example = f"`{_example(reference.handle, content.questions)}`" + start = f"Reply with `{escape_mrkdwn(reference.handle)}`" if buttons: - return f"Reply with {example}, or press a button." + return f"{start} and your answer, e.g. {example}, or press a button." if len(content.questions) > 1: - return f"Reply with {example} β€” every question needs an answer." - return f"Reply with {example}." + return ( + f"{start} and your answers, e.g. {example} " + "β€” every question needs an answer." + ) + return f"{start} and your answer, e.g. {example}." if request.state == "submitting": if request.decided_by is None: return "An answer is on its way." diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index ef6dbd410..1f8b0d6ad 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -759,7 +759,7 @@ async def test_a_request_form_says_its_handle_and_how_to_answer_it() -> None: message = _posts(adapter).created[0]["message"] assert "`R7`" in message - assert "Reply with `R7 " in message + assert "Reply with `R7` and your choice, e.g. `R7 1`." in message async def test_a_status_stays_inside_one_mattermost_post() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py b/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py index d8668b75b..5abb8e578 100644 --- a/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py +++ b/core/tests/switch_core/bridges/collaboration/test_rich_content_port.py @@ -244,7 +244,7 @@ async def test_a_request_card_falls_back_to_a_form_that_can_be_answered() -> Non assert ref == "C1:1.0" text = adapter.sent[0][2] assert "`R1`" in text - assert "Reply with `R1 " in text + assert "Reply with `R1` and your choice, e.g. `R1 1`." in text assert "1." in text assert content.request.content.title in text diff --git a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py index 77b22b2ea..c02a955f6 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py @@ -100,7 +100,7 @@ def test_a_permission_label_is_shown_whole_rather_than_cut_to_a_short_ceiling(): assert "test_a.py" in text assert "test_b.py" in text - assert "Reply with `R42 1`." in text + assert "Reply with `R42` and your choice, e.g. `R42 1`." in text def test_options_that_cannot_be_told_apart_are_not_answered_by_number(): @@ -229,7 +229,7 @@ def test_a_question_whose_title_did_not_fit_is_not_answerable_here(): def test_a_question_that_fits_is_still_answered_by_number(): text = _render(_questions(_question("Which branch?", "main", "release"))) - assert "Reply with `R42 1`." in text + assert "Reply with `R42` and your answer, e.g. `R42 1`." in text def test_options_told_apart_only_by_their_descriptions_are_not_cut_there(): @@ -249,7 +249,7 @@ def test_options_told_apart_only_by_their_descriptions_are_not_cut_there(): assert "to staging" in text assert "to production" in text - assert "Reply with `R42 1`." in text + assert "Reply with `R42` and your answer, e.g. `R42 1`." in text def test_a_description_too_long_for_even_that_stops_the_form_asking(): @@ -316,7 +316,7 @@ def test_an_option_its_button_says_in_full_is_not_printed_under_it(): "**Permission needed** Β· request `R42`", "Run a command?", "`pnpm test`", - "Reply with `R42 1`.", + "Reply with `R42` and your choice, e.g. `R42 1`.", ] @@ -355,7 +355,7 @@ def test_an_option_that_outlasts_the_turn_keeps_the_line_saying_so(): assert lines[-2:] == [ "2. Allow for this session (applies for the rest of this session)", - "Reply with `R42 1`.", + "Reply with `R42` and your choice, e.g. `R42 1`.", ] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_questions_cards.py b/core/tests/switch_core/bridges/collaboration/test_session_questions_cards.py index 61901ee83..196e345dc 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_questions_cards.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_questions_cards.py @@ -165,19 +165,24 @@ def test_the_card_carries_nothing_that_names_the_session() -> None: def test_the_example_on_the_card_parses_and_answers_that_card(named: str) -> None: """The instruction, the grammar and the record are one claim. - Whatever the footer offers to copy has to come back through the parser as - an answer, and that answer has to resolve against the form the same card - was drawn from. This is the test the approval card did not have, and it is - exactly what the missing one would have caught: an example that reads - perfectly well and parses as nothing. + Whatever the footer offers as the answer to copy has to come back through + the parser as an answer, and that answer has to resolve against the form + the same card was drawn from. This is the test the approval card did not + have, and it is exactly what the missing one would have caught: an example + that reads perfectly well and parses as nothing. + + The footer spans the handle on its own before it spans the example, so the + example is the last of them β€” the handle alone is the thing to type first, + not a whole answer. """ request, reference = (_form(), FORM) if named == "form" else (_one_question(), ONE) footer = _footer(render_questions(request, reference).blocks) - example = re.search(r"`([^`]+)`", footer) + spans = re.findall(r"`([^`]+)`", footer) - assert example is not None, f"the card offers no example to copy: {footer}" - answer = parse_text_answer(example.group(1)) + assert spans, f"the card offers no example to copy: {footer}" + assert spans[0] == reference.handle + answer = parse_text_answer(spans[-1]) assert answer is not None, f"the card's own instruction does not parse: {footer}" resolved = resolve_text_answer(posted_form(request), answer) @@ -186,12 +191,12 @@ def test_the_example_on_the_card_parses_and_answers_that_card(named: str) -> Non def test_the_example_says_which_question_only_when_there_is_more_than_one() -> None: """A form of one question takes a bare number, and saying `q1=` would be noise.""" - assert "Reply with `R44 1`, or press a button." == _footer( - render_questions(_one_question(), ONE).blocks + assert "Reply with `R44` and your answer, e.g. `R44 1`, or press a button." == ( + _footer(render_questions(_one_question(), ONE).blocks) ) assert _footer(render_questions(_form(), FORM).blocks) == ( - 'Reply with `R43 q1=1; q2=1,2; q3="your answer"` β€” ' - "every question needs an answer." + "Reply with `R43` and your answers, e.g. " + '`R43 q1=1; q2=1,2; q3="your answer"` β€” every question needs an answer.' ) @@ -199,7 +204,7 @@ def test_a_question_with_nothing_to_number_is_shown_as_words_to_write() -> None: """There is no option 1 on it, so an example offering one would be a lie.""" request = _amend(_one_question(), options=[], allow_custom_answer=True) - assert 'Reply with `R44 "your answer"`.' == _footer( + assert 'Reply with `R44` and your answer, e.g. `R44 "your answer"`.' == _footer( render_questions(request, ONE).blocks ) @@ -287,9 +292,9 @@ def test_no_form_shape_makes_the_card_offer_an_answer_it_would_refuse() -> None: if footer.startswith("This card cannot be answered"): continue - example = re.search(r"`([^`]+)`", footer) - assert example is not None, f"no example in {footer!r}" - answer = parse_text_answer(example.group(1)) + spans = re.findall(r"`([^`]+)`", footer) + assert spans, f"no example in {footer!r}" + answer = parse_text_answer(spans[-1]) assert answer is not None, f"{footer!r} does not parse" resolved = resolve_text_answer(posted_form(request), answer) assert not isinstance(resolved, Unanswerable), f"{footer!r}: {resolved}" diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_requests.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_requests.py index ea3a3b77f..61a0c6c1c 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_requests.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_requests.py @@ -155,7 +155,7 @@ def test_the_card_says_how_to_answer_in_words() -> None: message = render_approval(request, REFERENCE) assert not any(block["type"] == "context" for block in message.blocks) - assert "Reply with `R42 1`" in message.text + assert "Reply with `R42` and your choice, e.g. `R42 1`" in message.text assert message.text == render_approval_text(request, REFERENCE) assert message.text.startswith("> Request R42: Run project tests") assert "1. Allow once" in message.text @@ -169,14 +169,19 @@ def test_what_the_card_tells_you_to_type_is_what_the_grammar_reads() -> None: who copied it verbatim got a handle of `"R42`, which resolves to nothing, logs nothing and leaves the card unchanged. A code span is what makes the example copy back: Slack draws it as one and the grammar strips the marks. + + The footer spans the handle on its own before it spans the example, so the + example is the last of them β€” the handle alone is the thing to type first, + not a whole answer. """ request = _projection().open_requests()[0] footer = _footer_of(render_approval(request, REFERENCE)) - example = re.search(r"`([^`]+)`", footer) + spans = re.findall(r"`([^`]+)`", footer) - assert example is not None, f"the card offers no example to copy: {footer}" - for typed in (example.group(1), f"`{example.group(1)}`", f"_{example.group(1)}_"): + assert spans, f"the card offers no example to copy: {footer}" + assert spans[0] == "R42" + for typed in (spans[-1], f"`{spans[-1]}`", f"_{spans[-1]}_"): answer = parse_text_answer(typed) assert answer is not None, f"the card's own instruction does not parse: {typed}" assert answer.handle == "R42" @@ -292,9 +297,9 @@ def test_no_option_count_makes_the_card_offer_an_answer_it_would_refuse() -> Non if footer.startswith("This card cannot be answered"): continue - example = re.search(r"`([^`]+)`", footer) - assert example is not None, f"{count} options: no example in {footer!r}" - answer = parse_text_answer(example.group(1)) + spans = re.findall(r"`([^`]+)`", footer) + assert spans, f"{count} options: no example in {footer!r}" + answer = parse_text_answer(spans[-1]) assert answer is not None, f"{count} options: {footer!r} does not parse" resolved = resolve_text_answer(posted_form(card), answer) assert not isinstance(resolved, Unanswerable), f"{count} options: {resolved}" diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 1f70e5992..57f911fb7 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -519,7 +519,8 @@ async def test_what_a_refusal_carries_is_answerable_without_the_buttons() -> Non A posted card omits them β€” the buttons beside it spell them out, and repeating them costs a line each. That drawing republished on its own ends - "Reply with `R7 1`" over a card that never printed a 1. + "Reply with `R7` and your choice, e.g. `R7 1`" over a card that never + printed a 1. """ adapter = _adapter() ref = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) @@ -532,7 +533,10 @@ async def test_what_a_refusal_carries_is_answerable_without_the_buttons() -> Non lines = caught.value.text.splitlines() assert "1. Allow once" in lines assert "2. Deny" in lines - assert lines[-1] == "Reply with R7 1." + assert ( + lines[-1] + == "Reply with R7 and your choice, e.g. R7 1." + ) async def test_a_refused_post_carries_the_same_buttonless_drawing() -> None: @@ -1063,7 +1067,7 @@ async def test_the_body_does_not_repeat_what_the_buttons_already_say() -> None: text = _posted(adapter)["text"] assert "1. Allow once" not in text assert "2. Deny" not in text - assert "Reply with R7 1." in text + assert "Reply with R7 and your choice, e.g. R7 1." in text async def test_a_press_carries_the_request_and_where_the_control_was() -> None: From 66ec402ea6771a4a23d8edee212a9d56c589ac2e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 20:06:43 +0100 Subject: [PATCH 044/120] fix(providers): put the command, not the explanation, in a request's detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A permission card draws `detail` set apart β€” a code span on every surface that has one β€” because it is meant to be the literal thing being approved. Two adapters sent the opposite: Claude Code put the command in `title` and the model's one-line summary in `detail`, and Codex put the command in `title` and its reason for asking in `detail`. A reader saw the explanation formatted as something to type, and the command as prose. Both now follow the shape OpenCode already used: prose in `title`, the command alone in `detail`. Codex keeps the working directory visible in the title when it gives no reason, so nothing is lost. Non-command tools are unchanged. The contract is written down on the event type so the next adapter does not have to infer it. Co-Authored-By: Claude Opus 5 --- .../src/claude/claude-adapter.test.ts | 42 +++++++++++++++ .../src/claude/claude-adapter.ts | 4 +- .../src/claude/claude-mapping.ts | 27 ++++++++++ .../src/codex/codex-adapter.test.ts | 52 +++++++++++++++++++ .../src/codex/codex-adapter.ts | 19 ++++++- .../packages/agent-providers/src/events.ts | 6 +++ 6 files changed, 146 insertions(+), 4 deletions(-) diff --git a/console/packages/agent-providers/src/claude/claude-adapter.test.ts b/console/packages/agent-providers/src/claude/claude-adapter.test.ts index a8beff2e5..0945aa317 100644 --- a/console/packages/agent-providers/src/claude/claude-adapter.test.ts +++ b/console/packages/agent-providers/src/claude/claude-adapter.test.ts @@ -544,6 +544,7 @@ describe('ClaudeAdapter approvals', () => { expect(opened.requestType).toBe('command_execution_approval'); expect(opened.turnId).toBe('turn-1'); expect(opened.title).toBe('Claude wants to run echo hi'); + expect(opened.detail).toBe('echo hi'); expect(opened.options.map((option) => option.decision)).toEqual([ 'accept', 'acceptForSession', @@ -556,6 +557,47 @@ describe('ClaudeAdapter approvals', () => { expect(resolved.decision).toBe('accept'); }); + it('puts the command in detail and the description in the title', async () => { + const { sdk, adapter, recorder } = await startSession(); + await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' }); + const controller = new AbortController(); + void sdk.canUseTool()( + 'Bash', + { command: 'pwd', description: 'Print working directory' }, + toolOptions(controller.signal) + ); + + const opened = await recorder.waitFor('request.opened', () => true, 1_000); + expect(opened.title).toBe('Print working directory'); + expect(opened.detail).toBe('pwd'); + }); + + it('names the tool when a command arrives with nothing to describe it', async () => { + const { sdk, adapter, recorder } = await startSession(); + await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' }); + const controller = new AbortController(); + void sdk.canUseTool()('Bash', { command: 'pwd' }, toolOptions(controller.signal)); + + const opened = await recorder.waitFor('request.opened', () => true, 1_000); + expect(opened.title).toBe('Run a Bash command'); + expect(opened.detail).toBe('pwd'); + }); + + it('leaves a tool that is not a command with its description as the detail', async () => { + const { sdk, adapter, recorder } = await startSession(); + await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' }); + const controller = new AbortController(); + void sdk.canUseTool()( + 'Write', + { file_path: '/work/notes.md' }, + toolOptions(controller.signal, { description: 'Create the notes file' }) + ); + + const opened = await recorder.waitFor('request.opened', () => true, 1_000); + expect(opened.title).toBe('Write /work/notes.md'); + expect(opened.detail).toBe('Create the notes file'); + }); + it('rescopes the CLI suggestions to the session on acceptForSession', async () => { const { sdk, adapter, recorder } = await startSession(); await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' }); diff --git a/console/packages/agent-providers/src/claude/claude-adapter.ts b/console/packages/agent-providers/src/claude/claude-adapter.ts index 457b6fcd8..3c6701a66 100644 --- a/console/packages/agent-providers/src/claude/claude-adapter.ts +++ b/console/packages/agent-providers/src/claude/claude-adapter.ts @@ -37,6 +37,7 @@ import type { UserInputQuestion, } from '../events'; import { + approvalContent, isRecord, itemTypeForTool, outcomeForResult, @@ -977,8 +978,7 @@ export class ClaudeAdapter implements ProviderAdapter { turnId, requestId, requestType: requestTypeForTool(toolName), - title: options.title ?? toolTitle(toolName, toolInput), - ...(options.description ? { detail: options.description } : {}), + ...approvalContent(toolName, toolInput, options.title, options.description), options: APPROVAL_OPTIONS, raw: { source: 'claude', payload: { toolName, toolInput } }, }); diff --git a/console/packages/agent-providers/src/claude/claude-mapping.ts b/console/packages/agent-providers/src/claude/claude-mapping.ts index fdb4aba65..d7664d670 100644 --- a/console/packages/agent-providers/src/claude/claude-mapping.ts +++ b/console/packages/agent-providers/src/claude/claude-mapping.ts @@ -75,6 +75,33 @@ export function toolTitle(toolName: string, input: Record): str return toolName; } +/** + * Which part of a permission request is the command and which is the prose. + * + * A reader sees `detail` set apart β€” a code span where the surface has one β€” so + * it has to be the literal thing being approved, not a second sentence about + * it. For a command tool the command is in the input and the summary is the + * prose, which is the opposite of how they read on an activity item. + */ +export function approvalContent( + toolName: string, + input: Record, + title: string | undefined, + description: string | undefined +): { title: string; detail?: string } { + if (COMMAND_TOOLS.has(toolName)) { + const command = stringField(input, 'command'); + if (command) { + const summary = title ?? description ?? stringField(input, 'description'); + return { title: summary ? truncate(summary) : `Run a ${toolName} command`, detail: command }; + } + } + return { + title: title ?? toolTitle(toolName, input), + ...(description ? { detail: description } : {}), + }; +} + /** * The CLI stamps a user abort on the result: `aborted_streaming` when the * interrupt landed mid-stream, `aborted_tools` when it landed in a tool call. diff --git a/console/packages/agent-providers/src/codex/codex-adapter.test.ts b/console/packages/agent-providers/src/codex/codex-adapter.test.ts index ddda95a6a..b62249b81 100644 --- a/console/packages/agent-providers/src/codex/codex-adapter.test.ts +++ b/console/packages/agent-providers/src/codex/codex-adapter.test.ts @@ -365,6 +365,58 @@ describe('CodexAdapter', () => { expect(eventsOf(events, 'request.resolved')[0]).toMatchObject({ decision: 'decline' }); }); + it('puts the command in detail and the reason for asking in the title', async () => { + const { adapter, server, events } = await start('approval-required'); + server.replyAlways('turn/start', () => ({ turn: { id: 'native-a', status: 'inProgress' } })); + await adapter.sendTurn({ sessionId: 'session-1', turnId: 'caller-1', text: 'run it' }); + server.notify('turn/started', turnNotification('native-a', 'inProgress')); + + server.send({ + id: 92, + method: 'item/commandExecution/requestApproval', + params: { + threadId: THREAD, + turnId: 'native-a', + itemId: 'exec-1', + command: 'rm -rf build', + cwd: '/work', + reason: 'Clearing stale build output', + availableDecisions: ['accept'], + }, + }); + await vi.waitFor(() => expect(eventsOf(events, 'request.opened')).toHaveLength(1)); + expect(eventsOf(events, 'request.opened')[0]).toMatchObject({ + title: 'Clearing stale build output', + detail: 'rm -rf build', + }); + }); + + it('keeps the working directory visible when codex gives no reason', async () => { + const { adapter, server, events } = await start('approval-required'); + server.replyAlways('turn/start', () => ({ turn: { id: 'native-a', status: 'inProgress' } })); + await adapter.sendTurn({ sessionId: 'session-1', turnId: 'caller-1', text: 'run it' }); + server.notify('turn/started', turnNotification('native-a', 'inProgress')); + + server.send({ + id: 93, + method: 'item/commandExecution/requestApproval', + params: { + threadId: THREAD, + turnId: 'native-a', + itemId: 'exec-1', + command: 'echo hi', + cwd: '/work', + reason: null, + availableDecisions: ['accept'], + }, + }); + await vi.waitFor(() => expect(eventsOf(events, 'request.opened')).toHaveLength(1)); + expect(eventsOf(events, 'request.opened')[0]).toMatchObject({ + title: 'Run a command in /work', + detail: 'echo hi', + }); + }); + it('surfaces a question and answers it with the codex answer shape', async () => { const { adapter, server, events } = await start(); server.replyAlways('turn/start', () => ({ turn: { id: 'native-a', status: 'inProgress' } })); diff --git a/console/packages/agent-providers/src/codex/codex-adapter.ts b/console/packages/agent-providers/src/codex/codex-adapter.ts index 0ddeb19f6..45a473db6 100644 --- a/console/packages/agent-providers/src/codex/codex-adapter.ts +++ b/console/packages/agent-providers/src/codex/codex-adapter.ts @@ -167,6 +167,22 @@ function advertisedDecisions(raw: CodexCommandExecutionApprovalParams): Approval return decisions; } +/** + * Which part of a command approval is the command and which is the prose. + * + * A reader sees `detail` set apart β€” a code span where the surface has one β€” so + * the command belongs there and Codex's reason for asking belongs in the title. + * With no command there is nothing to set apart and the prose carries the card. + */ +function commandApprovalContent(payload: CodexCommandExecutionApprovalParams): { + title: string; + detail?: string; +} { + const where = payload.cwd ? `Run a command in ${payload.cwd}` : 'Run a command'; + if (!payload.command) return { title: payload.reason ?? where }; + return { title: payload.reason ?? where, detail: payload.command }; +} + function toCodexInput( text: string, attachments: ProviderSendTurnInput['attachments'] @@ -668,8 +684,7 @@ export class CodexAdapter implements ProviderAdapter { return this.openApproval(state, { turnId: payload.turnId, requestType: 'command_execution_approval', - title: payload.command ?? 'Run a command', - detail: payload.reason ?? payload.cwd ?? undefined, + ...commandApprovalContent(payload), options: approvalOptions(advertisedDecisions(payload)), respond: (decision) => ({ decision }), }); diff --git a/console/packages/agent-providers/src/events.ts b/console/packages/agent-providers/src/events.ts index 516faa760..d9166a790 100644 --- a/console/packages/agent-providers/src/events.ts +++ b/console/packages/agent-providers/src/events.ts @@ -102,7 +102,13 @@ export type ProviderRuntimeEvent = EventBase & turnId: string; requestId: string; requestType: RequestType; + /** Prose: what is being asked, in a sentence a reader can skim. */ title: string; + /** + * The literal thing being approved β€” a command, a path β€” and nothing + * else. Consumers set it apart from the prose, in a code span where the + * surface has one, so a sentence here reads as something to type. + */ detail?: string; options: ApprovalOption[]; } From 8302f62a2e1b1ec29ee3fb130a6be98afb85f6ea Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 20:12:48 +0100 Subject: [PATCH 045/120] fix(claude): keep the decision context a command approval was given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `66ec402e` put the command in `detail` but narrowed the prose to a single field, so two things went missing. A host that sends both a title and a description lost the description β€” "Run deployment" survived and "Uses production credentials." did not, which is the half someone deciding needs most. And a long description was cut to 160 characters before anything had budgeted it, so a warning past that point vanished from Console and from the messaging card, both of which would still offer buttons as though they had shown the whole thing. Both halves of the prose are now kept, joined, and deduplicated when a host sends the same string twice. Nothing is cut here: the consumer that cuts also records that it cut, and decides whether what is left is still answerable. Co-Authored-By: Claude Opus 5 --- .../src/claude/claude-adapter.test.ts | 51 +++++++++++++++++++ .../src/claude/claude-mapping.ts | 14 ++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/console/packages/agent-providers/src/claude/claude-adapter.test.ts b/console/packages/agent-providers/src/claude/claude-adapter.test.ts index 0945aa317..231dd6db4 100644 --- a/console/packages/agent-providers/src/claude/claude-adapter.test.ts +++ b/console/packages/agent-providers/src/claude/claude-adapter.test.ts @@ -583,6 +583,57 @@ describe('ClaudeAdapter approvals', () => { expect(opened.detail).toBe('pwd'); }); + it('keeps the description when the host also supplied a title', async () => { + const { sdk, adapter, recorder } = await startSession(); + await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' }); + const controller = new AbortController(); + void sdk.canUseTool()( + 'Bash', + { command: 'npm run deploy' }, + toolOptions(controller.signal, { + title: 'Run deployment', + description: 'Uses production credentials.', + }) + ); + + const opened = await recorder.waitFor('request.opened', () => true, 1_000); + expect(opened.title).toBe('Run deployment β€” Uses production credentials.'); + expect(opened.detail).toBe('npm run deploy'); + }); + + it('does not cut the decision context before anything has budgeted it', async () => { + const { sdk, adapter, recorder } = await startSession(); + await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' }); + const controller = new AbortController(); + const reason = `${'This runs against the configured workspace. '.repeat(6)}It writes to production.`; + void sdk.canUseTool()( + 'Bash', + { command: 'npm run deploy' }, + toolOptions(controller.signal, { description: reason }) + ); + + const opened = await recorder.waitFor('request.opened', () => true, 1_000); + expect(reason.length).toBeGreaterThan(160); + expect(opened.title).toBe(reason); + }); + + it('says a title once when the host sends it as both title and description', async () => { + const { sdk, adapter, recorder } = await startSession(); + await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' }); + const controller = new AbortController(); + void sdk.canUseTool()( + 'Bash', + { command: 'pwd' }, + toolOptions(controller.signal, { + title: 'Print working directory', + description: 'Print working directory', + }) + ); + + const opened = await recorder.waitFor('request.opened', () => true, 1_000); + expect(opened.title).toBe('Print working directory'); + }); + it('leaves a tool that is not a command with its description as the detail', async () => { const { sdk, adapter, recorder } = await startSession(); await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' }); diff --git a/console/packages/agent-providers/src/claude/claude-mapping.ts b/console/packages/agent-providers/src/claude/claude-mapping.ts index d7664d670..485833535 100644 --- a/console/packages/agent-providers/src/claude/claude-mapping.ts +++ b/console/packages/agent-providers/src/claude/claude-mapping.ts @@ -82,6 +82,12 @@ export function toolTitle(toolName: string, input: Record): str * it has to be the literal thing being approved, not a second sentence about * it. For a command tool the command is in the input and the summary is the * prose, which is the opposite of how they read on an activity item. + * + * Both halves of the prose are kept when the host sends both: a title says what + * the command is for and a description often says what it will cost, and + * someone deciding needs the second one most. Nothing is cut here β€” a consumer + * that cuts also records that it did, and answers for whether what survived is + * still enough to decide on. Cutting first hides that judgement from it. */ export function approvalContent( toolName: string, @@ -92,8 +98,12 @@ export function approvalContent( if (COMMAND_TOOLS.has(toolName)) { const command = stringField(input, 'command'); if (command) { - const summary = title ?? description ?? stringField(input, 'description'); - return { title: summary ? truncate(summary) : `Run a ${toolName} command`, detail: command }; + const summary = description ?? stringField(input, 'description'); + const prose = [...new Set([title, summary].filter((part) => part !== undefined))]; + return { + title: prose.length > 0 ? prose.join(' β€” ') : `Run a ${toolName} command`, + detail: command, + }; } } return { From af636fe0d131a6d771bb9ca24650b4147557d066 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 21:10:49 +0100 Subject: [PATCH 046/120] fix(sessions): leave no hourglass on a message nobody is waiting behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways the queued reaction could survive every turn that wanted it, all of them about who is recorded as holding a mark several turns share. A turn joining a mark another turn already put there kept its place in memory only, because the durable claim was written by the platform call and only the first holder makes one. A restart emptied the memory, the remaining holders ended finding nothing of their own to take off, and the hourglass stayed. Every holder now records its stake where the mark is there to be held β€” not where the first holder's addition was refused, since a reaction the platform would not add is not one to wait on. A refused removal dropped the turn from the holders before asking the platform, so its own end had no reason to ask again. The expectation survives a refusal, precisely because the reaction may still be there, and holding is now read from that as well. A claim written before the two marks were told apart names the message and the bot but not which mark, and matched neither. There was one reaction then, so it is read as the working one rather than rewritten. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/activity_journal.py | 53 ++++++++- .../bridges/collaboration/session/outbound.py | 59 ++++++--- .../sessions/test_activity_durability.py | 112 ++++++++++++++++++ 3 files changed, 204 insertions(+), 20 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index 6a11acb10..27fa13fa1 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -14,9 +14,9 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Any +from typing import Any, Final -from sqlalchemy import Text, cast, func, literal, select, text, update +from sqlalchemy import Text, and_, cast, func, literal, or_, select, text, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker @@ -51,6 +51,49 @@ def _claim_in(document: Any) -> ColumnElement: ) +#: What a claim that does not say which mark it holds is holding. +#: +#: There was one reaction before the hourglass, so a claim naming the message +#: and the bot but not the mark is naming the working one. Read that way rather +#: than rewritten: the rows belong to whichever turn wrote them, and a claim +#: only has to be understood for as long as the reaction it describes is still +#: on the message. Rewriting them would also have to reach rows no turn here +#: has any business opening. +IMPLIED_MARK: Final[str] = "working" + + +def claims(stored: Any, mark: dict[str, str]) -> bool: + """Whether a recorded claim names this reaction.""" + if not isinstance(stored, dict): + return False + if stored == mark: + return True + if mark.get("mark") != IMPLIED_MARK: + return False + return stored == {key: value for key, value in mark.items() if key != "mark"} + + +def _claiming(mark: dict[str, str]) -> ColumnElement: + """Rows whose recorded claim names this reaction β€” `claims` as a query. + + Containment is recursive, so the anchor alone also matches a claim on the + other mark; the kind being absent is what tells an older claim from that + one, and asking for it directly reads as SQL NULL where the key is not + there. + """ + named: ColumnElement = SessionActivityPost.data.contains({"mark": mark}) + if mark.get("mark") != IMPLIED_MARK: + return named + anchor = {key: value for key, value in mark.items() if key != "mark"} + return or_( + named, + and_( + SessionActivityPost.data.contains({"mark": anchor}), + SessionActivityPost.data["mark"]["mark"].astext.is_(None), + ), + ) + + @dataclass class ActivityRecord: sessions: async_sessionmaker[AsyncSession] @@ -259,7 +302,7 @@ async def mark_expected( select(SessionActivityPost).where( SessionActivityPost.tenant_id == require_tenant_id(), SessionActivityPost.bridge_id == self.bridge_id, - SessionActivityPost.data.contains({"mark": mark}), + _claiming(mark), ) ) ).first() @@ -290,7 +333,7 @@ async def mark_holders( select(SessionActivityPost).where( SessionActivityPost.tenant_id == require_tenant_id(), SessionActivityPost.bridge_id == self.bridge_id, - SessionActivityPost.data.contains({"mark": mark}), + _claiming(mark), ) ) return { @@ -344,7 +387,7 @@ async def forget_mark( SessionActivityPost.bridge_id == self.bridge_id, SessionActivityPost.session_id == session_id, SessionActivityPost.command_id == command_id, - SessionActivityPost.data.contains({"mark": mark}), + _claiming(mark), held == attempt, ) .values(data=forgotten) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 50ab7766b..dce3d9e50 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -58,7 +58,7 @@ from switch_core.db.stores.session_request_post_store import SessionRequestPostStore from switch_core.sessions.contract import TURN_ENDED, Item, SnapshotRequest, TurnUpsert -from .activity_journal import ActivityJournal, ActivityRecord +from .activity_journal import ActivityJournal, ActivityRecord, claims from .form import posted_form from .renderers import RequestReference @@ -944,6 +944,11 @@ async def _draw_log( anchor.log_state = state return True + @property + def _reacts(self) -> bool: + """Whether the adapter marks a message with a reaction at all.""" + return bool(getattr(self._adapter, "supports_activity_reactions", False)) + def _wanted_mark(self, turn: TurnUpsert) -> ActivityMark: """Which mark this turn's current state earns. @@ -980,19 +985,25 @@ async def _hold_mark( def _holds(self, key: tuple[str, str], anchor: _Anchor, mark: ActivityMark) -> bool: """Whether this turn's own ask may have put `mark` on the message. - Both halves, for the same reason `_claimants` reads both: this + Every half, for the same reason `_claimants` reads more than one: this process's memory is empty after a restart and the row's claim is not, - and a publisher with no journal has only the memory. A turn the second - to want a mark has no claim of its own β€” the first turn's covers the - reaction they share β€” and is answered by the memory alone, which is - where its place among the holders is kept. + and a publisher with no journal has only the memory. + + Being one of the holders is the ordinary answer. A standing expectation + is the answer when that has already been given up and the mark did not + come off with it: a refused removal drops the turn from the holders + before the platform is asked, and what it leaves behind is the + expectation, unretracted precisely because the reaction may still be + there. Reading only the holders is how a refusal comes to be its own + last word, with nothing left to say the retry is owed. """ if key in self._thread_turns.get(self._thread_key(anchor, mark), frozenset()): return True + wanted = self._mark_key(anchor, mark) + if key in self._expecting.get(_mark_id(wanted), {}): + return True record = self._record.get() - return record is not None and record.data.get("mark") == self._mark_key( - anchor, mark - ) + return record is not None and claims(record.data.get("mark"), wanted) async def _claim_thread( self, key: tuple[str, str], anchor: _Anchor, mark: ActivityMark @@ -1006,13 +1017,33 @@ async def _claim_thread( that same thread shares it too β€” and the second must not find the reaction already there and skip it, nor the first's own end wipe it out from under the second. + + Only the first asks the platform, and where the mark is there to be + held every one of them records it, because only the ask is redundant + and not the stake in what is on the message. A holder whose stake lives + in this process's memory alone is a holder only until the process + restarts, and the reaction outlives that: the rest of them end, each + finding nothing of its own to take off, and the mark stays on a message + with no turn left to answer for it. + + Where the mark is not there the joining turn records nothing, and the + first one's refusal is how that is known β€” a platform that would not + add the reaction is not holding one, and a turn claiming otherwise + would have its own end wait on a mark nobody can remove. """ if anchor.reaction_ref is None: return turns = self._thread_turns.setdefault(self._thread_key(anchor, mark), set()) - first = not turns - if not first or await self._mark_thread(key, anchor, mark=mark, on=True): - turns.add(key) + if key in turns: + return + if not turns: + if not await self._mark_thread(key, anchor, mark=mark, on=True): + return + elif self._reacts: + held = self._mark_key(anchor, mark) + if await self._mark_may_be_there(held): + await self._expect_mark(key, held) + turns.add(key) async def _release_marks(self, key: tuple[str, str], anchor: _Anchor) -> bool: """Take this turn off every mark it could be holding. @@ -1121,9 +1152,7 @@ async def _mark_thread( clear, which is why an expectation is named by the ask and not only by the turn that made it. """ - if anchor.reaction_ref is None or not getattr( - self._adapter, "supports_activity_reactions", False - ): + if anchor.reaction_ref is None or not self._reacts: return True held = self._mark_key(anchor, mark) removing: set[tuple[str, str, str]] = set() diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 9e92fbafd..448504c72 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -1153,6 +1153,118 @@ async def test_a_platform_with_one_reaction_marks_a_queued_prompt_as_it_always_d assert not platform.hourglass +class RefusingHourglass(ActivitySlack): + """A platform that will not take the hourglass off, until it will. + + Slack refuses a reaction change outright when the bot has lost the scope + for it, and the permission can come back. What the refusal leaves is a + reaction still on the message, which is why it is told apart from a + request that simply failed. + """ + + refuse_removal = False + + async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): + if mark == "queued" and not on and self.refuse_removal: + raise ActivityMarkRefused("Reactions are not allowed in this channel") + await super().mark_activity( + channel, ref, agent_name=agent_name, mark=mark, on=on, force=force + ) + + +async def test_a_shared_hourglass_outlives_a_restart_until_its_last_holder_ends( + session_factory, +): + """Both prompts waiting behind one message hold its hourglass, and only the + first of them asked the platform for it. + + A holder whose stake is only this process's memory stops being a holder + when the process does. The reaction does not: the second turn ends finding + nothing of its own to take off, and the hourglass stays on a message where + nobody is waiting any more. + """ + await setup(session_factory) + platform = ActivitySlack() + renderer = activity(session_factory, platform) + await publish(renderer, "queued") + await publish(renderer, "queued", agent="Other", command="other") + await publish(renderer, "completed") + assert platform.hourglass == {"channel-demo:question"} + + await publish( + activity(session_factory, platform), "completed", agent="Other", command="other" + ) + + assert not platform.hourglass + + +async def test_a_refused_hourglass_removal_is_asked_again_when_the_turn_ends(): + """A refusal is not the last word on a mark that is still there. + + The turn is dropped from the holders before the platform is asked, so a + refusal leaves it holding nothing β€” and its own end then has no reason to + ask again. What survives the refusal is the expectation, standing because + the reaction may still be on the message, and that is what is owed a retry. + """ + platform = RefusingHourglass() + renderer = SessionTurnActivity(platform) + await publish(renderer, "queued") + platform.refuse_removal = True + await publish(renderer, "running") + assert platform.hourglass == {"channel-demo:question"} + platform.refuse_removal = False + + await publish(renderer, "completed") + + assert not platform.hourglass + + +async def test_a_claim_written_before_the_hourglass_still_answers_for_the_eyes( + session_factory, +): + """A claim from before the two marks were told apart names no mark. + + Read as naming none of them, a refused removal finds no evidence that + anything was ever put on the message, reports the cleanup done and leaves + the πŸ‘€ in plain sight. There was one reaction when those rows were written, + so the claim is the working one and the turn is not finished until it is + off. + """ + await setup(session_factory) + platform = ActivitySlack() + await publish(activity(session_factory, platform)) + await _unstamp_the_mark(session_factory) + + async def refuse(*args, **kwargs): + raise ActivityMarkRefused("Reactions are not allowed in this channel") + + platform.mark_activity = refuse + ended = await publish(activity(session_factory, platform), "completed") + + assert platform.reactions == {"channel-demo:question"} + assert ended is False + + +async def _unstamp_the_mark(session_factory): + """Put the recorded claims back into the shape an older release wrote.""" + async with session_factory() as db: + for row in list(await db.scalars(select(SessionActivityPost))): + unstamped = { + key: row.data["mark"][key] for key in row.data["mark"] if key != "mark" + } + await db.execute( + update(SessionActivityPost) + .where( + SessionActivityPost.tenant_id == row.tenant_id, + SessionActivityPost.bridge_id == row.bridge_id, + SessionActivityPost.session_id == row.session_id, + SessionActivityPost.command_id == row.command_id, + ) + .values(data=row.data | {"mark": unstamped}) + ) + await db.commit() + + async def test_busy_journal_is_skipped_until_next_sweep(session_factory): await setup(session_factory) journal = ActivityJournal(session_factory, "bridge") From 9b4b6b5ef41e814607b8faf5bbd09516666e66b1 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 21:23:23 +0100 Subject: [PATCH 047/120] fix(collaboration): stop a failed card's notice showing its own tags The notice that says a card could not be redrawn carries the card's drawing so a reader can still see what it says. The drawing is in the platform's own spelling, because the platform drew it; the notice around it is Switch Markdown, because every notice is. Putting the two in one string sent the drawing through the Markdown conversion, and on Telegram the reader got `<code>R7 1</code>` where the card's own instruction should have been. `admin_message` now takes the drawn part separately and joins it after the conversion rather than through it. Nothing else about notice escaping changes: a body written as Markdown is still converted exactly as before, and an adapter that draws nothing passes nothing. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 17 ++++++- .../bridges/collaboration/discord/adapter.py | 3 +- .../collaboration/mattermost/adapter.py | 3 +- .../bridges/collaboration/session/outbound.py | 4 +- .../bridges/collaboration/slack/adapter.py | 3 +- .../bridges/collaboration/teams/adapter.py | 3 +- .../bridges/collaboration/telegram/adapter.py | 5 ++- .../collaboration/test_telegram_sdk_only.py | 44 +++++++++++++++++++ 8 files changed, 73 insertions(+), 9 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 0bb289dd3..02823f085 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -544,6 +544,7 @@ async def admin_message( thread_root_id: str | None = None, *, message_type: str | None = None, + drawn: str | None = None, ) -> str | None: """Post a first-class admin/system message to the external channel, rendered in the platform's native way as the bridge's own identity (not @@ -562,14 +563,26 @@ async def admin_message( notices, and the relayed admin events alike. An override must therefore run `translate_outbound` itself. Splitting that responsibility between callers is what once sent a body through the conversion twice, and the - second pass escapes the markup the first one produced.""" + second pass escapes the markup the first one produced. + + `drawn` is content this adapter drew, in the platform's own spelling, + to go under the notice β€” a card's fallback text, where the notice is + about that card. It is appended after the conversion and never through + it. Written into `content` instead it would be converted as if it were + Markdown, and a reader is shown the platform's own tags as prose: the + very failure the paragraph above describes, arriving from the other + side. An override joins the two with `_admin_body`.""" return await self.send_message( channel_id, self._bridge_display_name(), - self.translate_outbound(content), + self._admin_body(self.translate_outbound(content), drawn), thread_root_id, ) + def _admin_body(self, rendered: str, drawn: str | None) -> str: + """A rendered notice, and under it whatever the adapter already drew.""" + return rendered if drawn is None else f"{rendered}\n{drawn}" + def _bridge_display_name(self) -> str: return "Switch" diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 3e5b9682f..18a2fe502 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -724,6 +724,7 @@ async def admin_message( thread_root_id: str | None = None, *, message_type: str | None = None, + drawn: str | None = None, ) -> str | None: # Renders its own body: every caller of `admin_message` passes Switch # Markdown, so the conversion belongs here rather than at each of @@ -750,7 +751,7 @@ async def admin_message( return None return await self._send_chunked( - self.translate_outbound(content), + self._admin_body(self.translate_outbound(content), drawn), lambda part: target.send(part, suppress_embeds=True), where=f"channel {channel_id}", ) diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index dde349282..6f3867871 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -377,6 +377,7 @@ async def admin_message( thread_root_id: str | None = None, *, message_type: str | None = None, + drawn: str | None = None, ) -> str | None: """Post an admin/system message natively on Mattermost. @@ -389,7 +390,7 @@ async def admin_message( conversion belongs here rather than at each of them β€” one of them forgetting is how a notice reached a channel with its markup showing. """ - content = self.translate_outbound(content) + content = self._admin_body(self.translate_outbound(content), drawn) loop = self._main_loop if loop is None: logger.error("Cannot post admin message: event loop not initialized") diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index dce3d9e50..e29a798dc 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -1907,9 +1907,9 @@ async def refresh( await self._adapter.admin_message( post.external_channel_id, f"The card for request {post.handle} above could not be updated, " - f"so it may still be offering buttons that no longer " - f"work.\n{error.text}", + "so it may still be offering buttons that no longer work.", self._adapter.notice_address(post.external_post_id, post.thread_id), + drawn=error.text, ) self._reported_edit_failures[post.token] = state raise diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 79d468e9b..26105473f 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -756,6 +756,7 @@ async def admin_message( thread_root_id: str | None = None, *, message_type: str | None = None, + drawn: str | None = None, ) -> str | None: # Renders its own body: every caller of `admin_message` passes Switch # Markdown, so the conversion belongs here rather than at each of @@ -780,7 +781,7 @@ async def admin_message( try: result = await self._web_client.chat_postMessage( channel=channel_id, - text=content, + text=self._admin_body(content, drawn), thread_ts=thread_ts, unfurl_links=False, unfurl_media=False, diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 7b87f6106..3097f0ee8 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -1182,6 +1182,7 @@ async def admin_message( thread_root_id: str | None = None, *, message_type: str | None = None, + drawn: str | None = None, ) -> str | None: # Admin/system messages render as the Switch bot itself β€” a plain text # activity, no per-agent Adaptive Card β€” so they read as the platform @@ -1189,7 +1190,7 @@ async def admin_message( if self._connector is None: raise RuntimeError("Cannot post admin message: Teams adapter not started") - body = self.translate_outbound(content) + body = self._admin_body(self.translate_outbound(content), drawn) thread_root_id = await self._post_to_answer_in(channel_id, thread_root_id) activity: dict[str, Any] = {"type": "message", "text": _hard_wrap(body)} mentions = self._mention_entities(body) diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index f6014a613..b784436f3 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -1080,6 +1080,7 @@ async def admin_message( thread_root_id: str | None = None, *, message_type: str | None = None, + drawn: str | None = None, ) -> str | None: # Admin/system notices post unattributed, so they read as the bridge # speaking rather than as one of the agents. @@ -1090,7 +1091,9 @@ async def admin_message( # showing. Platforms with a Markdown-ish native format got away with # skipping this; Telegram does not. return await self._send_text( - channel_id, self.translate_outbound(content), thread_root_id + channel_id, + self._admin_body(self.translate_outbound(content), drawn), + thread_root_id, ) async def update_message( diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 57f911fb7..834ac9cba 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -16,6 +16,7 @@ from dataclasses import replace from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -41,6 +42,7 @@ posted_form, resolve_pressed_position, ) +from switch_core.bridges.collaboration.session.outbound import SessionRequestCards from switch_core.bridges.collaboration.session.renderers import ( RequestReference, parse_answer_position, @@ -539,6 +541,48 @@ async def test_what_a_refusal_carries_is_answerable_without_the_buttons() -> Non ) +async def test_the_notice_about_a_failed_card_delivers_that_drawing_intact() -> None: + """What `error.text` holds is only half the claim: it is HTML, and the + notice it goes under is Switch Markdown. + + A notice is converted on its way out, because every other caller writes + Markdown. Putting the drawing inside it sends Telegram's own tags through + that conversion, and `` arrives escaped β€” so the reader is shown the + tags themselves, on the one message whose job is to say what the card can + no longer say. + """ + adapter = _adapter() + content = await _card() + _bot(adapter).edit_error = BadRequest("message to edit not found") + post = SimpleNamespace( + token="tok-1", + handle="R7", + external_channel_id=CHANNEL, + external_post_id=f"{CHANNEL}:42", + thread_id=None, + request_id=content.request.request_id, + ) + cards = SessionRequestCards( + adapter, + bridge_id="bridge", + surface="telegram", + posts=None, + session_factory=None, + ) + + with pytest.raises(RichContentFailed): + await cards.refresh(post, content.request, agent_name="my-agent") + + notice = _bot(adapter).messages[-1]["text"] + assert "could not be updated" in notice + assert "1. Allow once" in notice + assert ( + notice.splitlines()[-1] + == "Reply with R7 and your choice, e.g. R7 1." + ) + assert "<code>" not in notice + + async def test_a_refused_post_carries_the_same_buttonless_drawing() -> None: """The card never reached the chat at all, so there is even less chance of a keyboard wherever its text is shown instead.""" From d33f576b8a21740fa6f429d99fac5e5bb7f345f8 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 21:39:51 +0100 Subject: [PATCH 048/120] fix(collaboration): let a joining prompt ask for the mark it is sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second turn waiting behind the same message recorded a stake in the hourglass without asking the platform for it, on the strength of a reading taken before the stake was written down. Between the two, another publisher can end the first turn and take the reaction off: the joining prompt is left queued with no hourglass and nothing that would put one back, because it is already a holder and a redraw returns early. Every holder now asks. Adding a mark that is already on the message is what reconciliation after a restart does anyway, so the redundant half costs a call the platform answers with "already" β€” and the claim and the reaction are established together, in that order, by the same turn. A removal either sees the claim and leaves the mark alone, or went first and this ask restores it. That also removes the journal read the gate had put in a bookkeeping path. Separately, `_expect_mark` recognised the claim it was renewing by exact equality, so a claim written before the two marks were told apart was renewed as nothing. A refused retry then erased the row's only evidence, and the turn's own end reported the cleanup complete with the eyes still on the message. It reads a stored claim the way every other reader does. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 61 +++++++++++-------- .../sessions/test_activity_durability.py | 56 ++++++++++++++++- 2 files changed, 88 insertions(+), 29 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index e29a798dc..30fecc9ea 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -944,11 +944,6 @@ async def _draw_log( anchor.log_state = state return True - @property - def _reacts(self) -> bool: - """Whether the adapter marks a message with a reaction at all.""" - return bool(getattr(self._adapter, "supports_activity_reactions", False)) - def _wanted_mark(self, turn: TurnUpsert) -> ActivityMark: """Which mark this turn's current state earns. @@ -1018,31 +1013,32 @@ async def _claim_thread( reaction already there and skip it, nor the first's own end wipe it out from under the second. - Only the first asks the platform, and where the mark is there to be - held every one of them records it, because only the ask is redundant - and not the stake in what is on the message. A holder whose stake lives - in this process's memory alone is a holder only until the process - restarts, and the reaction outlives that: the rest of them end, each - finding nothing of its own to take off, and the mark stays on a message - with no turn left to answer for it. - - Where the mark is not there the joining turn records nothing, and the - first one's refusal is how that is known β€” a platform that would not - add the reaction is not holding one, and a turn claiming otherwise - would have its own end wait on a mark nobody can remove. + Every one of them asks, rather than only the first. The ask is + redundant where the reaction is already there, and adding a mark that + is already on the message is an operation this whole design leans on + anyway β€” it is what reconciliation after a restart does β€” so the + redundant half costs a call the platform answers with "already". What + the second ask buys is that the stake and the reaction are established + together, in that order, by the same turn. A joining turn that recorded + a stake without asking would be trusting a reading of the message taken + before the stake was written down, and between those two the turn that + put the mark there can end and take it off: a queued prompt left with + no hourglass, and nothing that will put one back. Asking closes that, + because a removal either sees the claim and leaves the mark alone, or + went first and this ask restores it. + + The refusals stay the joining turn's own. A platform that will not add + the reaction refuses every one of them, so each retracts its own + expectation and none is left waiting to remove a mark nobody could put + there. """ if anchor.reaction_ref is None: return turns = self._thread_turns.setdefault(self._thread_key(anchor, mark), set()) if key in turns: return - if not turns: - if not await self._mark_thread(key, anchor, mark=mark, on=True): - return - elif self._reacts: - held = self._mark_key(anchor, mark) - if await self._mark_may_be_there(held): - await self._expect_mark(key, held) + if not await self._mark_thread(key, anchor, mark=mark, on=True): + return turns.add(key) async def _release_marks(self, key: tuple[str, str], anchor: _Anchor) -> bool: @@ -1152,7 +1148,9 @@ async def _mark_thread( clear, which is why an expectation is named by the ask and not only by the turn that made it. """ - if anchor.reaction_ref is None or not self._reacts: + if anchor.reaction_ref is None or not getattr( + self._adapter, "supports_activity_reactions", False + ): return True held = self._mark_key(anchor, mark) removing: set[tuple[str, str, str]] = set() @@ -1236,12 +1234,21 @@ async def _expect_mark( stamp neither of them can be about. An expectation written before attempts were stamped carries no stamp, - and is addressed by the empty one until the turn asks again. + and is addressed by the empty one until the turn asks again. One + written before the mark was named is read for the mark it names now, + the same way every other reader of a claim reads it: an older claim + that this ask renews is still an older claim, and treating it as + nothing is how a refusal comes to erase a reaction that is really + there. """ expecting = self._expecting.setdefault(_mark_id(mark), {}) record = self._record.get() renewed = expecting.get(key) - if renewed is None and record is not None and record.data.get("mark") == mark: + if ( + renewed is None + and record is not None + and claims(record.data.get("mark"), mark) + ): renewed = str(record.data.get("mark_attempt", "")) attempt = _MarkAttempt(secrets.token_urlsafe(16), renewed) expecting[key] = attempt.token diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 448504c72..8dfd81c75 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -1175,8 +1175,7 @@ async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False async def test_a_shared_hourglass_outlives_a_restart_until_its_last_holder_ends( session_factory, ): - """Both prompts waiting behind one message hold its hourglass, and only the - first of them asked the platform for it. + """Both prompts waiting behind one message hold its hourglass. A holder whose stake is only this process's memory stops being a holder when the process does. The reaction does not: the second turn ends finding @@ -1198,6 +1197,31 @@ async def test_a_shared_hourglass_outlives_a_restart_until_its_last_holder_ends( assert not platform.hourglass +async def test_a_prompt_joining_a_queue_puts_back_an_hourglass_taken_behind_it( + session_factory, +): + """A second prompt waiting behind the same message asks for the hourglass + too, rather than taking the first one's word that it is there. + + The word can be out of date. Another publisher ends the first prompt and + takes the reaction off, and this one is not told: its memory still has a + holder and a standing expectation, both describing a message that no longer + carries the mark. A joining prompt that only wrote down a stake would be + left queued with nothing to show for it and nothing that would put one + back. + """ + await setup(session_factory) + platform = ActivitySlack() + renderer = activity(session_factory, platform) + await publish(renderer, "queued") + await publish(activity(session_factory, platform), "completed") + assert not platform.hourglass + + await publish(renderer, "queued", agent="Other", command="other") + + assert platform.hourglass == {"channel-demo:question"} + + async def test_a_refused_hourglass_removal_is_asked_again_when_the_turn_ends(): """A refusal is not the last word on a mark that is still there. @@ -1245,6 +1269,34 @@ async def refuse(*args, **kwargs): assert ended is False +async def test_a_claim_written_before_the_hourglass_survives_a_refused_retry( + session_factory, +): + """A retry renews a claim from before the two marks were told apart. + + Read as naming no mark, the retry is a claim in its own right and the + refusal that answers it erases the row's evidence altogether β€” so the + turn's own end finds nothing recorded, reports the cleanup done and leaves + the πŸ‘€ on the message. The older claim is the one this ask renews, and a + refusal says nothing about the reaction that claim is still describing. + """ + await setup(session_factory) + platform = ActivitySlack() + await publish(activity(session_factory, platform)) + await _unstamp_the_mark(session_factory) + + async def refuse(*args, **kwargs): + raise ActivityMarkRefused("Reactions are not allowed in this channel") + + platform.mark_activity = refuse + restarted = activity(session_factory, platform) + await publish(restarted, "running") + ended = await publish(restarted, "completed") + + assert platform.reactions == {"channel-demo:question"} + assert ended is False + + async def _unstamp_the_mark(session_factory): """Put the recorded claims back into the shape an older release wrote.""" async with session_factory() as db: From ed9de84fe9c687851567fe33d50f376b43251f64 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 21:41:49 +0100 Subject: [PATCH 049/120] test(db): name the SDK session publisher in the raw-session inventory The audit has been failing since the publisher landed. All eight of its raw factory calls run with a tenant bound, by one of two routes out of `bridge_core`: an inbound interaction or answer through `_traced`, which binds the room's tenant, and the sweep loop, created inside a `tenant_scope` opened around the `create_task` for exactly that reason. The sweep's reads are keyed on `require_tenant_id()`, so an unbound publisher would raise on its first pass rather than read nothing. Co-Authored-By: Claude Opus 5 --- .../db/test_tenant_exemption_allowlist.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/tests/switch_core/db/test_tenant_exemption_allowlist.py b/core/tests/switch_core/db/test_tenant_exemption_allowlist.py index 44b599076..fbb011fbf 100644 --- a/core/tests/switch_core/db/test_tenant_exemption_allowlist.py +++ b/core/tests/switch_core/db/test_tenant_exemption_allowlist.py @@ -110,6 +110,20 @@ "switch_core.clients.client_lifecycle_service", "switch_core.provisioning.postgres", "switch_core.room_service", + # The SDK session publisher and the cards it draws. `bridge_core` is the + # only thing that builds any of the three, by two routes and both bound: a + # button press or a typed answer arrives through `BridgeCore._traced`, + # which binds the tenant of the room the channel maps to; and the sweep + # loop is created inside a `tenant_scope(self._bridge_tenant_id)` that + # `BridgeCore.start` opens for exactly that, so the task carries the + # bridge's tenant for its whole life. Worth saying plainly, because + # `start()` itself runs under `no_tenant()` and that scope is the only + # thing standing between the sweep and an unbound context. The sweep's own + # reads say so too: they are keyed on `require_tenant_id()`, so an unbound + # publisher raises on its first pass rather than quietly reading nothing. + "switch_core.sessions.publication", + "switch_core.bridges.collaboration.session.inbound", + "switch_core.bridges.collaboration.session.outbound", # ── The exemption's own plumbing. It opens a session with nothing bound # on purpose and touches only the seven functions above, which are the one # thing a session with nothing bound may read. From 73058ab90544b2d5e7ddf2ca88be50506a2115cd Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 21:45:36 +0100 Subject: [PATCH 050/120] test(clients): take the attachment-group deadline rather than wait on it The group-timeout test shortened the timeout to 0.12s and slept 0.05s between parts, so on a machine busy enough to lose the fraction the safety net fired first and the test reported a product defect for the suite's own load. It failed that way in a full-suite run and passed in isolation. It holds `loop.call_later` instead, so both the arming and the firing are taken. That drops the wall-clock dependence entirely and says the same thing about a group whose parts arrive over any interval, including the real five seconds nothing would wait for. It also asserts what the identity check only implied: exactly one timer is armed for the group, at the default deadline. Co-Authored-By: Claude Opus 5 --- .../test_agent_client_attachment_groups.py | 74 +++++++++++-------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/core/tests/switch_core/clients/test_agent_client_attachment_groups.py b/core/tests/switch_core/clients/test_agent_client_attachment_groups.py index 4abea2ce2..82c99589a 100644 --- a/core/tests/switch_core/clients/test_agent_client_attachment_groups.py +++ b/core/tests/switch_core/clients/test_agent_client_attachment_groups.py @@ -268,43 +268,59 @@ async def test_incomplete_group_is_anchored_on_part_zero() -> None: assert client.queue.events[0].payload.message_id == "$part-0" -async def test_group_timeout_bounds_the_group_not_the_gap_between_parts() -> None: +async def test_group_timeout_bounds_the_group_not_the_gap_between_parts( + monkeypatch: Any, +) -> None: """The safety-net timer is armed once per group. A batch dribbling in just - under the timeout must not be able to hold the buffer open indefinitely.""" - original = ac.ATTACHMENT_GROUP_TIMEOUT_SECONDS - ac.ATTACHMENT_GROUP_TIMEOUT_SECONDS = 0.12 - try: - client = _fake_client() + under the timeout must not be able to hold the buffer open indefinitely. + + The deadline is taken rather than waited on. Sleeping a fraction under a + shortened timeout tests the rule only on a machine quiet enough to keep to + the fraction, and on one that is not it reports a product defect β€” the + group flushing early with fewer parts than the test arranged β€” for what is + the suite's own load. Holding the arming and firing of the timer instead + says the same thing about a group whose parts arrive over any interval at + all, including the real five seconds nothing would wait for. + """ + armed: list[tuple[float, Any]] = [] + + def call_later(delay: float, callback: Any, *args: Any) -> Any: + armed.append((delay, callback)) + return SimpleNamespace(cancel=lambda: None) + + monkeypatch.setattr(asyncio.get_running_loop(), "call_later", call_later) + client = _fake_client() + await AgentClient.on_media( + client, + _room(), + _media_event( + body="slow batch", + filename="a.png", + event_id="$part-0", + group={"id": "grp-slow", "index": 0, "total": 4}, + ), + ) + first_timer = client._attachment_group_timers["grp-slow"] + assert [delay for delay, _ in armed] == [ac.ATTACHMENT_GROUP_TIMEOUT_SECONDS] + + # Parts keep trickling in below the deadline; the timer must NOT be + # pushed back by each arrival. + for index, name in [(1, "b.md"), (2, "c.csv")]: await AgentClient.on_media( client, _room(), _media_event( - body="slow batch", - filename="a.png", - event_id="$part-0", - group={"id": "grp-slow", "index": 0, "total": 4}, + body=name, + event_id=f"$part-{index}", + group={"id": "grp-slow", "index": index, "total": 4}, ), ) - first_timer = client._attachment_group_timers["grp-slow"] + assert client._attachment_group_timers["grp-slow"] is first_timer + assert len(armed) == 1 - # Parts keep trickling in below the deadline; the timer must NOT be - # pushed back by each arrival. - for index, name in [(1, "b.md"), (2, "c.csv")]: - await asyncio.sleep(0.05) - await AgentClient.on_media( - client, - _room(), - _media_event( - body=name, - event_id=f"$part-{index}", - group={"id": "grp-slow", "index": index, "total": 4}, - ), - ) - assert client._attachment_group_timers["grp-slow"] is first_timer - - await asyncio.sleep(0.15) - finally: - ac.ATTACHMENT_GROUP_TIMEOUT_SECONDS = original + running = asyncio.all_tasks() + armed[0][1]() + await asyncio.gather(*(asyncio.all_tasks() - running)) # Fired on the group's own deadline rather than being extended forever. assert len(client.queue.events) == 1 From dcac6215b5f5d7876b792686e361b9403a457bf1 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 21:50:54 +0100 Subject: [PATCH 051/120] docs(bridges): describe the marks and the status a turn actually leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five setup pages described a status posted, edited and then taken down at the end of the turn, and a single πŸ‘€ on the message that asked. Neither is what happens: the status is kept as the record of the turn on every platform, and three of the five also mark a prompt waiting behind one already running. Per page: Slack and Mattermost and Discord gain the ⏳ and lose the deletion; Telegram says plainly that it has only the one reaction, and what its kept status carries, which is deliberately less than elsewhere; Teams loses the posts-versus-threads split that decided how to retire, there being no retirement left to decide. Discord also gains a section on the second webhook publications go through, which an admin sees in the channel's integration settings and would otherwise have to guess at. Written from the source and the tests, not from a live check: these pages were describing a deleted code path, which is worse than describing a present one unverified. Worth re-reading once the platforms have been driven by hand. docs/official/ is generated elsewhere and still carries the old wording on Telegram and Slack; that has to be fixed in the docs repository. Co-Authored-By: Claude Opus 5 --- docs/old/bridges/DISCORD_SETUP.md | 43 +++++++++++++++++++++------- docs/old/bridges/MATTERMOST_SETUP.md | 21 ++++++++------ docs/old/bridges/SLACK_SETUP.md | 14 +++++---- docs/old/bridges/TEAMS_SETUP.md | 14 +++++---- docs/old/bridges/TELEGRAM_SETUP.md | 16 +++++++++-- 5 files changed, 74 insertions(+), 34 deletions(-) diff --git a/docs/old/bridges/DISCORD_SETUP.md b/docs/old/bridges/DISCORD_SETUP.md index a3011a5ac..74afdc9c6 100644 --- a/docs/old/bridges/DISCORD_SETUP.md +++ b/docs/old/bridges/DISCORD_SETUP.md @@ -49,14 +49,15 @@ Build an OAuth2 invite URL (Developer Portal β†’ **OAuth2 β†’ URL Generator**): - **Bot permissions** (matching what the adapter does): - **View Channels** β€” see the guild's channels. - **Send Messages** + **Send Messages in Threads** β€” post agent replies. - - **Manage Webhooks** β€” mint the per-channel webhook agents post through. + - **Manage Webhooks** β€” mint the two per-channel webhooks the bridge posts + through (see [Two webhooks per channel](#two-webhooks-per-channel)). - **Manage Channels** / **Manage Roles** β€” set per-member channel permission overwrites (`channel.set_permissions`) when provisioning access, and mint the per-agent role that makes an agent's name autocomplete (see [Agent name autocomplete](#agent-name-autocomplete-agent_roles)). - **Read Message History** β€” thread-aware replies. - **Attach Files** β€” relay agent image attachments. - - **Add Reactions** β€” put πŸ‘€ on the message an agent is working on (see + - **Add Reactions** β€” mark the message an agent is working on (see [Knowing an agent is working](#knowing-an-agent-is-working)). Open the generated URL and add the bot to your server. @@ -120,14 +121,16 @@ where role management is restricted. When a Switch Console-managed agent starts on a message, two things appear: -- **πŸ‘€ on the message it is answering**, removed when its turn ends. This is the - only signal that says *which* message is being handled β€” an agent answering - two people at once marks both, and clears both together. It needs the **Add - Reactions** permission; without it the bridge logs a warning and posts no - reaction rather than a mark that is not there. -- **A "βš™οΈ Working on it…" message** posted under the agent's own name and - avatar, edited in place as the activity changes and deleted when the turn - ends. +- **A reaction on the message it is answering** β€” πŸ‘€ while the agent is working + on it, ⏳ while a prompt is waiting behind one already running. Cleared when + the turn ends. This is the only signal that says *which* message is being + handled β€” an agent answering two people at once marks both, and clears both + together. It needs the **Add Reactions** permission; without it the bridge + logs a warning and posts no reaction rather than a mark that is not there. +- **A status message** posted under the agent's own name and avatar, edited in + place as the activity changes: "Working… 41s" while the turn runs, "Worked for + 2m 14s." when it finishes. It stays in the channel after the turn rather than + being deleted, so someone scrolling back can still see that the turn ran. **What Discord cannot do here.** There is no native progress surface β€” nothing like Slack's agent card β€” so the working message is one Switch renders itself. @@ -137,6 +140,26 @@ so with two agents working it would read as one anonymous "Switch Bridge is typing". Discord's "thinking…" placeholder is interaction-only (slash commands), which does not cover an ordinary `@agent` message. +## Two webhooks per channel + +An admin looking at a channel's integration settings will see **two** Switch +webhooks, not one, and both are expected: + +- **Switch Bridge** β€” every ordinary agent message, with the agent's name and + avatar carried as a per-message override. +- **Switch Sessions** β€” session status messages and request cards, and nothing + else. + +They are split so that a status or a card can be recognised with certainty from +the channel history alone. Recognising them by sender-plus-wording would also +match an agent that happens to quote a request handle in its own reply, and +Switch would then keep editing that reply as though it were the card. Nothing +but the publisher ever posts on the second webhook, so anything found on it is +one of ours. + +Both are minted on demand the first time the bridge needs them in a channel, and +both need **Manage Webhooks**. Discord's limit is 15 webhooks per channel. + ## Slash commands Switch's in-room commands are also registered as native Discord slash commands, diff --git a/docs/old/bridges/MATTERMOST_SETUP.md b/docs/old/bridges/MATTERMOST_SETUP.md index f375bb97e..3518bc3cb 100644 --- a/docs/old/bridges/MATTERMOST_SETUP.md +++ b/docs/old/bridges/MATTERMOST_SETUP.md @@ -92,15 +92,18 @@ don't onboard Mattermost by hand β€” it's already there after setup. Log in at Three signals, in order of how long they last. Nothing here needs configuring. -- **πŸ‘€ on the message that asked.** Added when the agent picks the message up and - removed when its turn ends. Inside a thread it goes on the reply, not the root - the reply hangs off β€” the mark says *which* message is being handled. It is - added by the agent's own bot, so two agents on one message show two - reactions and hovering names them. This is the signal that always works: it - needs no thread and it does not expire. -- **A posted status line** β€” "βš™οΈ Working on it…", edited in place as the agent - reports activity and retired to "βœ“ Done Β· 2m14s" when the turn finishes. It is - edited rather than deleted because Mattermost's client leaves a +- **A reaction on the message that asked** β€” πŸ‘€ while an agent is working on it, + ⏳ while a prompt is waiting behind one already running. Cleared when the turn + ends. Inside a thread it goes on the reply, not the root the reply hangs off β€” + the mark says *which* message is being handled. It is added by the agent's own + bot, so two agents on one message show two reactions and hovering names them. + This is the signal that always works: it needs no thread and it does not + expire. +- **A posted status line**, edited in place as the agent reports activity: + "Working… 41s" while the turn runs, "Worked for 2m 14s." when it finishes. It + stays in the channel after the turn rather than being taken down, so someone + scrolling back can still see that the turn ran and how long it took. Editing + is also the only clean option: Mattermost's client leaves a "(message deleted)" placeholder behind any post removed while it is on screen. - **The typing indicator**, nudged once as the turn opens. Mattermost expires it after about five seconds, so treat it as a first flicker rather than a diff --git a/docs/old/bridges/SLACK_SETUP.md b/docs/old/bridges/SLACK_SETUP.md index bd225998d..c195c3576 100644 --- a/docs/old/bridges/SLACK_SETUP.md +++ b/docs/old/bridges/SLACK_SETUP.md @@ -191,7 +191,7 @@ Under **OAuth & Permissions β†’ Scopes β†’ Bot Token Scopes**: - `users:read` β€” resolve user display names. - `files:read`, `files:write` β€” relay attachments (incl. agent image uploads). - `reactions:read`, `reactions:write` β€” reaction-based acknowledgements, and - the πŸ‘€ that marks the message an agent is working on. + the πŸ‘€ and ⏳ that mark the message an agent is working on or holding. - `assistant:write` β€” declares the app an Agent, which is what lets it open the session its progress card lives in. Slack adds this scope itself when the Agents feature is switched on. @@ -304,9 +304,10 @@ messages at once has a card and a mark on each, and both are cleared together when its turn finishes. Separately, and needing nothing but the reaction scopes: the message that asked -is marked with **πŸ‘€** for the duration of the turn β€” the message itself, not the -thread it sits in. That works at the channel root as well as in a thread, so it -is the one progress signal that is always available. +is marked for the duration of the turn β€” **πŸ‘€** while an agent is working on it, +**⏳** while a prompt is waiting behind one already running. The message itself, +not the thread it sits in. That works at the channel root as well as in a +thread, so it is the one progress signal that is always available. The stop button is wired to the same interrupt an operator can type, so pressing it stops the agent whose turn it is. Setting `agent_sessions: false` @@ -347,8 +348,9 @@ What a workspace still gets with both off: is the autocomplete, not the addressing. - An agent's progress appears as a status message posted under its own name and icon, carrying the **Open in Switch Console** link. -- The message being worked on is marked with **πŸ‘€** for the turn. That needs - only the reaction scopes, so it works on any plan and in any channel. +- The message being worked on is marked with **πŸ‘€** for the turn, or **⏳** while + its prompt waits behind one already running. That needs only the reaction + scopes, so it works on any plan and in any channel. ### Turning it on for an existing bridge diff --git a/docs/old/bridges/TEAMS_SETUP.md b/docs/old/bridges/TEAMS_SETUP.md index acc0ea2be..f344b953f 100644 --- a/docs/old/bridges/TEAMS_SETUP.md +++ b/docs/old/bridges/TEAMS_SETUP.md @@ -1038,12 +1038,14 @@ question and reads as a non-sequitur. So: - A post an *agent* opened never displaces a real message as the one later answers land in. - **A working agent's status is posted once, stays where it is, and is edited - to `βœ“ Done` when the turn ends.** It is never deleted, because Teams replaces - a deleted message with *"This message has been deleted."* and keeps it in the - post β€” so a status that vanished each turn would leave one of those behind - every time, and another every time it moved. In a threads-layout channel a - delete is clean, so there the status disappears and follows the conversation - as it does on every other platform. + in place to `Worked for 2m 14s.` when the turn ends.** It is never deleted, in + either layout. In a posts channel a delete is not clean β€” Teams replaces the + message with *"This message has been deleted."* and keeps it in the post, so a + status that vanished each turn would leave one of those behind every time, and + another every time it moved. In a chat or a threads-layout channel a delete + *is* clean, and the status is still kept: it is the only account a reader + scrolling back can find of the turn having run, how long it took and where to + open it. Teams is not special here β€” every platform keeps it. If Switch cannot read a channel's layout β€” Graph refusing the read is the usual reason β€” it assumes posts. That is Graph's own default for a channel it diff --git a/docs/old/bridges/TELEGRAM_SETUP.md b/docs/old/bridges/TELEGRAM_SETUP.md index b633ef505..1766d1d47 100644 --- a/docs/old/bridges/TELEGRAM_SETUP.md +++ b/docs/old/bridges/TELEGRAM_SETUP.md @@ -287,6 +287,11 @@ the message it is answering, and clears the reaction when the turn ends. It needs no administrator rights, and it is the same reaction the Slack and Mattermost bridges use, so a room reads the same wherever it is bridged. +Only that one. Those bridges also show ⏳ on a message whose prompt is waiting +behind one already running; Telegram does not, because a bot may hold exactly +one reaction on a message and the mark that matters is the one saying work is +under way. + It marks the *last thing a person said* in the chat, because outside forum topics Telegram has no threads β€” only reply chains β€” so there is no thread for a status to belong to. If an agent is asked two things at once, both messages @@ -295,9 +300,14 @@ are marked and both are cleared when the turn ends. A chat can have reactions switched off. Then the mark is lost and the turn carries on; the bridge logs it rather than failing the turn. -**The "βš™οΈ Working on it…" message.** Alongside the reaction, the bridge posts a -status message and edits it in place as the agent's activity changes, removing -it when the turn ends. +**The status message.** Alongside the reaction, the bridge posts a status +message and edits it in place as the agent's activity changes: "Working… 41s" +while the turn runs, "Worked for 2m 14s." when it finishes. It stays in the chat +afterwards as the record of the turn, which is why it is kept short β€” a chat is +the conversation itself, so the finished message carries the state, the +duration, the agent's name, one Switch Console link and, where a tool call +failed or was declined, the tally saying so. There is no tool log and no +line naming the tool of the moment; Telegram declines both. Telegram has a native animated "Thinking…" placeholder β€” the one it uses for its own AI features β€” but it is **not reachable here**. It is written with From 989644a9e4bd6301d83bbcc5c922da414cb6acd3 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 21:56:10 +0100 Subject: [PATCH 052/120] Correct _claim_thread's account of the joiner race The summary line still said the mark was switched on "only if this turn is the first to want it", which stopped being true when the gate came out at d33f576b and every holder began asking. The closing argument was also exhaustive where it is not. Asking narrows the joiner window to one order rather than eliminating it: a removal that read the claims before this one was written, and whose platform call lands after this turn's add, takes the mark off a turn that has already recorded a stake in it, and the stake is what stops a redraw repairing it. Name that order and say what closing it would take. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 30fecc9ea..63a5e8db9 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -1004,8 +1004,7 @@ async def _claim_thread( self, key: tuple[str, str], anchor: _Anchor, mark: ActivityMark ) -> None: """Add this turn to the set of turns holding `mark` on - `anchor.reaction_ref`, switching it on only if this turn is the - first to want it. + `anchor.reaction_ref`, asking the platform for it as it does. Two turns can resolve to the same asking message β€” one addressed at the channel root threads under it, and another already running in @@ -1023,9 +1022,17 @@ async def _claim_thread( a stake without asking would be trusting a reading of the message taken before the stake was written down, and between those two the turn that put the mark there can end and take it off: a queued prompt left with - no hourglass, and nothing that will put one back. Asking closes that, - because a removal either sees the claim and leaves the mark alone, or - went first and this ask restores it. + no hourglass, and nothing that will put one back. + + Asking narrows that rather than closing it. A removal that reads the + claims after this one is written stands down, and one that read them + before and has already taken the mark off is undone by this ask. The + order left open is a removal that read before and lands after: it + takes off a mark this turn has already asked for and recorded, and no + redraw puts it back, because the stake it would repair is exactly what + marks this turn as needing no repair. Closing that needs the claims + and the platform call to move together across publishers, which + nothing here does. The refusals stay the joining turn's own. A platform that will not add the reaction refuses every one of them, so each retracts its own From f758158fe84784d7eb14bd054ff11b95f33dd3ad Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 22:13:14 +0100 Subject: [PATCH 053/120] docs(bridges): correct three overclaims in the setup pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord's two-webhook section said the split makes a status or a card recognisable with certainty. It does not. `find_request_card` requires the publication webhook AND the card's heading line for that handle, and returns None outright when there is no handle β€” which is every turn status, since a status prints none. Describe the card protection the split actually buys and say plainly that a status is not recovered, with the reason: a Discord webhook message carries no metadata the bridge can set, so the printed handle is the only marker available. Telegram said the mark goes on the last thing a person said, which was the old renderer. The publisher resolves `asked_on` from the turn's own origin, so it is the message that asked β€” the reply itself where a command was answered inside an existing chain. Both pages also said an agent working on two messages clears both marks together. Clearing is per message and waits for that message's last holder, so a message two prompts are queued behind keeps its mark until both end. Slack's session section needed more than a retention fix: 27bbccee deleted the whole native-session feature, and the page still documented it in the present tense alongside a live reaction paragraph, so a reader could not tell which half survived. Move the reaction and status description into their own section, reduce the session section to a past-tense record of what went, and drop the `agent_sessions` switch and the session trace note from the surrounding prose. That leaves `agent_view` and `assistant:write` advising an irreversible change β€” one that costs a workspace its guests β€” for a feature with no consumer left. Both now say so. Co-Authored-By: Claude Opus 5 --- docs/old/bridges/DISCORD_SETUP.md | 33 ++++--- docs/old/bridges/SLACK_SETUP.md | 134 ++++++++++++----------------- docs/old/bridges/TELEGRAM_SETUP.md | 10 ++- 3 files changed, 84 insertions(+), 93 deletions(-) diff --git a/docs/old/bridges/DISCORD_SETUP.md b/docs/old/bridges/DISCORD_SETUP.md index 74afdc9c6..7e78c8f43 100644 --- a/docs/old/bridges/DISCORD_SETUP.md +++ b/docs/old/bridges/DISCORD_SETUP.md @@ -122,10 +122,13 @@ where role management is restricted. When a Switch Console-managed agent starts on a message, two things appear: - **A reaction on the message it is answering** β€” πŸ‘€ while the agent is working - on it, ⏳ while a prompt is waiting behind one already running. Cleared when - the turn ends. This is the only signal that says *which* message is being - handled β€” an agent answering two people at once marks both, and clears both - together. It needs the **Add Reactions** permission; without it the bridge + on it, ⏳ while a prompt is waiting behind one already running. This is the + only signal that says *which* message is being handled β€” an agent answering + two people at once marks both. A mark comes off a message once the last turn + holding it has ended, which is not always the same moment the turn that put + it there ends: two prompts queued behind one message share its ⏳, and it + stays until both are done. It needs the **Add Reactions** permission; without + it the bridge logs a warning and posts no reaction rather than a mark that is not there. - **A status message** posted under the agent's own name and avatar, edited in place as the activity changes: "Working… 41s" while the turn runs, "Worked for @@ -150,12 +153,22 @@ webhooks, not one, and both are expected: - **Switch Sessions** β€” session status messages and request cards, and nothing else. -They are split so that a status or a card can be recognised with certainty from -the channel history alone. Recognising them by sender-plus-wording would also -match an agent that happens to quote a request handle in its own reply, and -Switch would then keep editing that reply as though it were the card. Nothing -but the publisher ever posts on the second webhook, so anything found on it is -one of ours. +They are split so that a **request card** can be found again when its fate is +unknown β€” the post timed out, or the process died between sending it and +recording its id. Two things have to hold before Switch will bind a +reservation to a message it finds, and the webhook is only the first: the +message must have come through the publication webhook, and it must carry that +card's heading line for that handle. The webhook alone rules out an agent's own +reply that happens to quote a handle β€” "I can explain the request `R7` syntax" +arrives on the webhook agents speak through, so it is never a candidate β€” and +the heading picks the right card out of the other publications beside it. + +**This does not recover a status message.** A turn's status prints no handle, +so there is nothing to match on and the lookup declines rather than guessing; +the status stays unconfirmed and is not posted a second time. That is a Discord +limitation rather than a decision: a webhook message carries no metadata this +bridge can set, so the handle a card prints is the only marker available. On a +platform that can carry one β€” Slack does β€” a status is as findable as a card. Both are minted on demand the first time the bridge needs them in a channel, and both need **Manage Webhooks**. Discord's limit is 15 webhooks per channel. diff --git a/docs/old/bridges/SLACK_SETUP.md b/docs/old/bridges/SLACK_SETUP.md index c195c3576..d2680b504 100644 --- a/docs/old/bridges/SLACK_SETUP.md +++ b/docs/old/bridges/SLACK_SETUP.md @@ -192,25 +192,31 @@ Under **OAuth & Permissions β†’ Scopes β†’ Bot Token Scopes**: - `files:read`, `files:write` β€” relay attachments (incl. agent image uploads). - `reactions:read`, `reactions:write` β€” reaction-based acknowledgements, and the πŸ‘€ and ⏳ that mark the message an agent is working on or holding. -- `assistant:write` β€” declares the app an Agent, which is what lets it open the - session its progress card lives in. Slack adds this scope itself when the - Agents feature is switched on. +- `assistant:write` β€” **not used any more.** It declared the app an Agent, which + is what let it open the native session card removed at `27bbccee`. Slack adds + this scope itself when the Agents feature is switched on; nothing in the + bridge calls it now. See [Declaring the app an + Agent](#declaring-the-app-an-agent-agent_view) before enabling that feature. - `usergroups:read`, `usergroups:write` β€” the per-agent user groups that make agent names autocomplete. See below. ### Declaring the app an Agent (`agent_view`) -The manifest's `features.agent_view` is what makes the app an **Agent**, and -only an Agent app may open the sessions the progress card is drawn in. Without -it, the calls are refused and turns fall back to a status message Switch posts -itself β€” everything still works, it just looks like a bot rather than part of -Slack. +⚠️ **Switch no longer uses this, so do not turn it on for Switch's sake.** The +manifest's `features.agent_view` makes the app an **Agent**, and the only thing +Switch ever did with that was open the native session card described under +[Native session status](#native-session-status-agent_sessions--removed), which +was removed at `27bbccee`. A turn's progress is now a status message the bridge +posts itself, and that needs no Agent declaration. -⚠️ **Two consequences, and neither can be walked back.** Enabling the Agents -feature **removes access to the app for workspace guests**, and turns every DM +It still matters that you know what the toggle does, because the manifest below +sets it and the consequences **cannot be walked back**. Enabling the Agents +feature **removes access to the app for workspace guests** and turns every DM with it into a thread. The switch from the older `assistant_view` to -`agent_view` is **irreversible**, and a distributed app needs re-review. Decide -deliberately; a workspace with external collaborators as guests loses them. +`agent_view` is **irreversible**, and a distributed app needs re-review. A +workspace with external collaborators as guests loses them β€” for no Switch +feature. Strip `features.agent_view` and the `assistant:write` scope from the +manifest before pasting it unless something other than Switch wants them. On the from-scratch path this is the **Agents** toggle in the app's settings rather than a scope you tick. @@ -271,78 +277,51 @@ keeps an agent taggable from either workspace. It follows that both workspaces must be bridged to the **same** Switch server; two servers cannot see each other's groups, and a mention that crossed between them stays unresolved. -### Native session status (`agent_sessions`) - -**On by default.** A turn opens a Slack **agent session** and streams its -progress into the client's own live card, under the agent's name and icon, -carrying the link back to the session in Switch Console. - -**The card replaces the status message Switch used to post**, rather than -sitting beside it β€” two indicators for one turn said the same thing twice. -Where a card cannot be opened, the posted message is still the fallback, so a -turn always shows its progress somewhere. - -A session exists because a **stream** is opened for it β€” setting a session's -status without one is accepted by Slack and renders nothing at all. So each -turn opens a stream, pushes a step whenever the agent's activity changes, and -closes it at the end. - -Switch does **not** set the session *status*. It renders as a second card -attributed to the app rather than the agent, with Slack's own generic wording -and no way to rename it. Slack's native stop button hangs off that status, so -it is not offered either. - -Streaming into a channel has to name the person being replied to and their -team. The person comes from the message that started the thread; the team from -the app's own identity, which on an Enterprise Grid org is **not** the -configured workspace id (that is the org). A thread Switch never saw a question -on gets no card, and falls back to the posted message. - -The card is a progress indicator, not a record: it is removed when the turn -ends, the way the posted status message always was. An agent working on two -messages at once has a card and a mark on each, and both are cleared together -when its turn finishes. - -Separately, and needing nothing but the reaction scopes: the message that asked -is marked for the duration of the turn β€” **πŸ‘€** while an agent is working on it, -**⏳** while a prompt is waiting behind one already running. The message itself, -not the thread it sits in. That works at the channel root as well as in a -thread, so it is the one progress signal that is always available. - -The stop button is wired to the same interrupt an operator can type, so -pressing it stops the agent whose turn it is. Setting `agent_sessions: false` -turns the whole thing off. - -**Sessions only work if the Slack app is declared an Agent** (the Agents -feature in the app's settings, which brings `assistant:write` with it). The -default being on only means "use this where the app has it" β€” it does not make -that change for you. Until it is made, the first call is refused, the bridge -logs one warning naming the reason, and turns carry on showing Switch's own -status messages. - -Think before enabling the Agents feature in Slack: it **removes access to the -app for workspace guests**, turns every DM with it into a thread, and **cannot -be reverted**. +### Knowing an agent is working + +Two signals, and neither needs the app to be an Agent. + +**The message that asked is marked** for the duration of the turn β€” **πŸ‘€** while +an agent is working on it, **⏳** while a prompt is waiting behind one already +running. The message itself, not the thread it sits in, so it works at the +channel root as well as inside a thread. A mark comes off once the last turn +holding it has ended: two prompts queued behind one message share its ⏳, and it +stays until both are done. This needs nothing but the reaction scopes. + +**A status message** is posted under the agent's own name and icon, carrying the +**Open in Switch Console** link, and edited in place as the activity changes: +"Working… 41s" while the turn runs, "Worked for 2m 14s." when it finishes. It +stays in the channel after the turn rather than being deleted, so someone +scrolling back can still see that the turn ran. + +### Native session status (`agent_sessions`) β€” removed + +**This feature no longer exists.** Up to `27bbccee` a turn opened a Slack +**agent session** and streamed its progress into the client's own live card, +which was removed when the turn ended. The adapter's stream handling, its stop +button, and the `agent_sessions` connection setting all went with it; the +setting is no longer accepted and `SlackConnectionConfig` no longer carries it. +What replaced it is the status message described above, which is posted by the +shared session publisher and kept rather than removed. + +Recorded here because the sections around it still refer to the app being +declared an Agent, which this was the only consumer of. ### Running without a paid Slack plan -Two of the features above lean on things a Slack workspace may not have, and -each has its own switch on the bridge connection. Both default to on. +One of the features above leans on something a Slack workspace may not have, +and has its own switch on the bridge connection. It defaults to on. - **`agent_usergroups`** needs a **paid plan** β€” user groups do not exist on the free tier β€” and an admin willing to let the bot manage them. -- **`agent_sessions`** needs the app to be declared an **Agent**. Slack - documents that some AI features require a paid plan without saying which, so - treat the plan question there as answered by trying it: a refusal names its - own cause. -**Neither has to be switched off to be safe.** A refusal is caught, reported -once with what would fix it, and the feature is dropped for the life of the -process β€” it is not retried per turn and nothing else is affected. Setting them -to `false` on a workspace that cannot host them simply skips the attempt and -the warning. +**It does not have to be switched off to be safe.** A refusal is caught, +reported once with what would fix it, and the feature is dropped for the life +of the process β€” it is not retried per turn and nothing else is affected. +Setting it to `false` on a workspace that cannot host it simply skips the +attempt and the warning. -What a workspace still gets with both off: +What a workspace still gets with it off: - Agents are addressed by typing `@agent-name`, exactly as before. What is lost is the autocomplete, not the addressing. @@ -370,9 +349,6 @@ bridge is online and relaying throughout, and agents stay addressable by typed name while their groups are still being made β€” autocomplete is what arrives late, nothing else. Watch the per-group log lines for progress. -The session's own trace lines are at debug level: enough to follow a turn end -to end when something does not render, and out of the way when it does. - ### Event subscriptions (over Socket Mode) Subscribe the **bot** to (no request URL is needed with Socket Mode): diff --git a/docs/old/bridges/TELEGRAM_SETUP.md b/docs/old/bridges/TELEGRAM_SETUP.md index 1766d1d47..7bb5b4b0e 100644 --- a/docs/old/bridges/TELEGRAM_SETUP.md +++ b/docs/old/bridges/TELEGRAM_SETUP.md @@ -292,10 +292,12 @@ behind one already running; Telegram does not, because a bot may hold exactly one reaction on a message and the mark that matters is the one saying work is under way. -It marks the *last thing a person said* in the chat, because outside forum -topics Telegram has no threads β€” only reply chains β€” so there is no thread for -a status to belong to. If an agent is asked two things at once, both messages -are marked and both are cleared when the turn ends. +It marks **the message that asked** β€” the one that started the turn, not +whatever was said most recently. Where a command was answered inside an +existing reply chain, that is the reply itself rather than the message the +chain started from. If an agent is asked two things at once, both of those +messages are marked. A mark comes off once the last turn holding it has ended, +so a message two prompts are waiting on keeps its mark until both are done. A chat can have reactions switched off. Then the mark is lost and the turn carries on; the bridge logs it rather than failing the turn. From a2562cedd7eb314ad44dd39f3720a0f83dbeaa22 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 22:24:12 +0100 Subject: [PATCH 054/120] Remove the Mattermost legacy runtime renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag that gated it has been False since the SDK publication took over drawing the turn, so none of this could run. Gone: the posted "working on it…" status and its edit-to-done retirement, the operator-ping resolution, the trigger map that traced a threaded turn back to the reply that asked, and the πŸ‘€ bookkeeping layered on top of the reaction call. What the SDK path uses stays: mark_activity into _react_or_raise, the typing nudge, bot and attachment I/O. renders_legacy_runtime_state stays False because the base class still defaults it on for the platforms that have not migrated. format_elapsed is shared and still called by Teams, so its only test moves to a file of its own rather than going with the Mattermost suite around it. Co-Authored-By: Claude Opus 5 --- .../collaboration/mattermost/adapter.py | 238 +------------ .../collaboration/test_adapter_helpers.py | 21 ++ .../test_mattermost_runtime_state.py | 329 ------------------ .../collaboration/test_mattermost_sdk_only.py | 15 - .../test_mattermost_working_reaction.py | 259 -------------- 5 files changed, 25 insertions(+), 837 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_adapter_helpers.py delete mode 100644 core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py delete mode 100644 core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 6f3867871..939e30c14 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -31,13 +31,11 @@ from switch_core.bridges.collaboration.adapter import ( ActivityMark, CollaborationAdapter, - LiveRuntimeIndicator, RequestCard, RichContent, RichContentFailed, RichContentThrottled, TurnActivity, - format_elapsed, ) from switch_core.bridges.collaboration.models import ( Attachment, @@ -217,12 +215,10 @@ class MattermostAdapter(CollaborationAdapter): #: on its own. activity_reactions_per_agent: ClassVar[bool] = True - #: Off, because the SDK publication now draws the status. Leaving it on - #: would put two competing accounts of the same turn in the channel: the - #: legacy "working on it…" post edited to "βœ“ Done" beside the activity - #: message saying the same thing in more detail. The legacy renderer stays - #: in the file β€” it is what every unmigrated platform still uses β€” and this - #: flag is what stops it publishing here. + #: Off, because the SDK publication draws the status. This adapter has no + #: legacy renderer left to run, but the base class defaults the flag on for + #: the platforms that still do, so saying so here is what keeps the base + #: class's own fallback from drawing a second account of the turn. renders_legacy_runtime_state: ClassVar[bool] = False #: Mattermost renders a thread inline under its root as well as in the @@ -267,25 +263,11 @@ def __init__(self, *, config: MattermostConnectionConfig) -> None: self._seen_post_ids_max = 1000 self._seen_lock = threading.Lock() - # (channel_id, thread root post id) -> the post that actually asked. - # Inside a thread that is the reply, not the root the reply hangs off. - # Bounded like _seen_post_ids: it grows with inbound traffic and only - # the recent entries can still be the subject of a live turn. - self._thread_trigger: OrderedDict[tuple[str, str], str] = OrderedDict() - self._thread_trigger_max = 1000 - self._thread_trigger_lock = threading.Lock() - # (agent_name, post_id) currently carrying the working reaction. A # reaction belongs to the bot that added it, so two agents on the same # post are two independent marks. self._marked: set[tuple[str, str, ActivityMark]] = set() - # (channel_id, agent_name) -> the posts that agent has marked. An agent - # asked two things at once works on both, and the turn ends once β€” so - # the marks are cleared together rather than only on the last thread - # touched. - self._agent_eyes: dict[tuple[str, str], set[str]] = {} - # Mattermost user id -> username, because a mention is written with the # handle and Switch stores the id. Stable for the life of a user, so a # hit here saves a round trip on every redraw that carries a mention. @@ -1053,172 +1035,8 @@ async def _post_typing( except Exception as e: logger.debug("Failed to send MM typing for %s: %s", sender_name, e) - # ── Runtime state ────────────────────────────────────────────────────────── - - async def _apply_runtime_state( - self, - channel_id: str, - agent_name: str, - state: str, - *, - mention_handle: str | None, - thread_root_id: str | None, - deeplink_url: str | None = None, - detail: str | None = None, - trigger_thread_root_id: str | None = None, - anchor_message_ref: str | None = None, - ) -> None: - """Surface runtime state as a posted message that is **never deleted**. - - Mattermost's web client replaces any message deleted while it is on - screen with a "(message deleted)" placeholder, and only drops that on - reload. It does so however the message was removed β€” a permanent delete - looks the same to it as a soft one β€” so a status line that appears and - vanishes each turn leaves a trail of placeholders behind it. There is no - server setting that turns this off. The only way not to provoke it is - not to delete: every status message here is retired by editing it in - place. - - - ``working`` β†’ post "working on it…" as the agent (in-thread when the - trigger was threaded); it stays up across intermediate replies and - through ``awaiting-input``. - - ``idle`` (where ``completed`` collapses) β†’ edit the working message - into a "done" marker, and resolve any pings the same way. - - ``awaiting-input`` β†’ leave the working message up; post a separate - operator ping (tracked for resolution when the turn ends). - - The message that triggered the turn is marked with πŸ‘€ throughout, and - unmarked when it ends β€” see ``_track_eyes``. - """ - await self._track_eyes(channel_id, agent_name, state, thread_root_id) - - key = (channel_id, agent_name) - if state == "working": - # Resuming work means the requested input was provided β€” remove the - # now-resolved pings, then ensure the working indicator is up. - await self._clear_input_pings(channel_id, agent_name) - body = self._working_body(detail, deeplink_url) - existing = self._working_msg.get(key) - if existing is not None: - # Refresh the live message in place with the latest activity. - await self._patch_post_as(agent_name, existing.message_ref, body) - self._working_msg[key] = replace(existing, body=body) - return - ref = await self.send_message(channel_id, agent_name, body, thread_root_id) - if ref is not None: - self._working_msg[key] = LiveRuntimeIndicator( - message_ref=ref, - body=body, - thread_root_id=thread_root_id, - started_at=time.monotonic(), - ) - # Where the message came from, not where the status went. The - # status is pinned to the thread the answer will land in, but - # typing is for whoever is waiting β€” and someone who wrote at - # the channel root is watching the root, not a thread they have - # not opened. - # - # Only as the turn opens. Mattermost expires a typing indicator - # after a few seconds, and the posted message is what carries - # the state from there on β€” repeating it on every activity - # refresh would say "typing" for as long as the agent runs. - await self._post_typing(channel_id, agent_name, trigger_thread_root_id) - elif state == "awaiting-input": - ref = await self._ping_operator( - channel_id, - agent_name, - mention_handle, - thread_root_id, - deeplink_url, - detail, - ) - if ref is not None: - self._input_pings.setdefault(key, []).append(ref) - else: - await self._dispose_working(channel_id, agent_name) - await self._clear_input_pings(channel_id, agent_name) - - async def _clear_input_pings(self, channel_id: str, agent_name: str) -> None: - """Resolve the tracked operator pings when the turn ends. - - Edited rather than removed, for the same reason as the working message: - a delete would leave a placeholder in every client that had the ping on - screen β€” which, for a ping, is precisely the people it was aimed at.""" - for post_id in self._input_pings.pop((channel_id, agent_name), []): - await self._patch_post_as( - agent_name, post_id, self.translate_outbound("βœ“ Input received") - ) - # ── The eyes on the message being worked on ────────────────────────────── - def _remember_trigger(self, channel_id: str, root_id: str, post_id: str) -> None: - """Record the latest post in a thread, so the eyes land on what asked. - - Written from the websocket thread and read from the main loop, hence - the lock. Oldest entries are dropped past the cap: a thread nobody has - written in for a thousand messages is not the subject of a live turn, - and losing the entry only puts the mark on the thread root. - """ - with self._thread_trigger_lock: - self._thread_trigger[(channel_id, root_id)] = post_id - self._thread_trigger.move_to_end((channel_id, root_id)) - while len(self._thread_trigger) > self._thread_trigger_max: - self._thread_trigger.popitem(last=False) - - async def _track_eyes( - self, - channel_id: str, - agent_name: str, - state: str, - thread_root_id: str | None, - ) -> None: - """Mark every message this agent is working on, and clear them together. - - ``thread_root_id`` is where the answer will land: the thread the agent - was addressed in, or β€” since this adapter follows the anchor β€” the - message itself when it was addressed at the channel root. The mark - belongs on what was actually said, so a threaded turn is traced back - through ``_thread_trigger`` to the reply that asked rather than being - put on the root it hangs off. - """ - akey = (channel_id, agent_name) - - if state in ("working", "awaiting-input"): - if thread_root_id is None: - return - asked_on = self._thread_trigger.get( - (channel_id, thread_root_id), thread_root_id - ) - self._agent_eyes.setdefault(akey, set()).add(asked_on) - await self._mark_being_read(agent_name, asked_on, working=True) - return - - for post_id in sorted(self._agent_eyes.pop(akey, set())): - await self._mark_being_read(agent_name, post_id, working=False) - - async def _mark_being_read( - self, agent_name: str, post_id: str, *, working: bool, force: bool = False - ) -> None: - """Best-effort πŸ‘€ for the legacy runtime path, which cannot act on failure. - - Nothing on that path retries and nothing records what it did, so a - failure here is cosmetic and is logged rather than raised. The SDK seam - goes through `_react_or_raise`: its publisher writes a completion - receipt on the strength of what it is told, and a swallowed failure - there leaves πŸ‘€ on a finished turn for good. - """ - try: - await self._react_or_raise( - agent_name, post_id, mark="working", on=working, force=force - ) - except Exception as e: - logger.warning( - "Could not %s the working reaction on %s: %s", - "add" if working else "remove", - post_id, - e, - ) - async def _react_or_raise( self, agent_name: str, @@ -1284,53 +1102,6 @@ async def _react_or_raise( pass self._marked.discard(key) - async def _reposition_runtime_state( - self, channel_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - """Leave the indicator where it was first posted. - - Moving it means removing it from where it is, and any removal shows as - "(message deleted)" to everyone currently looking at the channel β€” once - per move, so an active conversation accumulates them fastest. The - indicator is pinned to the point the turn began instead: less precise - about where the agent is up to, but it costs the reader nothing. - """ - return - - async def _dispose_working(self, channel_id: str, agent_name: str) -> None: - """Retire the live "working on it…" message when the turn ends. - - Edited into a terminal marker rather than removed β€” see - ``_apply_runtime_state`` for why nothing here is ever deleted. Kept to - the bare fact that the turn finished and how long it took: this line - stays in the channel for good, so it earns its place by being small. - The session link belongs on the live indicator, where it is still - actionable, not on the record of a turn that is over.""" - live = self._working_msg.pop((channel_id, agent_name), None) - if live is None: - return - elapsed = format_elapsed(time.monotonic() - live.started_at) - await self._patch_post_as( - agent_name, - live.message_ref, - self.translate_outbound(f"βœ“ Done Β· {elapsed}"), - ) - - async def _patch_post_as(self, agent_name: str, post_id: str, content: str) -> None: - driver = self._bot_drivers.get(agent_name) or self._admin_driver - loop = self._main_loop - if driver is None or loop is None: - logger.error("Cannot edit runtime-state post: Mattermost not connected") - return - try: - await loop.run_in_executor( - None, driver.posts.patch_post, post_id, {"message": content} - ) - except Exception as e: - logger.error( - "Failed to edit Mattermost runtime-state post %s: %s", post_id, e - ) - # ── Channel creation ────────────────────────────────────────────────────── async def create_channel( @@ -1987,7 +1758,6 @@ async def _ws_handler(self, event_data: str, agent_name: str) -> None: message = post.get("message", "") # Mattermost sets root_id to the thread root for replies, "" otherwise. root_id = post.get("root_id", "") or None - self._remember_trigger(channel_id, root_id or post_id, post_id) mm_channel_type = data.get("channel_type", "") channel_name = str(data.get("channel_display_name", "")) or None diff --git a/core/tests/switch_core/bridges/collaboration/test_adapter_helpers.py b/core/tests/switch_core/bridges/collaboration/test_adapter_helpers.py new file mode 100644 index 000000000..0e7989526 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_adapter_helpers.py @@ -0,0 +1,21 @@ +"""Shared helpers on the collaboration adapter base class. + +These have no platform of their own, so they are tested here rather than in +whichever adapter happened to call them first. +""" + +from __future__ import annotations + +from switch_core.bridges.collaboration.adapter import format_elapsed + + +def test_elapsed_is_written_the_way_it_is_read() -> None: + assert format_elapsed(0.4) == "0s" + assert format_elapsed(8.9) == "8s" + assert format_elapsed(59) == "59s" + assert format_elapsed(60) == "1m00s" + assert format_elapsed(134) == "2m14s" + assert format_elapsed(3600) == "1h00m" + assert format_elapsed(3780) == "1h03m" + # A clock that ran backwards is not a negative duration. + assert format_elapsed(-5) == "0s" diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py deleted file mode 100644 index 35db6256e..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Mattermost's runtime indicator must never delete a post. - -Mattermost's web client swaps any message deleted while it is on screen for a -"(message deleted)" placeholder, and only drops it on reload β€” whether the -delete was permanent or not. A status line that appears and vanishes every turn -therefore leaves a trail of placeholders behind it, one per removal. - -So the rule these tests hold the adapter to is blunt: nothing it posts is ever -deleted. The status line is edited into a terminal marker at the end of a turn, -and it does not move while the turn runs. - -These drive `_apply_runtime_state` rather than the public entry point because -the public one no longer reaches it: Mattermost now publishes SDK sessions and -declares `renders_legacy_runtime_state = False`, so the base class stops the -legacy path before the adapter sees it. The implementation is still here and -still correct; what it no longer has is a caller. Removing it is its own task β€” -until then these keep it honest, and -`test_mattermost_sdk_only.py` covers the disabled ingress itself. -""" - -from __future__ import annotations - -import asyncio -import time -from typing import Any - -from switch_core.bridges.collaboration.adapter import ( - LiveRuntimeIndicator, - format_elapsed, -) -from switch_core.bridges.collaboration.mattermost.adapter import ( - MattermostAdapter, - MattermostConnectionConfig, -) - - -def _adapter() -> MattermostAdapter: - return MattermostAdapter( - config=MattermostConnectionConfig( - url="http://mm", - admin_user="admin", - admin_password="pw", - team_name="team", - ) - ) - - -def _run(coro: Any) -> Any: - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -class _Recorder: - """Records the adapter's platform calls, deletes included. - - ``deletes`` exists to be asserted empty: it is the failure this whole - approach is designed around, so a regression that reintroduces a delete - should fail a test rather than merely change one. - """ - - def __init__(self) -> None: - self.deletes: list[str] = [] - self.patches: list[tuple[str, str]] = [] - self.sends: list[tuple[str, str, str, str | None]] = [] - self.typing: list[tuple[str, str, str | None]] = [] - self._next_id = iter(f"post-{n}" for n in range(2, 20)) - - def install(self, adapter: MattermostAdapter) -> None: - async def delete_message(channel_id: str, message_ref: str) -> None: - self.deletes.append(message_ref) - - async def patch_post_as(agent_name: str, post_id: str, content: str) -> None: - self.patches.append((post_id, content)) - - async def send_message( - channel_id: str, - sender_name: str, - content: str, - thread_root_id: str | None = None, - ) -> str | None: - self.sends.append((channel_id, sender_name, content, thread_root_id)) - return next(self._next_id) - - async def post_typing( - channel_id: str, sender_name: str, thread_root_id: str | None - ) -> None: - self.typing.append((channel_id, sender_name, thread_root_id)) - - adapter.delete_message = delete_message # type: ignore[method-assign] - adapter._patch_post_as = patch_post_as # type: ignore[method-assign] - adapter.send_message = send_message # type: ignore[method-assign] - adapter._post_typing = post_typing # type: ignore[method-assign] - - -def _seed_indicator( - adapter: MattermostAdapter, - *, - thread_root_id: str | None = None, - age_seconds: float = 0.0, -) -> None: - adapter._working_msg[("chan-1", "worker")] = LiveRuntimeIndicator( - message_ref="post-1", - body="βš™οΈ _Working on it…_", - thread_root_id=thread_root_id, - started_at=time.monotonic() - age_seconds, - ) - - -def test_the_indicator_stays_put_instead_of_following_the_conversation() -> None: - # Moving it means deleting it from where it was, and every delete is a - # placeholder in every client watching. Pinned costs the reader nothing. - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - _seed_indicator(adapter, thread_root_id="root-9") - - _run(adapter._reposition_runtime_state("chan-1", "worker", "root-42")) - - assert recorder.deletes == [] - assert recorder.sends == [] - live = adapter._working_msg[("chan-1", "worker")] - assert live.message_ref == "post-1" - assert live.thread_root_id == "root-9" - - -def test_a_finished_turn_retires_the_indicator_in_place() -> None: - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - _seed_indicator(adapter, age_seconds=134) - - _run( - adapter._apply_runtime_state( - "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None - ) - ) - - assert recorder.deletes == [] - assert recorder.patches == [("post-1", "βœ“ Done Β· 2m14s")] - assert ("chan-1", "worker") not in adapter._working_msg - - -def test_the_done_marker_carries_no_session_link() -> None: - # The marker is permanent, so it stays minimal. A link into the session is - # worth having while the agent is working, not on the record of a finished - # turn β€” and it is one more thing to read on a line nobody asked to keep. - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - _seed_indicator(adapter, age_seconds=8) - - _run( - adapter._apply_runtime_state( - "chan-1", - "worker", - "idle", - mention_handle=None, - thread_root_id=None, - deeplink_url="https://switch.example/session/1", - ) - ) - - assert recorder.patches == [("post-1", "βœ“ Done Β· 8s")] - - -def test_an_operator_ping_is_resolved_rather_than_removed() -> None: - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - adapter._input_pings[("chan-1", "worker")] = ["ping-1", "ping-2"] - - _run( - adapter._apply_runtime_state( - "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None - ) - ) - - assert recorder.deletes == [] - assert recorder.patches == [ - ("ping-1", "βœ“ Input received"), - ("ping-2", "βœ“ Input received"), - ] - assert ("chan-1", "worker") not in adapter._input_pings - - -def test_a_turn_posts_one_status_line_and_deletes_nothing() -> None: - # The end-to-end shape: one post at the start, edited in place as the work - # changes, edited once more when it finishes. Never more than one line, and - # never a delete. - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - - async def turn() -> None: - for detail in ("Ran tool Bash", "Ran tool Edit", None): - await adapter._apply_runtime_state( - "chan-1", - "worker", - "working", - mention_handle=None, - thread_root_id=None, - detail=detail, - ) - await adapter._apply_runtime_state( - "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None - ) - - _run(turn()) - - assert recorder.deletes == [] - assert len(recorder.sends) == 1 - assert [content for _, content in recorder.patches] == [ - "βš™οΈ Ran tool Edit", - "βš™οΈ _Working on it…_", - "βœ“ Done Β· 0s", - ] - - -def test_idle_without_an_indicator_does_nothing() -> None: - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - - _run( - adapter._apply_runtime_state( - "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None - ) - ) - - assert recorder.patches == [] - assert recorder.deletes == [] - - -def test_the_turn_opens_with_a_typing_nudge_where_the_message_came_from() -> None: - # Addressed inside a thread: the reader is watching that thread, so the - # nudge goes there. Without a parent Mattermost shows it at the root. - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - - _run( - adapter._apply_runtime_state( - "chan-1", - "worker", - "working", - mention_handle=None, - thread_root_id="root-9", - trigger_thread_root_id="root-9", - ) - ) - - assert recorder.typing == [("chan-1", "worker", "root-9")] - - -def test_typing_stays_at_the_root_when_that_is_where_the_message_was() -> None: - # The status is pinned into the thread the answer will open, but whoever - # wrote at channel level is watching the channel β€” a typing indicator - # inside a thread they have not opened is one they never see. - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - - _run( - adapter._apply_runtime_state( - "chan-1", - "worker", - "working", - mention_handle=None, - thread_root_id="post-trigger", - trigger_thread_root_id=None, - ) - ) - - assert recorder.sends[0][3] == "post-trigger" - assert recorder.typing == [("chan-1", "worker", None)] - - -def test_typing_is_not_repeated_on_every_activity_refresh() -> None: - # Mattermost expires the indicator after a few seconds, so replaying it - # would claim the agent is typing for as long as the turn runs. The posted - # status carries the state from there on. - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - - async def turn() -> None: - for detail in (None, "Ran tool Edit", "Running tests"): - await adapter._apply_runtime_state( - "chan-1", - "worker", - "working", - mention_handle=None, - thread_root_id=None, - detail=detail, - ) - - _run(turn()) - - assert len(recorder.typing) == 1 - - -def test_a_retired_turn_does_not_nudge() -> None: - adapter = _adapter() - recorder = _Recorder() - recorder.install(adapter) - _seed_indicator(adapter) - - _run( - adapter._apply_runtime_state( - "chan-1", "worker", "idle", mention_handle=None, thread_root_id=None - ) - ) - - assert recorder.typing == [] - - -def test_elapsed_is_written_the_way_it_is_read() -> None: - assert format_elapsed(0.4) == "0s" - assert format_elapsed(8.9) == "8s" - assert format_elapsed(59) == "59s" - assert format_elapsed(60) == "1m00s" - assert format_elapsed(134) == "2m14s" - assert format_elapsed(3600) == "1h00m" - assert format_elapsed(3780) == "1h03m" - # A clock that ran backwards is not a negative duration. - assert format_elapsed(-5) == "0s" diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index 1f8b0d6ad..21729f168 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -613,21 +613,6 @@ async def test_clearing_a_mark_mattermost_says_is_gone_is_not_a_failure() -> Non ] -async def test_the_legacy_path_still_logs_a_failed_mark_rather_than_raising( - caplog: pytest.LogCaptureFixture, -) -> None: - """Nothing on that path retries or records what it did, so raising there - would lose a message over a cosmetic reaction.""" - adapter = _adapter() - driver: Any = adapter._bot_drivers["worker"] - driver.reactions.create_error = ConnectionError("temporary network failure") - - with caplog.at_level(logging.WARNING): - await adapter._track_eyes("chan-1", "worker", "working", "root-1") - - assert caplog.records - - async def test_a_mark_left_over_from_before_a_restart_is_still_cleared() -> None: """After a restart the in-process record is empty, but the πŸ‘€ is still in the channel. Without `force` the removal is skipped as already done.""" diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py deleted file mode 100644 index cc669bfe3..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py +++ /dev/null @@ -1,259 +0,0 @@ -"""The πŸ‘€ Mattermost puts on the message an agent is working on. - -Mattermost has no equivalent of Slack's native progress card, and its typing -indicator expires after a few seconds. The reaction is therefore the one -progress signal here that is both immediate and durable β€” and unlike the status -post, it says *which* message was picked up. - -It is added by the agent's own bot rather than a shared bridge account, so two -agents working on one message show as two marks. - -The two cases here that come in through `_apply_runtime_state` do so directly: -the legacy path that used to call it is disabled now that Mattermost publishes -SDK sessions, and the mark arrives through `mark_activity` instead. Both routes -end in `_mark_being_read`, which is what these are really about. -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -import pytest - -from switch_core.bridges.collaboration.mattermost.adapter import ( - MattermostAdapter, - MattermostConnectionConfig, -) - - -class _FakeReactions: - def __init__(self) -> None: - self.calls: list[tuple[str, str, str, str]] = [] - self.error: Exception | None = None - - def create_reaction(self, options: dict[str, str]) -> dict[str, str]: - if self.error: - raise self.error - self.calls.append( - ("add", options["user_id"], options["post_id"], options["emoji_name"]) - ) - return options - - def delete_reaction( - self, user_id: str, post_id: str, emoji_name: str - ) -> dict[str, str]: - if self.error: - raise self.error - self.calls.append(("remove", user_id, post_id, emoji_name)) - return {"status": "OK"} - - -class _FakeDriver: - def __init__(self) -> None: - self.reactions = _FakeReactions() - - -def _adapter(*agents: str) -> MattermostAdapter: - adapter = MattermostAdapter( - config=MattermostConnectionConfig( - url="http://mm", - admin_user="admin", - admin_password="pw", - team_name="team", - ) - ) - for name in agents or ("worker",): - adapter._agent_bots[name] = {"user_id": f"bot-{name}"} - adapter._bot_drivers[name] = _FakeDriver() # type: ignore[assignment] - - async def send_message( - channel_id: str, - sender_name: str, - content: str, - thread_root_id: str | None = None, - ) -> str | None: - return "status-post" - - async def patch_post_as(agent_name: str, post_id: str, content: str) -> None: - return None - - async def post_typing( - channel_id: str, sender_name: str, thread_root_id: str | None - ) -> None: - return None - - adapter.send_message = send_message # type: ignore[method-assign] - adapter._patch_post_as = patch_post_as # type: ignore[method-assign] - adapter._post_typing = post_typing # type: ignore[method-assign] - return adapter - - -def _reactions( - adapter: MattermostAdapter, agent: str = "worker" -) -> list[tuple[str, ...]]: - driver: Any = adapter._bot_drivers[agent] - return driver.reactions.calls - - -def _run(adapter: MattermostAdapter, *states: tuple[str, str | None]) -> None: - """Drive the adapter through runtime states with a live main loop set.""" - - async def _body() -> None: - adapter._main_loop = asyncio.get_running_loop() - for state, thread_root_id in states: - await adapter._apply_runtime_state( - "chan-1", - "worker", - state, - mention_handle=None, - thread_root_id=thread_root_id, - ) - - asyncio.run(_body()) - - -def test_the_message_being_worked_on_gets_the_eyes() -> None: - adapter = _adapter() - - _run(adapter, ("working", "post-1")) - - assert _reactions(adapter) == [("add", "bot-worker", "post-1", "eyes")] - - -def test_the_eyes_come_off_when_the_turn_ends() -> None: - adapter = _adapter() - - _run(adapter, ("working", "post-1"), ("idle", None)) - - assert _reactions(adapter) == [ - ("add", "bot-worker", "post-1", "eyes"), - ("remove", "bot-worker", "post-1", "eyes"), - ] - - -def test_the_eyes_are_added_once_for_a_turn() -> None: - # The activity refresh repeats `working` for as long as the agent runs. - adapter = _adapter() - - _run(adapter, ("working", "post-1"), ("working", "post-1"), ("working", "post-1")) - - assert _reactions(adapter) == [("add", "bot-worker", "post-1", "eyes")] - - -def test_the_eyes_stay_on_while_the_agent_waits_for_input() -> None: - # awaiting-input is mid-turn, just paused β€” the message is still being - # worked on, so the mark stays. - adapter = _adapter() - - _run(adapter, ("working", "post-1"), ("awaiting-input", "post-1")) - - assert _reactions(adapter) == [("add", "bot-worker", "post-1", "eyes")] - - -def test_the_eyes_go_on_the_message_that_asked_not_the_thread_root() -> None: - # Inside a thread the status is anchored to the root, but what was said is - # the reply β€” and the reply is what the reader is waiting on. - adapter = _adapter() - adapter._remember_trigger("chan-1", "root-1", "reply-9") - - _run(adapter, ("working", "root-1")) - - assert _reactions(adapter) == [("add", "bot-worker", "reply-9", "eyes")] - - -def test_the_eyes_come_off_the_message_that_asked() -> None: - adapter = _adapter() - adapter._remember_trigger("chan-1", "root-1", "reply-9") - - _run(adapter, ("working", "root-1"), ("idle", None)) - - assert ("remove", "bot-worker", "reply-9", "eyes") in _reactions(adapter) - assert not any(call[2] == "root-1" for call in _reactions(adapter)) - - -def test_a_message_at_the_channel_root_is_marked_on_itself() -> None: - # This adapter follows the anchor, so a root-level trigger arrives as its - # own post id and no trigger mapping is needed. - adapter = _adapter() - - _run(adapter, ("working", "post-42")) - - assert _reactions(adapter) == [("add", "bot-worker", "post-42", "eyes")] - - -def test_every_marked_message_is_cleared_when_the_turn_ends() -> None: - # An agent asked two things at once works on both, but the turn ends once. - # Clearing only the last thread would leave the first marked for good. - adapter = _adapter() - - _run(adapter, ("working", "post-1"), ("working", "post-2"), ("idle", None)) - - removed = {call[2] for call in _reactions(adapter) if call[0] == "remove"} - assert removed == {"post-1", "post-2"} - - -def test_two_agents_on_one_message_each_leave_their_own_mark() -> None: - adapter = _adapter("worker", "reviewer") - - async def _body() -> None: - adapter._main_loop = asyncio.get_running_loop() - for agent in ("worker", "reviewer"): - await adapter._apply_runtime_state( - "chan-1", - agent, - "working", - mention_handle=None, - thread_root_id="post-1", - ) - - asyncio.run(_body()) - - assert _reactions(adapter, "worker") == [("add", "bot-worker", "post-1", "eyes")] - assert _reactions(adapter, "reviewer") == [ - ("add", "bot-reviewer", "post-1", "eyes") - ] - - -def test_a_failed_reaction_does_not_break_the_turn( - caplog: pytest.LogCaptureFixture, -) -> None: - # The post may have been deleted, or the bot removed from the channel. The - # status message is what actually carries the state. - adapter = _adapter() - driver: Any = adapter._bot_drivers["worker"] - driver.reactions.error = RuntimeError("403 forbidden") - - with caplog.at_level(logging.WARNING): - _run(adapter, ("working", "post-1")) - - assert ("worker", "post-1", "working") not in adapter._marked - assert any("working reaction" in r.getMessage() for r in caplog.records) - - -def test_an_agent_with_no_connected_bot_is_reported_not_ignored( - caplog: pytest.LogCaptureFixture, -) -> None: - adapter = _adapter() - adapter._bot_drivers.clear() - adapter._agent_bots.clear() - - with caplog.at_level(logging.WARNING): - _run(adapter, ("working", "post-1")) - - assert any("no connected bot" in r.getMessage() for r in caplog.records) - - -def test_the_trigger_map_does_not_grow_without_bound() -> None: - adapter = _adapter() - adapter._thread_trigger_max = 3 - - for n in range(6): - adapter._remember_trigger("chan-1", f"root-{n}", f"post-{n}") - - assert list(adapter._thread_trigger) == [ - ("chan-1", "root-3"), - ("chan-1", "root-4"), - ("chan-1", "root-5"), - ] From 2cf4fcc36e93be02ecdfd3397bf7cd8dc4e93243 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 22:30:42 +0100 Subject: [PATCH 055/120] Remove the Discord legacy runtime renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unreachable since the SDK publication took over drawing the turn: the base class stops the legacy path on renders_legacy_runtime_state, which this adapter has declared False throughout. Gone: the posted status message and its deletion, the operator pings and their cleanup, the per-agent map of marked messages, and the best-effort reaction wrapper layered over _react. What the SDK path uses stays: mark_activity into _react, the typing nudge, webhook identity and ordinary delivery. Three properties of _react had coverage only through the legacy wrapper β€” a deleted message is not a failed removal, a refused mark is not recorded as present, and a message in a thread is marked in the thread. Those move to the SDK entry point rather than going with the suite around them. The unlinked-owner ping test now drives the shared _ping_operator directly, which other platforms still reach. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 160 +---------- .../collaboration/test_discord_adapter.py | 171 ++---------- .../collaboration/test_discord_sdk_only.py | 78 ++++-- .../test_discord_working_reaction.py | 254 ------------------ 4 files changed, 79 insertions(+), 584 deletions(-) delete mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_working_reaction.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 18a2fe502..71b5e310a 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -5,7 +5,6 @@ import io import logging import re -import time from collections import OrderedDict from collections.abc import Awaitable, Callable, Coroutine from dataclasses import replace @@ -22,7 +21,6 @@ ActivityMark, ActivityMarkRefused, CollaborationAdapter, - LiveRuntimeIndicator, RequestCard, RichContent, RichContentFailed, @@ -294,8 +292,10 @@ class DiscordAdapter(CollaborationAdapter): # them: the first turn to want it adds it and the last to finish removes it. activity_reactions_per_agent: ClassVar[bool] = False - # Both paths would draw the same turn. The legacy renderer below is - # retained, not reachable β€” removing it is its own task. + # Off, because the SDK publication draws the status. This adapter has no + # legacy renderer left to run, but the base class defaults the flag on for + # the platforms that still do, so saying so here is what keeps the base + # class's own fallback from drawing a second account of the turn. renders_legacy_runtime_state: ClassVar[bool] = False # `find_request_card` reads a channel's history back and matches a card by @@ -327,14 +327,10 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: # Discord user id ↔ username caches, for mention translation both ways. self._user_names: dict[int, str] = {} self._username_to_id: dict[str, int] = {} - # Webhook messages delete cleanly on Discord, so runtime state renders - # as a persistent message (see the base class's _working_msg) rather - # than the one-shot typing indicator. - # Message refs currently carrying the "being worked on" reaction, and - # per agent the set it has marked β€” a turn ends once but may have - # marked several messages. + # Message refs currently carrying an activity reaction. One bot serves + # every agent here, so a mark belongs to the application rather than to + # whichever agent asked for it. self._marked: set[tuple[str, ActivityMark]] = set() - self._agent_eyes: dict[tuple[str, str], set[str]] = {} # Set once Discord has told us it will not host agent roles, so the # bridge stops asking and says so only once. self._agent_roles_off_reason: str | None = None @@ -1478,138 +1474,6 @@ def _thread_channel_id(thread_root_ref: str) -> int | None: except ValueError: return None - # ── Runtime state ──────────────────────────────────────────────────────── - - async def _apply_runtime_state( - self, - channel_id: str, - agent_name: str, - state: str, - *, - mention_handle: str | None, - thread_root_id: str | None, - deeplink_url: str | None = None, - detail: str | None = None, - trigger_thread_root_id: str | None = None, - anchor_message_ref: str | None = None, - ) -> None: - """Render runtime state as persistent, truly-deletable status messages. - - Discord deletes webhook messages cleanly (no tombstone), so β€” like - Slack β€” the "working on it…" indicator and any "needs your input" - pings are posted while relevant and deleted when the turn ends. The - working indicator stays up through `awaiting-input` (the agent is - mid-turn, just paused) and the pings are removed alongside it when - the turn goes idle or resumes to `working`. When the agent was - addressed in a thread, messages surface in that thread. - - Alongside them the message the agent is answering carries πŸ‘€ for as long - as the turn lasts. The status sits where the conversation is, so the - reaction is the only thing that says *which* message is being handled β€” - and it needs nothing from Discord but a permission, so it is there at - the channel root as well as inside a thread. - """ - # Marked before the branching below, because the working branch returns - # early when it only has to refresh the message in place. - await self._track_turn(channel_id, anchor_message_ref, agent_name, state=state) - - key = (channel_id, agent_name) - if state == "working": - await self._clear_input_pings(channel_id, agent_name) - # 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) - existing = self._working_msg.get(key) - if existing is not None: - await self.update_message(channel_id, existing.message_ref, body) - self._working_msg[key] = replace(existing, body=body) - return - ref = await self.send_message(channel_id, agent_name, body, thread_root_id) - if ref is not None: - self._working_msg[key] = LiveRuntimeIndicator( - message_ref=ref, - body=body, - thread_root_id=thread_root_id, - started_at=time.monotonic(), - ) - elif state == "awaiting-input": - ref = await self._ping_operator( - channel_id, - agent_name, - mention_handle, - thread_root_id, - deeplink_url, - detail, - ) - if ref is not None: - self._input_pings.setdefault(key, []).append(ref) - else: - await self._clear_working(channel_id, agent_name) - await self._clear_input_pings(channel_id, agent_name) - - async def _track_turn( - self, - channel_id: str, - anchor_message_ref: str | None, - agent_name: str, - *, - state: str, - ) -> None: - """Mark every message this agent is working on, and unmark them together. - - An agent asked two things at once works on both, and each message gets - its own πŸ‘€ β€” but the turn ends **once**, naming only the message it last - touched. Clearing just that one leaves the first marked as being worked - on for good, so they are remembered per agent and cleared together. - """ - akey = (channel_id, agent_name) - if state in ("working", "awaiting-input"): - if anchor_message_ref is None: - return - self._agent_eyes.setdefault(akey, set()).add(anchor_message_ref) - await self._mark_being_read(anchor_message_ref, working=True) - return - - for ref in sorted(self._agent_eyes.pop(akey, set())): - await self._mark_being_read(ref, working=False) - - async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: - """Put πŸ‘€ on the message an agent is working on, and take it off after. - - Needs only the Add Reactions permission, and works at the channel root - as well as inside a thread β€” so it is the progress signal that is always - available. A guild that has not granted the permission gets one warning - and no reaction, rather than a mark that is not there. - - This path has no durable record, so it answers the refused-removal - question from `self._marked` β€” which is sound only because it will not - attempt a removal at all unless this process put the mark there. The - reaction is then known to be outstanding, and is reported as such. - """ - _, message_id = self._parse_message_ref(message_ref) - if not message_id or self._client is None: - return - if working == ((message_ref, "working") in self._marked): - return - - try: - await self._react(message_ref, mark="working", on=working) - except ActivityMarkRefused as refusal: - if working: - logger.warning("%s", refusal) - else: - logger.error( - "%s The mark this process put there is still on the message.", - refusal, - ) - except (discord.HTTPException, ValueError) as e: - logger.warning( - "Could not %s the working reaction on Discord message %s: %s", - "add" if working else "remove", - message_ref, - e, - ) - async def _react(self, message_ref: str, *, mark: ActivityMark, on: bool) -> None: """Add or remove a mark, letting through whatever another attempt might fix. @@ -1658,16 +1522,6 @@ async def _react(self, message_ref: str, *, mark: ActivityMark, on: bool) -> Non f"it can still see {message_ref}." ) from error - 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: - await self.delete_message(channel_id, live.message_ref) - - async def _clear_input_pings(self, channel_id: str, agent_name: str) -> None: - refs = self._input_pings.pop((channel_id, agent_name), []) - for ref in refs: - await self.delete_message(channel_id, ref) - # ── Channels ───────────────────────────────────────────────────────────── async def create_channel( diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py index 6a6c6d7b5..fdd58134f 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py @@ -911,151 +911,6 @@ def test_send_typing_triggers_once_and_off_is_noop() -> None: assert channel.typing_count == 1 -# ── Runtime state (working-on-it activity) ────────────────────────────────── -# -# These drive `_apply_runtime_state` rather than the public entry point because -# the public one no longer reaches it: Discord now publishes SDK sessions and -# declares `renders_legacy_runtime_state = False`, so the base class stops the -# legacy path before the adapter sees it. The implementation is still here and -# still correct; what it no longer has is a caller. Removing it is its own task -# β€” until then these keep it honest, and `test_discord_sdk_only.py` covers the -# disabled ingress itself. - - -def _runtime_setup() -> tuple[DiscordAdapter, _FakeChannel, _FakeWebhook]: - adapter = _adapter() - channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) - webhook = _FakeWebhook() - adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook - return adapter, channel, webhook - - -def test_runtime_state_working_posts_persistent_indicator() -> None: - adapter, _, webhook = _runtime_setup() - - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - - assert len(webhook.sent) == 1 - assert webhook.sent[0]["content"] == "βš™οΈ _Working on it…_" - assert webhook.sent[0]["username"] == "my-agent" - assert ( - adapter._working_msg[(str(CHANNEL_ID), "my-agent")].message_ref - == f"{CHANNEL_ID}:901" - ) - - -def test_runtime_state_detail_edits_message_in_place() -> None: - adapter, _, webhook = _runtime_setup() - - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "working", - mention_handle=None, - thread_root_id=None, - detail="Editing adapter.py", - ) - ) - - assert len(webhook.sent) == 1 - assert len(webhook.edits) == 1 - assert webhook.edits[0]["message_id"] == 901 - assert webhook.edits[0]["content"] == "βš™οΈ Editing adapter.py" - assert ( - adapter._working_msg[(str(CHANNEL_ID), "my-agent")].message_ref - == f"{CHANNEL_ID}:901" - ) - - -def test_runtime_state_idle_clears_working_message() -> None: - adapter, channel, webhook = _runtime_setup() - - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "idle", - mention_handle=None, - thread_root_id=None, - ) - ) - - assert [d["message_id"] for d in webhook.deletes] == [901] - assert channel.deleted_ids == [] - assert (str(CHANNEL_ID), "my-agent") not in adapter._working_msg - - -def test_runtime_state_awaiting_input_pings_and_resume_clears_pings() -> None: - adapter, channel, webhook = _runtime_setup() - - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "awaiting-input", - mention_handle="louis", - thread_root_id=None, - ) - ) - - # Working indicator stays up; a ping was posted and tracked. - assert len(webhook.sent) == 2 - assert "@louis" in webhook.sent[1]["content"] - assert "needs your input" in webhook.sent[1]["content"] - assert adapter._input_pings[(str(CHANNEL_ID), "my-agent")] == [f"{CHANNEL_ID}:902"] - - # Resuming work means the input was provided β€” the ping is deleted, the - # working indicator is refreshed in place. - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - assert [d["message_id"] for d in webhook.deletes] == [902] - assert (str(CHANNEL_ID), "my-agent") not in adapter._input_pings - - # ── Webhook management ─────────────────────────────────────────────────────── @@ -1270,20 +1125,26 @@ async def scenario() -> None: def test_awaiting_input_with_nobody_linked_says_so() -> None: - # The ping used to post with the mention simply missing, which on the - # channel reads exactly like a ping that worked β€” an agent waiting on input - # nobody knows to give. The handle is the agent owner's linked account - # (CHOO-2137), so "nobody" now means the owner has not said which account - # here is theirs, and the line says that instead of trailing off. - adapter, _channel, webhook = _runtime_setup() + """`_ping_operator` is shared and still used by the platforms that have + not migrated, so it is exercised here through a real adapter's delivery. + + The ping used to post with the mention simply missing, which on the channel + reads exactly like a ping that worked β€” an agent waiting on input nobody + knows to give. The handle is the agent owner's linked account, so "nobody" + means the owner has not said which account here is theirs, and the line + says that instead of trailing off. + """ + adapter = _adapter() + adapter._client = _FakeClient({CHANNEL_ID: _FakeChannel()}) + webhook = _FakeWebhook() + adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook _run( - adapter._apply_runtime_state( + adapter._ping_operator( str(CHANNEL_ID), "my-agent", - "awaiting-input", - mention_handle=None, - thread_root_id=None, + None, + None, ) ) diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index c795f88a2..21a4247aa 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -350,11 +350,13 @@ async def _card(**kwargs: Any) -> RequestCard: return RequestCard(request, RequestReference(token="tok-1", handle="R7"), **kwargs) -# ── The legacy renderer is off ─────────────────────────────────────────────── +# ── No legacy renderer ─────────────────────────────────────────────────────── -async def test_the_legacy_renderer_no_longer_draws_alongside_the_sdk_one() -> None: - """Both would draw the same turn, and the channel would show it twice.""" +async def test_nothing_draws_a_second_account_of_the_turn() -> None: + """This adapter's own renderer is gone, but the base class still defaults + the flag on for the platforms that have one, so the declaration is what + keeps the inherited fallback from drawing the turn a second time.""" adapter, channel, _thread, webhook = _guild_setup() for state in ("working", "awaiting-input", "idle"): @@ -975,33 +977,65 @@ async def test_a_mark_that_cannot_be_taken_off_is_refused_not_shrugged_away() -> ) -async def test_the_pre_sdk_path_still_says_which_refusal_it_hit( - caplog: pytest.LogCaptureFixture, -) -> None: - """A turn with no durable record behind it answers the question itself. - - It can, because it only reaches a removal for a mark this process put - there: an absent mark is a warning and a stuck one is an error, as they - were before the refusal became an exception. - """ +async def test_a_refused_mark_is_not_recorded_as_present() -> None: + """Recording a mark that was refused would make the next attempt a no-op, + so the guild would never get the reaction back once the permission is.""" adapter, channel, _thread, _webhook = _guild_setup() ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" channel.reaction_error = discord.Forbidden(_Response(), "no Add Reactions") # type: ignore[arg-type] - with caplog.at_level(logging.WARNING): - await adapter._track_turn(str(CHANNEL_ID), ref, "my-agent", state="working") - assert [record.levelname for record in caplog.records] == ["WARNING"] + with pytest.raises(ActivityMarkRefused): + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True + ) - caplog.clear() channel.reaction_error = None - await adapter._track_turn(str(CHANNEL_ID), ref, "my-agent", state="working") - channel.reaction_error = discord.Forbidden(_Response(), "cannot see the channel") # type: ignore[arg-type] + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True + ) + assert channel.reactions == [("πŸ‘€", True)] - with caplog.at_level(logging.WARNING): - await adapter._track_turn(str(CHANNEL_ID), ref, "my-agent", state="completed") - assert [record.levelname for record in caplog.records] == ["ERROR"] - assert "still on the message" in caplog.text +async def test_a_message_inside_a_thread_is_marked_in_that_thread() -> None: + """The reaction goes where the message is, not where the room is. + + A message posted in a thread is bridged into the parent channel's room, but + it lives in the thread β€” which on Discord is a channel of its own, and the + only place the reaction can be added. + """ + adapter, channel, thread, _webhook = _guild_setup() + + await adapter.mark_activity( + str(CHANNEL_ID), + f"{ROOT_MESSAGE_ID}:999", + agent_name="my-agent", + mark="working", + on=True, + ) + + assert thread.reactions == [("πŸ‘€", True)] + assert channel.reactions == [] + + +async def test_a_deleted_message_is_not_a_failed_removal() -> None: + """The end state is what was wanted either way, so the mark is forgotten + rather than left recorded as present β€” and a later turn still marks.""" + adapter, channel, _thread, _webhook = _guild_setup() + ref = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True + ) + + channel.reaction_error = discord.NotFound(_Response(), "unknown message") # type: ignore[arg-type] + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=False + ) + + channel.reaction_error = None + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True + ) + assert channel.reactions[-1] == ("πŸ‘€", True) async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_working_reaction.py b/core/tests/switch_core/bridges/collaboration/test_discord_working_reaction.py deleted file mode 100644 index e0893fa05..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_discord_working_reaction.py +++ /dev/null @@ -1,254 +0,0 @@ -"""The πŸ‘€ Discord puts on the message an agent is answering. - -The status message says an agent is busy; it does not say *what with*. The -reaction is what ties a turn to the message that started it, and it is the one -progress signal that costs nothing but a permission β€” so it has to survive a -missing permission, a deleted message, and an agent answering two people at -once, without ever leaving a mark behind. -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -import discord -import pytest - -from switch_core.bridges.collaboration.discord.adapter import ( - DiscordAdapter, - DiscordConnectionConfig, -) - -GUILD_ID = 900 -CHANNEL_ID = 100 - - -def _run(coro: Any) -> Any: - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -class _FakeResponse: - status = 403 - reason = "Forbidden" - - -class _FakePartialMessage: - def __init__(self, channel: _FakeChannel, message_id: int) -> None: - self.id = message_id - self._channel = channel - - async def add_reaction(self, emoji: str) -> None: - if self._channel.reaction_error is not None: - raise self._channel.reaction_error - self._channel.reactions.append(("add", self.id, emoji)) - - async def remove_reaction(self, emoji: str, member: Any) -> None: - if self._channel.reaction_error is not None: - raise self._channel.reaction_error - self._channel.reactions.append(("remove", self.id, emoji)) - - -class _FakeChannel: - def __init__(self, channel_id: int = CHANNEL_ID) -> None: - self.id = channel_id - self.reactions: list[tuple[str, int, str]] = [] - self.reaction_error: Exception | None = None - - def get_partial_message(self, message_id: int) -> _FakePartialMessage: - return _FakePartialMessage(self, message_id) - - -class _FakeClient: - def __init__(self, channels: dict[int, Any]) -> None: - self._channels = channels - self.user = object() - - def get_channel(self, channel_id: int) -> Any | None: - return self._channels.get(channel_id) - - async def fetch_channel(self, channel_id: int) -> Any: - channel = self._channels.get(channel_id) - if channel is None: - raise discord.NotFound(_FakeResponse(), "unknown channel") - return channel - - -@pytest.fixture -def channel() -> _FakeChannel: - return _FakeChannel() - - -@pytest.fixture -def adapter(channel: _FakeChannel) -> DiscordAdapter: - made = DiscordAdapter( - config=DiscordConnectionConfig(bot_token="token", guild_id=str(GUILD_ID)) - ) - made._client = _FakeClient({CHANNEL_ID: channel}) # type: ignore[assignment] - return made - - -def _state( - adapter: DiscordAdapter, - state: str, - *, - anchor: str | None, - agent: str = "scribe", -) -> None: - _run(adapter._track_turn(str(CHANNEL_ID), anchor, agent, state=state)) - - -# ── The mark goes on, and comes off ────────────────────────────────────────── - - -def test_the_message_being_answered_is_marked( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - - assert channel.reactions == [("add", 11, "πŸ‘€")] - - -def test_the_mark_comes_off_when_the_turn_ends( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - _state(adapter, "idle", anchor=None) - - assert channel.reactions == [("add", 11, "πŸ‘€"), ("remove", 11, "πŸ‘€")] - - -def test_a_refreshed_turn_does_not_mark_twice( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - # The activity refresh reports `working` over and over with the same - # anchor; each one must not cost an API call. - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - - assert channel.reactions == [("add", 11, "πŸ‘€")] - - -def test_the_mark_stays_up_while_the_agent_waits_for_input( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - # `awaiting-input` is mid-turn, not the end of one. - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - _state(adapter, "awaiting-input", anchor=f"{CHANNEL_ID}:11") - - assert ("remove", 11, "πŸ‘€") not in channel.reactions - - -def test_a_turn_with_nothing_bridged_to_mark_is_not_an_error( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - # A turn the agent started from somewhere other than this channel has no - # anchor here β€” there is nothing to mark, and nothing to complain about. - _state(adapter, "working", anchor=None) - - assert channel.reactions == [] - - -# ── Two questions at once ──────────────────────────────────────────────────── - - -def test_both_messages_are_marked_and_both_are_cleared( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - """A turn ends once, naming only the message it last touched. - - Clearing just that one would leave the first marked as being worked on for - good β€” which is why the marks are held per agent rather than per message. - """ - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - _state(adapter, "working", anchor=f"{CHANNEL_ID}:22") - _state(adapter, "idle", anchor=None) - - assert channel.reactions == [ - ("add", 11, "πŸ‘€"), - ("add", 22, "πŸ‘€"), - ("remove", 11, "πŸ‘€"), - ("remove", 22, "πŸ‘€"), - ] - - -def test_one_agent_ending_its_turn_leaves_another_agent_marked( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11", agent="scribe") - _state(adapter, "working", anchor=f"{CHANNEL_ID}:22", agent="courier") - _state(adapter, "idle", anchor=None, agent="scribe") - - assert ("remove", 11, "πŸ‘€") in channel.reactions - assert ("remove", 22, "πŸ‘€") not in channel.reactions - - -# ── Inside a thread ────────────────────────────────────────────────────────── - - -def test_a_message_inside_a_thread_is_marked_in_that_thread( - adapter: DiscordAdapter, -) -> None: - """The reaction goes where the message is, not where the room is. - - A message posted in a thread is bridged into the parent channel's room, but - it lives in the thread β€” which on Discord is a channel of its own, and the - only place the reaction can be added. - """ - thread = _FakeChannel(channel_id=777) - adapter._client._channels[777] = thread # type: ignore[union-attr] - - _state(adapter, "working", anchor="777:33") - - assert thread.reactions == [("add", 33, "πŸ‘€")] - - -# ── When Discord says no ───────────────────────────────────────────────────── - - -def test_a_missing_permission_is_named_rather_than_faked( - adapter: DiscordAdapter, - channel: _FakeChannel, - caplog: pytest.LogCaptureFixture, -) -> None: - channel.reaction_error = discord.Forbidden(_FakeResponse(), "missing perms") - - with caplog.at_level(logging.WARNING): - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - - assert channel.reactions == [] - assert "Add Reactions" in caplog.text - - -def test_a_refused_mark_is_not_recorded_as_present( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - # Recording a mark that was refused would make the retry on the next - # activity report a no-op, so the guild would never recover the reaction - # once the permission is granted. - channel.reaction_error = discord.Forbidden(_FakeResponse(), "missing perms") - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - - channel.reaction_error = None - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - - assert channel.reactions == [("add", 11, "πŸ‘€")] - - -def test_a_deleted_message_ends_the_turn_cleanly( - adapter: DiscordAdapter, channel: _FakeChannel -) -> None: - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - - channel.reaction_error = discord.NotFound(_FakeResponse(), "unknown message") - _state(adapter, "idle", anchor=None) - - # The mark is forgotten, so a later turn on a fresh message still marks. - channel.reaction_error = None - _state(adapter, "working", anchor=f"{CHANNEL_ID}:11") - assert channel.reactions[-1] == ("add", 11, "πŸ‘€") From 51a0df6c3a2b742439d34b3942a75d7e234f243e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 22:42:21 +0100 Subject: [PATCH 056/120] Remove Telegram's legacy runtime-state renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram declares `renders_legacy_runtime_state = False`, so the base class stopped routing to this code before the adapter ever saw it. Deletes the status-message renderer, the eyes bookkeeping that fed it, and the inbound tracking those two needed: `_note_inbound`, `_reaction_anchor`, `_begin_working_reaction`, `_end_working_reactions`, `_mark_working`, `_apply_runtime_state`, `_clear_working`, `_clear_input_pings`, and the `_last_inbound` / `_thread_trigger` / `_agent_reactions` state. `mark_activity` survives and keeps its own `set_message_reaction` call, so three of its branches had no cover once the legacy tests went. Ported to `test_telegram_sdk_only.py`: the mark lands on the message the reference names, it comes off the same one, and a reference naming no message is not reacted to rather than an error. `test_runtime_indicator_race.py` covers the base class's runtime lock and reposition, not Telegram's, so it moves onto Teams β€” the one adapter still rendering through them β€” in a chat-layout channel, the case that repositions at all. Verified still defect-catching by removing the lock and watching four of the five fail. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/telegram/adapter.py | 192 +---------- .../test_bridge_agent_display_names.py | 45 +-- .../test_runtime_indicator_race.py | 57 ++-- .../collaboration/test_telegram_adapter.py | 301 ------------------ .../collaboration/test_telegram_sdk_only.py | 48 ++- 5 files changed, 90 insertions(+), 553 deletions(-) diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index b784436f3..40fe2500c 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -39,7 +39,6 @@ ActivityMark, ActivityMarkRefused, CollaborationAdapter, - LiveRuntimeIndicator, RequestCard, RichContent, RichContentFailed, @@ -428,6 +427,10 @@ class TelegramAdapter(CollaborationAdapter): #: account that added it, so there is one mark between them all. activity_reactions_per_agent: ClassVar[bool] = False + #: Off, because the SDK publication draws the status. This adapter has no + #: legacy renderer left to run, but the base class defaults the flag on for + #: the platforms that still do, so saying so here is what keeps the base + #: class's own fallback from drawing a second account of the turn. renders_legacy_runtime_state: ClassVar[bool] = False # Telegram's is the one disclosure that has been agreed: T2, accepted for @@ -468,27 +471,9 @@ def __init__(self, *, config: TelegramConnectionConfig) -> None: # prompt is abandoned rather than remembered forever. self._awaiting_args: OrderedDict[tuple[str, int], str] = OrderedDict() self._awaiting_args_max = 200 - # The bot can delete its own messages, so runtime state renders as a - # persistent message (the base class's _working_msg) rather than the - # one-shot typing action. - # chat id -> the last message a person sent there. Outside forum topics - # Telegram has no thread object, so a turn is reported with no root and - # there is nothing else to say which message a reaction belongs on. - # Bounded like _seen_ids: one entry per chat the bot has ever seen. - self._last_inbound: OrderedDict[str, str] = OrderedDict() - self._last_inbound_max = 1000 - # (chat id, thread root) -> the message that actually asked in it. - # A forum topic keeps the same root for every message in it, so the - # mark belongs on the latest question rather than on the topic. - # Bounded for the same reason as _seen_ids. - self._thread_trigger: OrderedDict[tuple[str, str], str] = OrderedDict() - self._thread_trigger_max = 1000 # Messages currently carrying the πŸ‘€, as (chat id, message id), so a # turn reporting its activity repeatedly reacts once. self._reacted: set[tuple[str, str]] = set() - # (chat id, agent name) -> every message that agent has marked. An - # agent asked two things at once marks both, and the turn ends once. - self._agent_reactions: dict[tuple[str, str], set[str]] = {} # chat id -> whether it is a forum. What a thread root means depends on # the answer, and nothing in a message ref says which kind it is. self._forum_chats: dict[str, bool] = {} @@ -1175,174 +1160,6 @@ async def send_typing( except Exception: logger.exception("Failed to trigger typing in Telegram chat %s", channel_id) - # ── Working reaction ───────────────────────────────────────────────────── - - def _note_inbound(self, chat_id: str, root_id: str | None, message_id: str) -> None: - """Remember the message a reaction should go on if this chat asks next.""" - if root_id: - key = (chat_id, root_id) - self._thread_trigger.pop(key, None) - self._thread_trigger[key] = message_id - while len(self._thread_trigger) > self._thread_trigger_max: - self._thread_trigger.popitem(last=False) - self._last_inbound.pop(chat_id, None) - self._last_inbound[chat_id] = message_id - while len(self._last_inbound) > self._last_inbound_max: - self._last_inbound.popitem(last=False) - - def _reaction_anchor(self, chat_id: str, thread_root_id: str | None) -> str | None: - """The message the πŸ‘€ goes on. - - Inside a forum topic every message shares one root, so the mark belongs - on the latest question asked there rather than on the topic itself. - Everywhere else Telegram has no thread object and the turn is reported - with no root at all, so the last thing a person said in the chat stands - in: that is what the agent is replying to, and the message a reader is - looking at while they wait. - """ - if thread_root_id: - return self._thread_trigger.get((chat_id, thread_root_id), thread_root_id) - return self._last_inbound.get(chat_id) - - async def _begin_working_reaction( - self, chat_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - """Mark what this agent is working on, once per turn.""" - asked_on = self._reaction_anchor(chat_id, thread_root_id) - if not asked_on: - return - self._agent_reactions.setdefault((chat_id, agent_name), set()).add(asked_on) - await self._mark_working(chat_id, asked_on, working=True) - - async def _end_working_reactions(self, chat_id: str, agent_name: str) -> None: - """Unmark everything this agent marked β€” the turn ends only once. - - An agent asked two things at once works on both and marks both, but the - report that ends the turn names one chat. Clearing only that would - leave the other message marked as in progress for good. - """ - for ref in sorted(self._agent_reactions.pop((chat_id, agent_name), set())): - await self._mark_working(chat_id, ref, working=False) - - async def _mark_working( - self, chat_id: str, message_id: str, *, working: bool - ) -> None: - """Put πŸ‘€ on the message being worked on, and take it off after. - - This is Telegram's one real progress affordance in a group. A bot may - react to anyone's message without being an administrator, it needs no - thread, and it says *which* message is being handled β€” which the status - message, sitting at the bottom of the chat, cannot. - - A chat can have reactions switched off. That costs the mark, not the - turn, so a refusal is logged and the tracked state left as it was for a - later attempt to correct. - """ - key = (chat_id, message_id) - if working == (key in self._reacted): - return - try: - bot = self._require_bot() - await bot.set_message_reaction( - chat_id=self._chat_id(chat_id), - message_id=int(message_id), - reaction=[ReactionTypeEmoji(_WORKING_REACTION)] if working else [], - ) - except TelegramError as e: - logger.warning( - "Could not %s the working reaction on %s in %s: %s", - "add" if working else "remove", - message_id, - chat_id, - e, - ) - return - if working: - self._reacted.add(key) - else: - self._reacted.discard(key) - - # ── Runtime state ──────────────────────────────────────────────────────── - - async def _apply_runtime_state( - self, - channel_id: str, - agent_name: str, - state: str, - *, - mention_handle: str | None, - thread_root_id: str | None, - deeplink_url: str | None = None, - detail: str | None = None, - trigger_thread_root_id: str | None = None, - anchor_message_ref: str | None = None, - ) -> None: - """Render runtime state as persistent, deletable status messages. - - Superseded: `renders_legacy_runtime_state` is False, so nothing calls - this. Kept until the legacy indicator is removed everywhere, because - deleting one platform's copy ahead of the others makes the comparison - between them impossible to read. - - A Telegram bot deletes its own messages cleanly (no tombstone), so β€” like - Slack and Discord β€” the "working on it…" indicator and any "needs your - input" pings are posted while relevant and removed when the turn ends. - The working indicator stays up through `awaiting-input` (the agent is - mid-turn, just paused) and the pings go with it when the turn ends or - resumes. - - The same posted message in a 1:1 chat as in a group. Telegram has no - per-bot progress affordance to prefer over it β€” `sendChatAction` is the - only one, it is a five-second one-shot with no cancel, and it says - "typing" rather than what the agent is doing. - """ - key = (channel_id, agent_name) - if state in ("working", "awaiting-input"): - await self._begin_working_reaction(channel_id, agent_name, thread_root_id) - else: - await self._end_working_reactions(channel_id, agent_name) - - if state == "working": - await self._clear_input_pings(channel_id, agent_name) - body = self._working_body(detail, deeplink_url) - existing = self._working_msg.get(key) - if existing is not None: - agent = await self.agent_rendering(agent_name) - await self.update_message( - channel_id, - existing.message_ref, - self._attribute(agent_name, agent.body_label, body), - ) - self._working_msg[key] = replace(existing, body=body) - return - ref = await self.send_message(channel_id, agent_name, body, thread_root_id) - if ref is not None: - self._working_msg[key] = LiveRuntimeIndicator( - message_ref=ref, - body=body, - thread_root_id=thread_root_id, - started_at=time.monotonic(), - ) - elif state == "awaiting-input": - ref = await self._ping_operator( - channel_id, agent_name, mention_handle, thread_root_id, deeplink_url - ) - if ref is not None: - self._input_pings.setdefault(key, []).append(ref) - else: - await self._clear_working(channel_id, agent_name) - await self._clear_input_pings(channel_id, agent_name) - - 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: - await self.delete_message(channel_id, live.message_ref) - - async def _clear_input_pings(self, channel_id: str, agent_name: str) -> None: - refs = self._input_pings.pop((channel_id, agent_name), []) - for ref in refs: - await self.delete_message(channel_id, ref) - # ── SDK session publication ────────────────────────────────────────────── def rich_fallback_limit(self) -> int: @@ -2339,7 +2156,6 @@ async def _handle_message(self, message: Any) -> None: ) root_id = self._root_id_of(message) message_ref = f"{chat_id}:{message.message_id}" - self._note_inbound(chat_id, root_id, str(message.message_id)) if await self._handle_start(content.strip(), chat_id, channel_type): return diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py index 9e9de7b1c..c1233ef3c 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py @@ -1412,31 +1412,6 @@ def test_telegram_album_caption_uses_the_display_name() -> None: assert "switchdev" not in caption -def test_telegram_runtime_status_edit_keeps_the_display_name() -> None: - """The live "working on it…" card is edited in place rather than reposted, - and that edit rebuilds the prefix itself instead of going through - `send_message`.""" - bridge = _bridge(_agent("switchdev", "Switch Dev")) - adapter, bot = _telegram_adapter(bridge) - - for detail in ("Reading foo.py", "Editing foo.py"): - _run( - adapter._apply_runtime_state( - TELEGRAM_CHAT, - "switchdev", - "working", - mention_handle=None, - thread_root_id=None, - detail=detail, - ) - ) - - edited = bot.edits[0]["text"] - assert "Switch Dev" in edited - assert "switchdev" not in edited - assert "Editing foo.py" in edited - - def test_telegram_escapes_a_display_name_exactly_once_in_the_prefix() -> None: """The prefix is finished HTML, escaped by `_attribute` and nothing else, so one `html.escape` is the whole of what it needs. The body escape inserts @@ -1588,9 +1563,9 @@ def test_a_telegram_display_name_cannot_mention_from_the_prefix() -> None: def test_telegram_defuses_the_label_in_every_prefix_it_builds() -> None: - """Four call sites build a prefix; the plain send is only one of them. An - attachment caption, an album caption and the in-place runtime edit each - assemble their own, so each has to reach for the escaped label itself.""" + """Three call sites build a prefix; the plain send is only one of them. An + attachment caption and an album caption each assemble their own, so each + has to reach for the escaped label itself.""" bridge = _bridge(_agent("switchdev", "@ceo_person")) adapter, bot = _telegram_adapter(bridge) files = [ @@ -1604,22 +1579,12 @@ def test_telegram_defuses_the_label_in_every_prefix_it_builds() -> None: ) ) _run(adapter.send_attachments(TELEGRAM_CHAT, "switchdev", files, "two charts")) - for detail in ("Reading foo.py", "Editing foo.py"): - _run( - adapter._apply_runtime_state( - TELEGRAM_CHAT, - "switchdev", - "working", - mention_handle=None, - thread_root_id=None, - detail=detail, - ) - ) + _run(adapter.send_message(TELEGRAM_CHAT, "switchdev", "on it")) built = [ bot.photos[0]["caption"], bot.albums[0]["media"][0].caption, - bot.edits[0]["text"], + bot.messages[0]["text"], ] for prefix in built: assert "@\u200bceo_person" in prefix 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 e8d1970a0..2718cd774 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 @@ -10,12 +10,10 @@ deleted, and the message the move posted is referenced by nothing β€” so the end-of-turn clear cannot remove it and it stays in the channel forever. -Every platform that publishes SDK sessions has left this path, so the races are -reproduced against a Telegram adapter with the legacy indicator switched back -on. The path itself is shared and still live β€” Teams renders its runtime state -through exactly this locking β€” and Telegram remains the adapter whose -implementation exercises it most directly, so this is a fixture for a real -defect rather than a test of dead code. +The lock and the reposition are both the base class's, and Teams is the one +adapter still rendering through them, so that is what these run against. A +**chat**-layout channel is the case that repositions at all: in a posts channel +Teams declines the move rather than leave a tombstone per hop. The invariant each test asserts is the same: whatever is still posted on the platform is exactly what the adapter thinks is posted. @@ -28,14 +26,14 @@ from typing import Any, ClassVar from switch_core.bridges.collaboration.adapter import LiveRuntimeIndicator -from switch_core.bridges.collaboration.telegram.adapter import ( - TelegramAdapter, - TelegramConnectionConfig, +from switch_core.bridges.collaboration.teams.adapter import ( + TeamsAdapter, + TeamsConnectionConfig, ) -class _LegacyIndicator(TelegramAdapter): - """Telegram with the legacy runtime indicator still switched on. +class _LegacyIndicator(TeamsAdapter): + """Teams with the legacy runtime indicator still switched on. The lock these tests are about lives in the public `apply_runtime_state` / `reposition_runtime_state`, above the flag that now turns the whole path @@ -47,7 +45,7 @@ class _LegacyIndicator(TelegramAdapter): renders_legacy_runtime_state: ClassVar[bool] = True -CHANNEL = "chan-1" +CHANNEL = "19:abc@thread.tacv2" AGENT = "worker" KEY = (CHANNEL, AGENT) @@ -59,7 +57,7 @@ class _Platform: stands in for the network round trip each of these calls really makes. """ - def __init__(self, adapter: TelegramAdapter, seeded_ref: str) -> None: + def __init__(self, adapter: TeamsAdapter, seeded_ref: str) -> None: self.live: set[str] = {seeded_ref} self.edits: list[tuple[str, str]] = [] self._next = iter(f"msg-{n}" for n in range(2, 20)) @@ -75,25 +73,42 @@ async def send_message( self.live.add(ref) return ref - async def update_message( - channel_id: str, message_ref: str, new_content: str + async def refresh_card( + channel_id: str, message_ref: str, agent_name: str, body: str ) -> None: await asyncio.sleep(0) - self.edits.append((message_ref, new_content)) + self.edits.append((message_ref, body)) async def delete_message(channel_id: str, message_ref: str) -> None: await asyncio.sleep(0) self.live.discard(message_ref) adapter.send_message = send_message # type: ignore[method-assign] - adapter.update_message = update_message # type: ignore[method-assign] + adapter._refresh_card = refresh_card # type: ignore[method-assign] adapter.delete_message = delete_message # type: ignore[method-assign] -def _adapter() -> tuple[TelegramAdapter, _Platform]: +class _Graph: + """A chat-layout channel, the one where a delete leaves nothing behind.""" + + async def get_channel(self, *, team_id: str, channel_id: str) -> dict[str, Any]: + return {"id": channel_id, "displayName": "general", "layoutType": "chat"} + + +def _adapter() -> tuple[TeamsAdapter, _Platform]: adapter = _LegacyIndicator( - config=TelegramConnectionConfig(bot_token="test", bot_username="test_bot") + config=TeamsConnectionConfig( + app_id="app-123", + app_password="secret", + tenant_id="tenant-9", + team_id="team-7", + public_base_url="https://switch.example", + client_state="s3cr3t", + ) ) + adapter._graph = _Graph() # type: ignore[assignment] + adapter._default_service_url = "https://smba.example" + adapter._channel_type[CHANNEL] = "channel_public" adapter._working_msg[KEY] = LiveRuntimeIndicator( message_ref="msg-1", body="βš™οΈ _Working on it…_", @@ -103,7 +118,7 @@ def _adapter() -> tuple[TelegramAdapter, _Platform]: return adapter, _Platform(adapter, "msg-1") -def _refresh(adapter: TelegramAdapter, detail: str) -> Any: +def _refresh(adapter: TeamsAdapter, detail: str) -> Any: return adapter.apply_runtime_state( CHANNEL, AGENT, @@ -114,7 +129,7 @@ def _refresh(adapter: TelegramAdapter, detail: str) -> Any: ) -def _assert_consistent(adapter: TelegramAdapter, platform: _Platform) -> None: +def _assert_consistent(adapter: TeamsAdapter, platform: _Platform) -> None: live = adapter._working_msg.get(KEY) tracked = {live.message_ref} if live is not None else set() assert platform.live == tracked, ( diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py index ef1371ede..adaa83b48 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_adapter.py @@ -1346,307 +1346,6 @@ def test_a_rejected_album_still_delivers_the_files() -> None: assert len(_bot(adapter).photos) == 2 -# ── Runtime state ──────────────────────────────────────────────────────────── - -# These drive `_apply_runtime_state` rather than the public entry point because -# the public one no longer reaches it: Telegram now publishes SDK sessions and -# declares `renders_legacy_runtime_state = False`, so the base class stops the -# legacy path before the adapter sees it. The implementation is still here and -# still correct; what it no longer has is a caller. Removing it is its own task -# β€” until then these keep it honest, and `test_telegram_sdk_only.py` covers -# what replaced it. - - -def test_working_posts_a_status_message() -> None: - adapter = _adapter() - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - - assert "Working on it" in _bot(adapter).messages[0]["text"] - assert (str(CHAT_ID), "scout") in adapter._working_msg - - -def test_working_again_edits_the_status_rather_than_reposting() -> None: - adapter = _adapter() - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), - "scout", - "working", - mention_handle=None, - thread_root_id=None, - detail="Editing adapter.py", - ) - ) - - assert len(_bot(adapter).messages) == 1 - assert "Editing adapter.py" in _bot(adapter).edits[0]["text"] - - -def test_awaiting_input_pings_the_operator() -> None: - adapter = _adapter() - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), - "scout", - "awaiting-input", - mention_handle="alice", - thread_root_id=None, - ) - ) - - assert "needs your input" in _bot(adapter).messages[0]["text"] - assert adapter._input_pings[(str(CHAT_ID), "scout")] - - -def test_going_idle_removes_the_status_and_the_pings() -> None: - adapter = _adapter() - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - _run( - adapter._apply_runtime_state( - str(CHAT_ID), - "scout", - "awaiting-input", - mention_handle="alice", - thread_root_id=None, - ) - ) - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "idle", mention_handle=None, thread_root_id=None - ) - ) - - assert adapter._working_msg == {} - assert adapter._input_pings == {} - assert len(_bot(adapter).deletes) == 2 - - -def test_the_status_message_follows_the_conversation() -> None: - # Repositioning comes from the base class, but only for adapters that track - # the indicator β€” this asserts Telegram does. - adapter = _adapter() - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - original = adapter._working_msg[(str(CHAT_ID), "scout")].message_ref - - _run(adapter._reposition_runtime_state(str(CHAT_ID), "scout", "88")) - - moved = adapter._working_msg[(str(CHAT_ID), "scout")] - assert moved.message_ref != original - assert moved.thread_root_id == "88" - # The replacement goes up before the original comes down. - assert _bot(adapter).deletes[0]["message_id"] == int(original.split(":")[1]) - - -# ── Working reaction ───────────────────────────────────────────────────────── - - -def _ask(adapter: TelegramAdapter, message_id: int = 11, **kwargs: Any) -> None: - """Deliver an inbound message, so the adapter knows what is being answered.""" - adapter._on_message = lambda m: _collect([], m) - _run(adapter._handle_message(_FakeInbound(message_id=message_id, **kwargs))) - - -def _emoji(call: dict[str, Any]) -> list[str]: - return [r.emoji for r in call["reaction"]] - - -def test_working_puts_the_eyes_on_the_message_that_asked() -> None: - adapter = _adapter() - _ask(adapter, message_id=11) - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - - assert _emoji(_bot(adapter).reactions[0]) == ["πŸ‘€"] - assert _bot(adapter).reactions[0]["message_id"] == 11 - - -def test_the_eyes_come_off_when_the_turn_ends() -> None: - adapter = _adapter() - _ask(adapter, message_id=11) - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "idle", mention_handle=None, thread_root_id=None - ) - ) - - assert _emoji(_bot(adapter).reactions[-1]) == [] - assert _bot(adapter).reactions[-1]["message_id"] == 11 - - -def test_the_eyes_go_up_once_however_often_the_activity_changes() -> None: - adapter = _adapter() - _ask(adapter, message_id=11) - - for detail in ("reading", "editing", "running tests"): - _run( - adapter._apply_runtime_state( - str(CHAT_ID), - "scout", - "working", - mention_handle=None, - thread_root_id=None, - detail=detail, - ) - ) - - assert len(_bot(adapter).reactions) == 1 - - -def test_the_eyes_stay_up_while_the_agent_waits_for_input() -> None: - # awaiting-input is mid-turn, not the end of one: the agent is still on it. - adapter = _adapter() - _ask(adapter, message_id=11) - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), - "scout", - "awaiting-input", - mention_handle="alice", - thread_root_id=None, - ) - ) - - assert [_emoji(c) for c in _bot(adapter).reactions] == [["πŸ‘€"]] - - -def test_the_eyes_follow_the_newest_question_in_the_chat() -> None: - # Telegram reports no thread, so "what is being worked on" is whatever was - # asked last β€” not the first thing the agent was ever asked. - adapter = _adapter() - _ask(adapter, message_id=11) - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "idle", mention_handle=None, thread_root_id=None - ) - ) - - _ask(adapter, message_id=12) - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - - assert [c["message_id"] for c in _bot(adapter).reactions] == [11, 11, 12] - - -def test_a_reply_is_marked_on_itself_not_on_what_it_replied_to() -> None: - adapter = _adapter() - _ask(adapter, message_id=11) - _ask(adapter, message_id=12, reply_to_message=_FakeInbound(message_id=11)) - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id="11" - ) - ) - - assert _bot(adapter).reactions[0]["message_id"] == 12 - - -def test_every_message_an_agent_marked_is_cleared_by_the_one_turn_ending() -> None: - # Two chats, one agent, one turn end β€” both marks have to come off. - adapter = _adapter() - other = str(-100999) - _ask(adapter, message_id=11) - _ask(adapter, message_id=21, chat=_FakeChat(chat_id=-100999)) - for channel in (str(CHAT_ID), other): - _run( - adapter._apply_runtime_state( - channel, "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - - for channel in (str(CHAT_ID), other): - _run( - adapter._apply_runtime_state( - channel, "scout", "idle", mention_handle=None, thread_root_id=None - ) - ) - - cleared = [c["message_id"] for c in _bot(adapter).reactions if not c["reaction"]] - assert sorted(cleared) == [11, 21] - - -def test_a_chat_that_never_spoke_is_not_reacted_to() -> None: - # Nothing to mark is not an error, and must not invent a message id. - adapter = _adapter() - - _run( - adapter._apply_runtime_state( - str(CHAT_ID), "scout", "working", mention_handle=None, thread_root_id=None - ) - ) - - assert _bot(adapter).reactions == [] - assert "Working on it" in _bot(adapter).messages[0]["text"] - - -def test_a_refused_reaction_is_logged_and_the_turn_carries_on( - caplog: pytest.LogCaptureFixture, -) -> None: - # Reactions can be switched off per chat. That costs the signal, not the turn. - adapter = _adapter() - _ask(adapter, message_id=11) - _bot(adapter).reaction_error = BadRequest("REACTION_INVALID") - - with caplog.at_level(logging.WARNING): - _run( - adapter._apply_runtime_state( - str(CHAT_ID), - "scout", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - - assert any("working reaction" in r.getMessage() for r in caplog.records) - assert "Working on it" in _bot(adapter).messages[0]["text"] - - # ── Translation ────────────────────────────────────────────────────────────── diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 834ac9cba..ab351e458 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -7,9 +7,9 @@ depending on what the chat actually is, edited in place, paced under Telegram's own limits, and loud when any of that fails. -The old runtime-state renderer is still in the file (removing it is its own -task) but nothing routes to it any more. The first test holds that line: the -two renderers must not both draw, or every turn appears twice. +This adapter has no legacy renderer left, but the base class defaults the flag +on for the platforms that still do. The first test holds that line: nothing +inherited may draw alongside the publication, or every turn appears twice. """ from __future__ import annotations @@ -941,6 +941,48 @@ async def test_a_tool_title_cannot_reach_the_chat_as_markup() -> None: # ── The working reaction ───────────────────────────────────────────────────── +async def test_the_mark_lands_on_the_message_the_reference_names() -> None: + """Telegram reports no thread, so the reference is the only thing saying + which message is being worked on β€” a chat has no other way to tell.""" + adapter = _adapter() + + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True + ) + + assert _bot(adapter).reactions[0]["chat_id"] == CHAT_ID + assert _bot(adapter).reactions[0]["message_id"] == 55 + assert [r.emoji for r in _bot(adapter).reactions[0]["reaction"]] == ["πŸ‘€"] + + +async def test_the_mark_comes_off_the_message_it_went_on() -> None: + """Telegram takes a reaction off by being sent an empty set for it, so a + removal is the same call β€” which makes landing it on the right message the + difference between a clean chat and one wearing πŸ‘€ for good.""" + adapter = _adapter() + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True + ) + + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=False + ) + + assert _bot(adapter).reactions[-1]["message_id"] == 55 + assert _bot(adapter).reactions[-1]["reaction"] == [] + + +async def test_a_reference_naming_no_message_is_not_reacted_to() -> None: + """Nothing to mark is not an error, and must not invent a message id.""" + adapter = _adapter() + + await adapter.mark_activity( + CHANNEL, "no-such-shape", agent_name="one", mark="working", on=True + ) + + assert _bot(adapter).reactions == [] + + async def test_one_mark_is_shared_between_agents_and_not_added_twice() -> None: """Every agent reacts through the one bot account, and Telegram allows it one reaction per message.""" From 31b96a464805c65c561280558b7745df9ee08209 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 22:43:37 +0100 Subject: [PATCH 057/120] Assert the whole reaction list in the deleted-message test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking only the tail passed on the first add alone, because the failed removal appends nothing to the fake β€” so the test would not have noticed the final add being suppressed by a record still claiming the mark was present. Verified by replacing the `NotFound` cache discard with `pass`: the test now fails where it previously passed. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/test_discord_sdk_only.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index 21a4247aa..9920ac5f1 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -1035,7 +1035,10 @@ async def test_a_deleted_message_is_not_a_failed_removal() -> None: await adapter.mark_activity( str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True ) - assert channel.reactions[-1] == ("πŸ‘€", True) + # The whole list, not just its tail: the failed removal appends nothing, so + # a tail check passes on the first add alone and would not notice the + # second being suppressed by a record still claiming the mark is present. + assert channel.reactions == [("πŸ‘€", True), ("πŸ‘€", True)] async def test_a_transient_reaction_failure_raises_so_the_publisher_retries() -> None: From 8d51707f7dc2a66b7f104064a11be6ab2c9cbbad Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 23:11:16 +0100 Subject: [PATCH 058/120] Remove the Teams legacy renderer and the shared legacy-indicator path Teams was the last adapter carrying a pre-SDK runtime-state renderer, so this removes both it and the shared machinery underneath it. Every adapter already declared renders_legacy_runtime_state False, so apply_runtime_state returned at the gate and the reposition and anchor-following paths were equally inert. Nothing here changes what a channel shows: the SDK session publication was already the only account of a turn on all five platforms. Removed from the base adapter: the LiveRuntimeIndicator record, format_elapsed, the renders_legacy_runtime_state and runtime_state_follows_anchor flags, the working-message, input-ping and runtime-lock state, and apply_runtime_state with its helpers. From bridge_core: handle_agent_runtime_state, the reported-anchor tracking and the whole runtime-indicator positioning section. From bridge_client: the on_agent_runtime_state override. ClientBase keeps the protocol hook as the no-op it always was, and set_runtime_state still persists state and emits its event for protocol clients; no adapter renders it now. Three surviving concerns were covered only through the deleted path and are ported rather than lost: Discord's unreachable-owner notice, Teams' defusal of markup in a display name, and the publishes_sdk_sessions claim that bridge_core reads to decide whether to route sessions at all. Two others were dropped as covered elsewhere or unfalsifiable on the surviving path, and one bare-handle case was added to Discord's label-escaping table in their place. Co-Authored-By: Claude Opus 5 --- .../bridges/agent/protocol/service.py | 27 +- .../bridges/collaboration/adapter.py | 329 +----------------- .../bridges/collaboration/bridge_core.py | 199 ----------- .../bridges/collaboration/discord/adapter.py | 14 +- .../collaboration/mattermost/adapter.py | 15 - .../bridges/collaboration/slack/adapter.py | 1 - .../bridges/collaboration/teams/README.md | 8 - .../bridges/collaboration/teams/adapter.py | 179 ---------- .../bridges/collaboration/telegram/adapter.py | 12 +- core/switch_core/clients/bridge_client.py | 6 - .../collaboration/test_adapter_helpers.py | 21 -- .../test_bridge_agent_display_names.py | 271 +-------------- .../test_bridge_indicator_position.py | 205 ----------- .../test_bridge_outbound_admin_rendering.py | 1 - .../test_bridge_outbound_media.py | 6 - .../test_bridge_runtime_state_thread.py | 195 ----------- .../collaboration/test_discord_adapter.py | 32 -- .../collaboration/test_discord_sdk_only.py | 54 +-- .../collaboration/test_mattermost_sdk_only.py | 29 +- .../test_runtime_indicator_race.py | 222 ------------ .../collaboration/test_slack_sdk_only.py | 21 +- .../collaboration/test_teams_adapter.py | 187 ---------- .../test_teams_channel_layout.py | 23 -- .../test_teams_runtime_state_layout.py | 243 ------------- .../collaboration/test_teams_sdk_only.py | 28 +- .../collaboration/test_telegram_sdk_only.py | 31 +- 26 files changed, 88 insertions(+), 2271 deletions(-) delete mode 100644 core/tests/switch_core/bridges/collaboration/test_adapter_helpers.py delete mode 100644 core/tests/switch_core/bridges/collaboration/test_bridge_indicator_position.py delete mode 100644 core/tests/switch_core/bridges/collaboration/test_bridge_runtime_state_thread.py delete mode 100644 core/tests/switch_core/bridges/collaboration/test_runtime_indicator_race.py delete mode 100644 core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py diff --git a/core/switch_core/bridges/agent/protocol/service.py b/core/switch_core/bridges/agent/protocol/service.py index 388020da6..921f8c3b9 100644 --- a/core/switch_core/bridges/agent/protocol/service.py +++ b/core/switch_core/bridges/agent/protocol/service.py @@ -1450,24 +1450,15 @@ async def set_runtime_state( """Record and broadcast an agent's runtime state in a room. Persists the latest state (so it is queryable via `!status`) and emits - a `com.switch.agent.runtime_state` room event the collaboration bridge - picks up to surface the state on the bridged channel. Reported by the - Switch Console connector as its managed session transitions. - - `thread_id` (the triggering message's thread, when it was in one) rides - the event so the bridge can surface the state in that thread. It is - transient routing only β€” it is never persisted as part of the state. - - `detail` is a short activity line for the running turn (e.g. "Editing - foo.py"); like `thread_id` it is transient and rides the event only β€” - the bridge surfaces it in place on the live working message. - - `anchor_event_id` is the latest message the reporting connector has - actually handed to the agent's session. The bridge repositions the - indicator when it changes, so position follows what the agent has - genuinely been given rather than what merely arrived in the room. Also - transient routing β€” reported on every refresh, and only a change moves - anything. + a `com.switch.agent.runtime_state` room event for protocol clients that + watch it. Reported by the Switch Console connector as its managed + session transitions. + + What a bridged channel shows of a running turn is the SDK session + publication, not this: no collaboration adapter renders the event any + more. `thread_id`, `detail` and `anchor_event_id` still ride it as + transient routing β€” never persisted as part of the state β€” and describe + where the turn is happening for a client that wants to draw it. The `switchdash://` deeplink is rewritten to a gateway HTTP redirect for platforms that linkify only http(s) (Discord, Telegram), so the "Open in diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 02823f085..bf701e242 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -1,10 +1,9 @@ from __future__ import annotations -import asyncio import logging from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from datetime import datetime from typing import ClassVar, Literal @@ -41,27 +40,6 @@ logger = logging.getLogger(__name__) -def format_elapsed(seconds: float) -> str: - """How long a turn took, for the marker its status line becomes. - - Rounded to whole seconds and written the way a reader skims it β€” "8s", - "2m14s", "1h03m" β€” rather than as a precise duration nobody reads. Sub- - second turns report "0s" instead of an empty string. - - Lives here rather than beside one adapter because every platform ends a - status line the same way β€” edited in place, left in the conversation β€” and - wants the same words on it. - """ - total = max(0, int(seconds)) - if total < 60: - return f"{total}s" - minutes, secs = divmod(total, 60) - if minutes < 60: - return f"{minutes}m{secs:02d}s" - hours, minutes = divmod(minutes, 60) - return f"{hours}h{minutes:02d}m" - - @dataclass(frozen=True) class AgentPresentation: """The presentation columns of one agent, exactly as they are stored. @@ -93,27 +71,6 @@ class AgentRendering: icon_url: str -@dataclass(frozen=True) -class LiveRuntimeIndicator: - """The runtime status message currently posted for one agent in one channel. - - ``body`` and ``thread_root_id`` are retained so the indicator can be - reposted verbatim, in the same thread, when it is moved to follow newer - traffic β€” a move has no access to the ``detail``/``deeplink_url`` the body - was originally rendered from. - - ``started_at`` is a ``time.monotonic()`` reading from when the turn's - indicator first went up, for adapters that report how long the turn took - once it ends. Monotonic because it measures an elapsed span, which a clock - adjustment must not distort. - """ - - message_ref: str - body: str - thread_root_id: str | None - started_at: float - - @dataclass(frozen=True) class TurnActivity: """A turn's items and its own status, as `post_rich` / `update_rich` draw it. @@ -311,8 +268,6 @@ class CollaborationAdapter(ABC): #: puts it on and the last to finish takes it off. activity_reactions_per_agent: ClassVar[bool] = False - renders_legacy_runtime_state: ClassVar[bool] = True - #: Whether this platform can create a channel from Switch at all. #: #: A ceiling, not a preference: an operator may withhold channel creation @@ -345,22 +300,6 @@ class CollaborationAdapter(ABC): #: instead of each bridge discovering it in its own way. renders_custom_url_schemes: ClassVar[bool] = True - #: Whether a runtime-state report with no thread of its own should anchor - #: to the message the agent is working on. - #: - #: A report only carries a `thread_id` when the agent was addressed inside - #: an existing thread. Addressed at the conversation root it carries none, - #: while the agent's reply still opens a thread on the triggering message β€” - #: so the status and the answer to it end up in two different places. - #: Where this is True the anchor the agent reports (the last message it was - #: actually handed) stands in, putting the status in the thread the reply - #: will land in. - #: - #: Off by default: on a platform that renders a thread as a side panel - #: rather than inline, moving the status out of the channel hides it, and - #: that trade is the platform's to make. - runtime_state_follows_anchor: ClassVar[bool] = False - #: Whether `find_request_card` can actually search this platform. #: #: False here because the base `find_request_card` returns `None` for @@ -443,15 +382,6 @@ def __init__(self) -> None: # size against this before downloading so an oversize file is rejected # loudly instead of being pulled down and discarded. self._max_attachment_bytes = 20 * 1024 * 1024 - # (channel_id, agent_name) -> the agent's live "working on it…" runtime - # indicator, and the operator pings posted alongside it. Adapters that - # render runtime state as a persistent message maintain these; the - # typing-indicator default leaves them empty. - self._working_msg: dict[tuple[str, str], LiveRuntimeIndicator] = {} - self._input_pings: dict[tuple[str, str], list[str]] = {} - # One lock per (channel_id, agent_name). Every mutation of the entries - # above happens under it β€” see _runtime_lock. - self._runtime_locks: dict[tuple[str, str], asyncio.Lock] = {} def set_max_attachment_bytes(self, max_bytes: int) -> None: self._max_attachment_bytes = max_bytes @@ -955,255 +885,12 @@ async def notify_working( does nothing and says nothing. """ - def _runtime_lock(self, channel_id: str, agent_name: str) -> asyncio.Lock: - """The lock serialising runtime-indicator work for one agent in one - channel. - - The indicator is mutated from two independent places β€” the periodic - activity refresh and a reposition triggered by new traffic β€” and each - reads the tracked message, awaits a platform call, then writes it back. - Left to interleave, the later write restores a superseded message ref: - the entry then names a message that has just been deleted while the one - actually on screen is referenced by nothing, so the end-of-turn clear - cannot remove it and it stays in the channel for good. - """ - return self._runtime_locks.setdefault((channel_id, agent_name), asyncio.Lock()) - - async def apply_runtime_state( - self, - channel_id: str, - agent_name: str, - state: str, - *, - mention_handle: str | None, - thread_root_id: str | None, - deeplink_url: str | None = None, - detail: str | None = None, - trigger_thread_root_id: str | None = None, - anchor_message_ref: str | None = None, - ) -> None: - """Serialise against any other runtime-indicator work for this agent, - then apply the state. Adapters override ``_apply_runtime_state``.""" - if not self.renders_legacy_runtime_state: - return - async with self._runtime_lock(channel_id, agent_name): - await self._apply_runtime_state( - channel_id, - agent_name, - state, - mention_handle=mention_handle, - thread_root_id=thread_root_id, - deeplink_url=deeplink_url, - detail=detail, - trigger_thread_root_id=trigger_thread_root_id, - anchor_message_ref=anchor_message_ref, - ) - - async def reposition_runtime_state( - self, channel_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - """Serialise against any other runtime-indicator work for this agent, - then move the indicator. Adapters override - ``_reposition_runtime_state``.""" - if not self.renders_legacy_runtime_state: - return - async with self._runtime_lock(channel_id, agent_name): - await self._reposition_runtime_state(channel_id, agent_name, thread_root_id) - - async def _apply_runtime_state( - self, - channel_id: str, - agent_name: str, - state: str, - *, - mention_handle: str | None, - thread_root_id: str | None, - deeplink_url: str | None = None, - detail: str | None = None, - trigger_thread_root_id: str | None = None, - anchor_message_ref: str | None = None, - ) -> None: - """Surface a Switch Console-managed agent's runtime state on the channel. - - How a state is rendered is the adapter's choice β€” this default uses the - typing indicator for ``working``. Mattermost overrides this to edit a - persistent status message, since deletion leaves a tombstone. - Slack disables this path and renders SDK session activity instead. - - ``thread_root_id``, when set, is the external thread the state belongs - in; the state surfaces there. - - ``trigger_thread_root_id`` is where the triggering message itself sits, - and is None when it came from the channel root. The two differ on an - adapter that pins a status to a thread the conversation is not in yet - (see ``runtime_state_follows_anchor``): the status belongs in the - thread, but a typing indicator belongs where the person who is waiting - for it is looking. Defaulted because only an adapter that draws the - distinction reads it, and its callers should not have to restate a - value the other adapters ignore. - - ``anchor_message_ref`` is the external post the agent reports it is - answering β€” the last message it was actually handed. Unlike the two - above it names a *message* rather than a thread, and it is set whether - or not that message opened one, so an adapter can mark the message - itself (Discord puts a reaction on it) without moving where the status - is posted. None when nothing the agent was handed crossed this bridge. - - ``deeplink_url``, when set, is an https link (served by the gateway) that - opens the agent's session in the Switch Console desktop app; adapters that - post a visible status message append it so a reader can jump there. - - - ``working`` β†’ typing on. - - ``awaiting-input`` β†’ keep the working/typing indicator (the agent is - mid-turn, paused for input) and ping the configured operator. - - ``idle`` (where ``completed`` collapses) β†’ typing off. - """ - if state == "working": - await self.send_typing(channel_id, agent_name, True) - elif state == "awaiting-input": - await self.send_typing(channel_id, agent_name, True) - await self._ping_operator( - channel_id, - agent_name, - mention_handle, - thread_root_id, - deeplink_url, - detail, - ) - else: - await self.send_typing(channel_id, agent_name, False) - - def agents_with_live_runtime_state(self, channel_id: str) -> list[str]: - """Agents with a runtime indicator currently posted in this channel. - - Cheap and synchronous so a caller can skip the work of deciding whether - a message warrants a move when there is nothing to move.""" - return [ - agent_name - for (posted_channel, agent_name) in self._working_msg - if posted_channel == channel_id - ] - - async def _reposition_runtime_state( - self, channel_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - """Move the agent's live runtime indicator to follow the latest message. - - Called when a message the agent is party to has just crossed the bridge, - so the indicator no longer sits below the conversation it belongs to. - The replacement is posted *before* the original is removed: the - indicator is therefore never briefly absent, and a failed repost leaves - the original in place rather than clearing it. - - ``thread_root_id`` is the thread that message belonged to, and is where - the indicator lands β€” so it follows the agent between threads (and back - out to the channel root) rather than being stranded in whichever thread - the turn happened to start in. - - Runs under the agent's runtime lock, so the tracked indicator cannot be - cleared or refreshed part-way through. - - Adapters that render runtime state as a typing indicator have nothing - positional to move, so the default does nothing. - """ - key = (channel_id, agent_name) - live = self._working_msg.get(key) - if live is None: - return - - ref = await self.send_message(channel_id, agent_name, live.body, 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 _remove_runtime_indicator( - self, channel_id: str, message_ref: str - ) -> None: - """Delete a superseded runtime indicator. - - Separate from ``delete_message`` so an adapter whose delete raises can - keep a failed cleanup from tearing down the turn β€” the worst case is a - duplicate indicator, which is visible, rather than a broken turn.""" - await self.delete_message(channel_id, message_ref) - - @staticmethod - def _deeplink_suffix(deeplink_url: str | None) -> str: - """A trailing ``(Open in Switch Console)`` link to the session, or empty. - - Appended inline in parentheses after the status text. Rendered through - ``translate_outbound`` along with the rest of the body, so it converts - to each platform's link format.""" - if not deeplink_url: - return "" - return f" ([Open in Switch Console]({deeplink_url}))" - - def _working_body(self, detail: str | None, deeplink_url: str | None) -> str: - """The "working on it…" status text, rendered for this platform. - - Uses the connector-supplied `detail` (e.g. "Editing foo.py") as the live - activity line when present, falling back to the generic phrase. The - deeplink is appended as a trailing link either way.""" - activity = detail.strip() if detail and detail.strip() else "_Working on it…_" - return self.translate_outbound( - f"βš™οΈ {activity}" + self._deeplink_suffix(deeplink_url) - ) - - async def _ping_operator( - self, - channel_id: str, - agent_name: str, - mention_handle: str | None, - thread_root_id: str | None, - deeplink_url: str | None = None, - detail: str | None = None, - ) -> str | None: - """Post a message nudging the operator that the agent needs attention. - - ``detail``, when set, is the reason the session stalled β€” an API or auth - failure the agent cannot recover from on its own. It replaces the - generic "needs your input" wording so the operator knows what is wrong - before clicking through. - - `mention_handle` is the agent owner's account on this platform, or None - when there is nobody to reach β€” no owner, or an owner who has not said - which account here is theirs. That case says so instead of posting a - line nobody is notified about: a nudge that reaches no one looks - identical to an agent that never asked. - - Returns the posted message ref so callers that can remove it (Slack, - Mattermost) track it for cleanup when the turn ends.""" - label = await self.agent_label_for_body(agent_name) - reason = detail.strip() if detail and detail.strip() else "" - need = f"hit an error: {reason}" if reason else "needs your input" - lead = "⚠️ " if reason else "" - if mention_handle: - text = f"@{mention_handle} {lead}**{label}** {need}." - else: - text = ( - f"{lead}**{label}** {need} β€” but nobody here is linked " - f"to its owner, so this pings no one. Link your " - f"{self.platform_name} account in Switch Console to be notified." - ) - body = self.translate_outbound(text + self._deeplink_suffix(deeplink_url)) - return await self.send_message(channel_id, agent_name, body, thread_root_id) - def unnotified_notice(self) -> str: """Why an attention post named nobody, for a platform that says so. - The same explanation `_ping_operator` gives, for the SDK publication - that replaces it: the reader is told this reached no one and what to - do so the next one does, rather than being left to assume the person - who can act has already seen it. + The reader is told this reached no one and what to do so the next one + does, rather than being left to assume the person who can act has + already seen it. """ return ( "Nobody here is linked to this agent's owner, so this notified no one. " @@ -1560,10 +1247,10 @@ def adapt_icon_url(self, raw: str | None, agent_name: str) -> str: def escape_label_for_body(self, label: str) -> str: """Neutralise a label's markup before it goes into message text. - A display name is presentation text an agent's owner chooses, and - `_ping_operator` inlines it into a body. Two constructs are near - universal across chat platforms and are the ones a name can use to - claim something it is not: + A display name is presentation text an agent's owner chooses, and an + adapter inlines it into a body. Two constructs are near universal + across chat platforms and are the ones a name can use to claim + something it is not: - `@…` addresses somebody. Whether the platform resolves `@channel`, `@here`, a person or one of our own agent handles, the label gets to diff --git a/core/switch_core/bridges/collaboration/bridge_core.py b/core/switch_core/bridges/collaboration/bridge_core.py index a12e581b3..d35d2f5a1 100644 --- a/core/switch_core/bridges/collaboration/bridge_core.py +++ b/core/switch_core/bridges/collaboration/bridge_core.py @@ -50,7 +50,6 @@ from switch_core.db.stores.room_store import RoomStore from switch_core.db.stores.session_request_post_store import SessionRequestPostStore from switch_core.db.tenant_lookup import tenant_of_room -from switch_core.events import AgentRuntimeStateEvent from switch_core.logging_context import log_context from switch_core.provisioning import Provisioning from switch_core.room_service import RoomCreateConfig @@ -105,11 +104,6 @@ class _PendingOutboundGroup: first_event_id: str | None = None -# How long a queued runtime-indicator move waits before it runs, so a burst of -# messages to the same agent costs one move rather than one per message. Short -# enough that the indicator still reads as following the conversation. -_INDICATOR_MOVE_DELAY_SECONDS = 1.0 - _LOBBY_DEPRECATION_NOTICE = ( "πŸ‘‹ This isn't where you talk to agents β€” direct messages to the Switch " "app aren't routed to anyone. Head to a channel and @-mention an agent " @@ -214,14 +208,6 @@ def __init__( # never completes cannot leak. self._outbound_groups: dict[str, _PendingOutboundGroup] = {} self._outbound_group_timers: dict[str, asyncio.TimerHandle] = {} - # (channel_id, agent_name) whose runtime indicator is due to be moved - # below newly-arrived traffic, and the thread each should land in. - # See _schedule_indicator_move. - self._indicator_move_timers: dict[tuple[str, str], asyncio.TimerHandle] = {} - self._indicator_move_targets: dict[tuple[str, str], str | None] = {} - # (channel_id, agent_name) -> the last message the agent reported having - # been handed. Cleared when its turn ends. See _follow_reported_anchor. - self._reported_anchors: dict[tuple[str, str], str] = {} # Matrix-event -> external-post anchors written synchronously right after # room_send, before the durable _record_message_map commit, so a fast # command reply relayed during that DB await still resolves the command's @@ -1775,11 +1761,6 @@ async def _relay_outbound_message( external_post_id=message_ref, ) - if sender_name is not None: - await self._move_indicator_for_sender( - channel_id, sender_name, thread_root_ref - ) - async def _outbound_thread_root_ref( self, event_content: dict[str, object], channel_id: str ) -> str | None: @@ -1935,8 +1916,6 @@ async def _relay_outbound_media( external_post_id=message_ref, ) - await self._move_indicator_for_sender(channel_id, sender_name, thread_root_ref) - def _schedule_outbound_group_flush( self, group_id: str, @@ -2019,8 +1998,6 @@ async def _relay_outbound_group( external_post_id=message_ref, ) - await self._move_indicator_for_sender(channel_id, sender_name, thread_root_ref) - async def _download_matrix_media( self, client: ClientBase[Any], mxc: str | None, filename: str ) -> bytes | None: @@ -2051,125 +2028,6 @@ async def handle_outbound_typing( await self._adapter.send_typing(channel_id, agent_name, is_typing) - async def handle_agent_runtime_state( - self, room: RoomRef, event: AgentRuntimeStateEvent - ) -> None: - """Resolve the channel and let the adapter surface the runtime state. - - Each platform decides how to render it (see - ``CollaborationAdapter.apply_runtime_state``). When the triggering - message was in a thread, the state surfaces in that same thread: the - event's `thread_id` (a Matrix event id) is resolved to the external - thread root via the same map outbound replies use. - - Addressed at the conversation root there is no `thread_id`, and on an - adapter that asks for it the reported anchor β€” the last message the - agent was handed β€” stands in, so the status joins the thread the reply - opens on that message instead of sitting at the channel root beside it. - """ - channel_id = self._find_channel(matrix_room_id=room.room_id) - if channel_id is None: - logger.debug( - "[BRIDGE-OUT] no channel mapping for runtime-state room %s", - room.room_id, - ) - return - - room_id, _ = self._channel_to_room[channel_id] - with tenant_scope(await self._room_tenant(room_id)): - await self._apply_runtime_state(channel_id, event) - - async def _apply_runtime_state( - self, channel_id: str, event: AgentRuntimeStateEvent - ) -> None: - # Where the triggering message itself sits β€” None when it came from the - # channel root. This is what a typing indicator follows. - trigger_thread_ref: str | None = None - if event.thread_id is not None: - trigger_thread_ref = await self._external_post_for_matrix_event( - event.thread_id - ) - - # The message the agent says it is answering. Resolved whichever way - # the status is positioned, so an adapter can mark that message without - # also moving the status onto it. - anchor_message_ref: str | None = None - if event.anchor_event_id is not None: - anchor_message_ref = await self._external_post_for_matrix_event( - event.anchor_event_id - ) - - # Where a persistent status belongs, which on an adapter that asks for - # it is the thread the reply will open on the message being worked on. - anchor_ref = event.thread_id - if anchor_ref is None and self._adapter.runtime_state_follows_anchor: - anchor_ref = event.anchor_event_id - - thread_root_ref: str | None = trigger_thread_ref - if anchor_ref is not None and anchor_ref != event.thread_id: - thread_root_ref = await self._external_post_for_matrix_event(anchor_ref) - if anchor_ref is not None and thread_root_ref is None: - logger.debug( - "[BRIDGE-OUT] no external post mapped for runtime-state thread " - "%s; surfacing at channel root in %s", - anchor_ref, - channel_id, - ) - - await self._adapter.apply_runtime_state( - channel_id, - event.agent_name, - event.state, - mention_handle=event.mention_handle, - thread_root_id=thread_root_ref, - deeplink_url=event.deeplink_url, - detail=event.detail, - trigger_thread_root_id=trigger_thread_ref, - anchor_message_ref=anchor_message_ref, - ) - await self._follow_reported_anchor( - channel_id, - event.agent_name, - event.state, - event.anchor_event_id, - thread_root_ref, - ) - - async def _follow_reported_anchor( - self, - channel_id: str, - agent_name: str, - state: str, - anchor_event_id: str | None, - thread_root_ref: str | None, - ) -> None: - """Move the indicator when the agent reports it has been handed a - newer message than the one it was last positioned against. - - Position follows what the agent has actually received, not what merely - arrived in the room β€” a message the agent has not been given yet must - not make the indicator look like the agent has seen it. The periodic - activity refresh repeats the current anchor, so it never moves anything. - """ - key = (channel_id, agent_name) - if state != "working": - self._reported_anchors.pop(key, None) - return - if anchor_event_id is None: - return - - if self._reported_anchors.get(key) == anchor_event_id: - return - previous = self._reported_anchors.get(key) - self._reported_anchors[key] = anchor_event_id - if previous is None: - # First anchor of the turn β€” the indicator was only just posted - # against it, so there is nothing to move. - return - - self._indicator_move_targets[key] = thread_root_ref - await self._run_indicator_move(key) - # ── Protection sync ────────────────────────────────────────────────────── # TODO: use this when protection setup is done @@ -2197,63 +2055,6 @@ async def handle_protection_verdict( translated = self._adapter.translate_outbound(new_content) await self._adapter.update_message(channel_id, message_ref, translated) - # ── Runtime-indicator positioning ───────────────────────────────────────── - - async def _move_indicator_for_sender( - self, channel_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - """Follow a message the agent itself just posted.""" - if agent_name in self._adapter.agents_with_live_runtime_state(channel_id): - self._schedule_indicator_move(channel_id, agent_name, thread_root_id) - - def _schedule_indicator_move( - self, channel_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - """Queue a move of this agent's runtime indicator, coalescing bursts. - - Messages arriving while a move is already queued are absorbed into it, - so a rapid exchange costs one delete-and-repost rather than one per - message. The delay is deliberately not extended by later messages β€” - a sustained conversation would otherwise starve the move indefinitely. - - ``thread_root_id`` is the thread the triggering message belongs to, and - the one the indicator will land in. A coalesced burst keeps the most - recent one, so the indicator follows the conversation's latest thread - rather than the one that opened the window. - """ - key = (channel_id, agent_name) - self._indicator_move_targets[key] = thread_root_id - if key in self._indicator_move_timers: - return - - loop = asyncio.get_running_loop() - self._indicator_move_timers[key] = loop.call_later( - _INDICATOR_MOVE_DELAY_SECONDS, - lambda: asyncio.ensure_future(self._run_indicator_move(key)), - ) - - async def _run_indicator_move(self, key: tuple[str, str]) -> None: - # An anchor-driven move runs immediately rather than through the timer, - # so a coalescing window opened by outbound traffic may still be - # pending; it would otherwise fire a second, redundant move. - timer = self._indicator_move_timers.pop(key, None) - if timer is not None: - timer.cancel() - thread_root_id = self._indicator_move_targets.pop(key, None) - channel_id, agent_name = key - try: - await self._adapter.reposition_runtime_state( - channel_id, agent_name, thread_root_id - ) - except Exception: - # The indicator is cosmetic; a platform failure here must not take - # down the bridge callback that happened to trigger it. - logger.exception( - "[BRIDGE-OUT] failed to move the runtime indicator for %s in %s", - agent_name, - channel_id, - ) - # ── Message-map helpers ─────────────────────────────────────────────────── def _prerecord_message_map( diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 71b5e310a..91e5df35b 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -292,12 +292,6 @@ class DiscordAdapter(CollaborationAdapter): # them: the first turn to want it adds it and the last to finish removes it. activity_reactions_per_agent: ClassVar[bool] = False - # Off, because the SDK publication draws the status. This adapter has no - # legacy renderer left to run, but the base class defaults the flag on for - # the platforms that still do, so saying so here is what keeps the base - # class's own fallback from drawing a second account of the turn. - renders_legacy_runtime_state: ClassVar[bool] = False - # `find_request_card` reads a channel's history back and matches a card by # the handle printed on it, so an unacknowledged send can still be bound to # the message it produced. @@ -1871,10 +1865,10 @@ def escape_label_for_body(self, label: str) -> str: - Mass and user mentions. `escape_mentions` breaks `@everyone`, `@here` and `<@id>` with a zero-width space after the `@`. It does nothing for a plain `@opsbot`, which needs no Discord syntax at all: - `translate_outbound` runs over the finished body after the label is - inlined and resolves any handle it holds an id for into a real - `<@id>` or `<@&role>`. The base class's `@` rule is what closes that, - which is why this builds on it rather than replacing it. + `translate_outbound` resolves any handle it holds an id for into a + real `<@id>` or `<@&role>`, and `_rich_escape` runs it over escaped + host text. The base class's `@` rule is what closes that, which is + why this builds on it rather than replacing it. - Everything else Discord resolves from `<…>` β€” a channel link (`<#id>`), a custom emoji (`<:name:id>`), a timestamp (``), a slash-command link (``). `escape_mentions` covers none of diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 939e30c14..42bf661db 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -215,21 +215,6 @@ class MattermostAdapter(CollaborationAdapter): #: on its own. activity_reactions_per_agent: ClassVar[bool] = True - #: Off, because the SDK publication draws the status. This adapter has no - #: legacy renderer left to run, but the base class defaults the flag on for - #: the platforms that still do, so saying so here is what keeps the base - #: class's own fallback from drawing a second account of the turn. - renders_legacy_runtime_state: ClassVar[bool] = False - - #: Mattermost renders a thread inline under its root as well as in the - #: side panel, so anchoring the status to the message being worked on keeps - #: it beside the answer instead of stranding it at the channel root. - #: - #: Read only by the legacy runtime-state path, which is off above. Kept - #: because it is a true statement about the platform, and the platform is - #: what the flag describes. - runtime_state_follows_anchor: ClassVar[bool] = True - #: `find_request_card` reads the channel's recent posts back, so a card #: whose send was never acknowledged can be bound to what is actually #: there instead of being disclosed as lost. diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 26105473f..d093d0fbf 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -171,7 +171,6 @@ class SlackAdapter(CollaborationAdapter): redraws_for_elapsed_time: ClassVar[bool] = True supports_activity_reactions: ClassVar[bool] = True supports_queue_reaction: ClassVar[bool] = True - renders_legacy_runtime_state: ClassVar[bool] = False recovers_uncertain_posts: ClassVar[bool] = True #: Every publication carries its token in `block_id` and in the message's diff --git a/core/switch_core/bridges/collaboration/teams/README.md b/core/switch_core/bridges/collaboration/teams/README.md index a33dddc12..e6cba5ff2 100644 --- a/core/switch_core/bridges/collaboration/teams/README.md +++ b/core/switch_core/bridges/collaboration/teams/README.md @@ -46,14 +46,6 @@ and listing a team's channels reports `layoutType` as null for all of them, so the per-channel read is the only route; an unreadable layout is treated as `post`, Graph's own default. -**Runtime status** is retired differently per layout, for the same reason. -Teams substitutes *"This message has been deleted."* for a deleted message in a -`post`-layout channel and keeps it in the post, so there the status card is -never deleted: it is edited into a `βœ“ Done Β· ` marker, and it is not -repositioned either (a move is a repost plus a delete). A `chat`-layout channel -deletes cleanly and keeps the ordinary behaviour. Mattermost does the same -thing for the same reason β€” see `_apply_runtime_state` there. - **Commands** arrive three ways and all land on the same dispatcher: `!name`, `/name`, and a bare `name` on a *targeted* message (`recipient.isTargeted`), which is what Teams' `/` picker sends β€” it prints the slash and inserts the diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 3097f0ee8..6c47f6bb8 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -24,13 +24,11 @@ from switch_core.bridges.agent.commands import COMMANDS_BY_NAME from switch_core.bridges.collaboration.adapter import ( CollaborationAdapter, - LiveRuntimeIndicator, RequestCard, RichContent, RichContentFailed, RichContentThrottled, TurnActivity, - format_elapsed, ) from switch_core.bridges.collaboration.models import ( BridgeConnectionConfig, @@ -530,8 +528,6 @@ class TeamsAdapter(CollaborationAdapter): supports_activity_reactions: ClassVar[bool] = False activity_reactions_per_agent: ClassVar[bool] = False - renders_legacy_runtime_state: ClassVar[bool] = False - # Nothing here can look for a publication whose response was lost. The # Graph credentials are app-only and the app's resource-specific consent # covers reading channel messages, not chats; `GraphClient` has no @@ -1670,181 +1666,6 @@ async def _edit_rich( text=text, ) from error - # ── Runtime state ────────────────────────────────────────────────────────── - - async def _apply_runtime_state( - self, - channel_id: str, - agent_name: str, - state: str, - *, - mention_handle: str | None, - thread_root_id: str | None, - deeplink_url: str | None = None, - detail: str | None = None, - trigger_thread_root_id: str | None = None, - anchor_message_ref: str | None = None, - ) -> None: - """Persistent status messages, mirroring Slack. - - Superseded: `renders_legacy_runtime_state` is False, so nothing calls - this. Kept until the legacy indicator is removed everywhere, because - deleting one platform's copy ahead of the others makes the comparison - between them impossible to read. - - A "working on it…" card is posted (as the agent) while the agent works - and edited in place as the activity detail changes; it stays up through - ``awaiting-input`` β€” where a "needs your input" ping is added β€” and both - are retired when the turn goes ``idle`` (or resumes to ``working``, - since the requested input was provided). - - **How they are retired depends on the channel's layout**, because Teams - does not delete the same way in both. In a chat-layout channel a deleted - message is gone, so the status is removed and leaves nothing behind. In - a **posts** channel Teams substitutes *"This message has been deleted."* - and keeps it in the post β€” so a status that appears and vanishes each - turn litters the conversation with tombstones, one per turn per agent. - There is no way to delete without one. So there, as on Mattermost, the - status is never deleted: it is edited into a small terminal marker and - left as the record of a turn that is over. - """ - key = (channel_id, agent_name) - if state == "working": - await self._clear_input_pings(channel_id, agent_name) - body = self._working_body(detail, deeplink_url) - existing = self._working_msg.get(key) - if existing is not None: - await self._refresh_card( - channel_id, existing.message_ref, agent_name, body - ) - self._working_msg[key] = replace(existing, body=body) - return - ref = await self.send_message(channel_id, agent_name, body, thread_root_id) - if ref is not None: - self._working_msg[key] = LiveRuntimeIndicator( - message_ref=ref, - body=body, - thread_root_id=thread_root_id, - started_at=time.monotonic(), - ) - elif state == "awaiting-input": - ref = await self._ping_operator( - channel_id, - agent_name, - mention_handle, - thread_root_id, - deeplink_url, - detail, - ) - if ref is not None: - self._input_pings.setdefault(key, []).append(ref) - else: - await self._retire_working(channel_id, agent_name) - await self._clear_input_pings(channel_id, agent_name) - - async def _leaves_a_tombstone(self, channel_id: str) -> bool: - """Whether deleting a message here would leave wreckage behind. - - Only in a posts channel, where Teams replaces a deleted message with - *"This message has been deleted."* and keeps it in the post. A - chat-layout channel drops it cleanly, and a chat or group chat has no - post to litter. - """ - return await self._uses_post_layout(channel_id) - - async def _refresh_card( - self, channel_id: str, message_ref: str, agent_name: str, body: str - ) -> None: - if self._connector is None: - return - service_url, conversation_id = self._locate(channel_id, message_ref) - await self._connector.update_activity( - service_url=service_url, - conversation_id=conversation_id, - activity_id=message_ref, - activity=await self._message_activity(agent_name, body), - ) - - async def _retire_working(self, channel_id: str, agent_name: str) -> None: - """End the turn's live status: edited where a delete would scar, else - removed. - - The marker is kept to the bare fact that the turn finished and how long - it took, because in a posts channel this line stays there for good and - has to earn its place. The session link is deliberately dropped: it - belongs on a live indicator, where it is still worth following, not on - the record of a turn that is over. - """ - live = self._working_msg.pop((channel_id, agent_name), None) - if live is None: - return - if not await self._leaves_a_tombstone(channel_id): - await self.delete_message(channel_id, live.message_ref) - return - elapsed = format_elapsed(time.monotonic() - live.started_at) - await self._refresh_card( - channel_id, - live.message_ref, - agent_name, - self.translate_outbound(f"βœ“ Done Β· {elapsed}"), - ) - - async def _reposition_runtime_state( - self, channel_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - """Follow the conversation, except where moving would leave a scar. - - Repositioning is a repost plus a delete, and in a posts channel that - delete leaves *"This message has been deleted."* behind β€” once per move, - so the busier the conversation the more of them. Pinned to where the - turn began there instead: less precise about where the agent is up to, - and it costs the reader nothing. - """ - if await self._leaves_a_tombstone(channel_id): - return - await super()._reposition_runtime_state(channel_id, agent_name, thread_root_id) - - async def _remove_runtime_indicator( - self, channel_id: str, message_ref: str - ) -> None: - """Drop a superseded indicator without letting a delete failure escape. - - Unlike the other adapters, Teams' delete raises β€” on a missing connector - and on any non-2xx from the Bot Connector. When the indicator has - already been reposted elsewhere, a failure here means a stale duplicate - is left visible, which is preferable to aborting the turn.""" - try: - await self.delete_message(channel_id, message_ref) - except (RuntimeError, httpx.HTTPError) as e: - logger.warning( - "Could not remove the superseded runtime indicator %s in %s (%s); " - "a stale copy may remain visible", - message_ref, - channel_id, - e, - ) - - async def _clear_input_pings(self, channel_id: str, agent_name: str) -> None: - """Resolve the operator pings raised during the turn. - - Edited rather than removed wherever a delete would leave a tombstone β€” - and for a ping that matters more than for the status line, since the - people looking at it are exactly the ones it was aimed at.""" - refs = self._input_pings.pop((channel_id, agent_name), []) - if not refs: - return - scars = await self._leaves_a_tombstone(channel_id) - for ref in refs: - if scars: - await self._refresh_card( - channel_id, - ref, - agent_name, - self.translate_outbound("βœ“ Input received"), - ) - else: - await self.delete_message(channel_id, ref) - # ── Channels ───────────────────────────────────────────────────────────── @staticmethod diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 40fe2500c..8e01c7464 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -427,12 +427,6 @@ class TelegramAdapter(CollaborationAdapter): #: account that added it, so there is one mark between them all. activity_reactions_per_agent: ClassVar[bool] = False - #: Off, because the SDK publication draws the status. This adapter has no - #: legacy renderer left to run, but the base class defaults the flag on for - #: the platforms that still do, so saying so here is what keeps the base - #: class's own fallback from drawing a second account of the turn. - renders_legacy_runtime_state: ClassVar[bool] = False - # Telegram's is the one disclosure that has been agreed: T2, accepted for # this platform on this platform's evidence. It does not travel to another # adapter that happens to share the inability to search. @@ -2712,11 +2706,7 @@ def _attribute(cls, sender_name: str, label: str, content: str) -> str: zero-width space is not an entity, so the two never compound. This prefix is finished HTML β€” assembled after `translate_outbound` has already run over `content`, and never fed back through it β€” so - `html.escape` is the whole of the tag escaping it needs. The ping line - the base `_ping_operator` builds is the other pipeline: it inlines the - same escaped label into Markdown source and lets `translate_outbound` - escape the whole line. Neither pipeline knows about the other and each - escapes exactly once.""" + `html.escape` is the whole of the tag escaping it needs.""" name = ( f"{cls._agent_marker(sender_name)} {html.escape(label, quote=False)}" ) diff --git a/core/switch_core/clients/bridge_client.py b/core/switch_core/clients/bridge_client.py index 6713a6c3b..9a781fde0 100644 --- a/core/switch_core/clients/bridge_client.py +++ b/core/switch_core/clients/bridge_client.py @@ -8,7 +8,6 @@ ClientBaseKwargs, ClientConfig, ) -from switch_core.events import AgentRuntimeStateEvent from switch_core.transport import InboundMedia, InboundMessage, RoomRef if TYPE_CHECKING: @@ -48,8 +47,3 @@ async def on_media(self, room: RoomRef, event: InboundMedia) -> None: event.sender, ) await self._bridge_core.handle_outbound_media(room, event, self) - - async def on_agent_runtime_state( - self, room: RoomRef, event: AgentRuntimeStateEvent - ) -> None: - await self._bridge_core.handle_agent_runtime_state(room, event) diff --git a/core/tests/switch_core/bridges/collaboration/test_adapter_helpers.py b/core/tests/switch_core/bridges/collaboration/test_adapter_helpers.py deleted file mode 100644 index 0e7989526..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_adapter_helpers.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Shared helpers on the collaboration adapter base class. - -These have no platform of their own, so they are tested here rather than in -whichever adapter happened to call them first. -""" - -from __future__ import annotations - -from switch_core.bridges.collaboration.adapter import format_elapsed - - -def test_elapsed_is_written_the_way_it_is_read() -> None: - assert format_elapsed(0.4) == "0s" - assert format_elapsed(8.9) == "8s" - assert format_elapsed(59) == "59s" - assert format_elapsed(60) == "1m00s" - assert format_elapsed(134) == "2m14s" - assert format_elapsed(3600) == "1h00m" - assert format_elapsed(3780) == "1h03m" - # A clock that ran backwards is not a negative duration. - assert format_elapsed(-5) == "0s" diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py index c1233ef3c..ae093d044 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py @@ -989,6 +989,9 @@ async def on_message(msg: InboundMessage) -> None: ("plain", "plain"), ("Bo*b", r"Bo\*b"), ("@here", "@\u200bhere"), + # A bare handle needs no Discord syntax at all, so `escape_mentions` + # leaves it alone and only the base class's `@` rule defuses it. + ("@opsbot", "@\u200bopsbot"), ("<@123456789012345678>", "<\u200b@\u200b123456789012345678>"), ("a`b`c", r"a\`b\`c"), # Everything else Discord resolves from `<…>`: a channel link, a @@ -1006,51 +1009,6 @@ def test_discord_body_escape_cases(label: str, expected: str) -> None: assert adapter.escape_label_for_body(label) == expected -def test_a_discord_display_name_cannot_forge_a_role_mention() -> None: - """`escape_mentions` breaks Discord's own `@everyone`/`<@id>` syntax, but a - label needs no Discord syntax: `translate_outbound` resolves a bare handle - into a real mention after the label is already in the body.""" - bridge = _bridge(_agent("switchdev", "@opsbot"), _agent("opsbot", None)) - adapter, channel, _dm = _discord_adapter(bridge) - adapter._agent_role_ids["opsbot"] = 4242 - - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "switchdev", - "awaiting-input", - mention_handle="123", - thread_root_id=None, - deeplink_url=None, - ) - ) - - content = channel.webhook.sent[-1]["content"] - assert "<@&4242>" not in content - assert "@\u200bopsbot" in content - - -def test_a_discord_display_name_cannot_forge_a_user_mention() -> None: - bridge = _bridge(_agent("switchdev", "@alice")) - adapter, channel, _dm = _discord_adapter(bridge) - adapter._username_to_id["alice"] = 777 - - _run( - adapter._apply_runtime_state( - str(CHANNEL_ID), - "switchdev", - "awaiting-input", - mention_handle="123", - thread_root_id=None, - deeplink_url=None, - ) - ) - - content = channel.webhook.sent[-1]["content"] - assert "<@777>" not in content - assert "@\u200balice" in content - - # ── Teams ──────────────────────────────────────────────────────────────────── @@ -1142,32 +1100,6 @@ def test_teams_card_names_the_agent_by_its_identifier_without_one() -> None: assert activity["summary"] == "worker: hello" -def test_teams_awaiting_input_ping_uses_the_display_name_in_both_header_and_body() -> ( - None -): - """The ping is a card like any other message, so the label has to reach the - header AND the prose the base class writes β€” the identifier neither.""" - bridge = _bridge(_agent("switchdev", "Switch Dev")) - adapter, connector = _teams_adapter(bridge) - - _run( - adapter._apply_runtime_state( - TEAMS_CHAT, - "switchdev", - "awaiting-input", - mention_handle="Alice Example", - thread_root_id=None, - deeplink_url=None, - ) - ) - - activity = connector.sends[-1] - assert _teams_header(activity) == "Switch Dev" - assert "Switch Dev" in _teams_body(activity) - assert "switchdev" not in _teams_body(activity) - assert "switchdev" not in _teams_header(activity) - - def test_teams_resolves_an_agent_once_per_send() -> None: """The card needs the label and the avatar; they come off one row, so the single icon lookup this replaced must not have become two.""" @@ -1179,54 +1111,21 @@ def test_teams_resolves_an_agent_once_per_send() -> None: assert bridge._agent_store.lookups == ["worker"] # type: ignore[attr-defined] -def test_a_teams_display_name_cannot_forge_a_mention_of_a_real_person() -> None: - """`@alice` in a display name is a ping of whoever alice is, not a cosmetic - bug: the base ping inlines the label into Markdown source that - `translate_outbound` then runs the mention pass over, so both halves of a - real Teams mention are forgeable from a name alone.""" - bridge = _bridge(_agent("switchdev", "@alice the Bot")) - adapter, connector = _teams_adapter(bridge) - adapter.prime_mention_targets({"alice": "aad-alice"}) - - _run( - adapter._apply_runtime_state( - TEAMS_CHAT, - "switchdev", - "awaiting-input", - mention_handle=None, - thread_root_id=None, - deeplink_url=None, - ) - ) - - activity = connector.sends[-1] - assert "msteams" not in _teams_card(activity) - assert "" not in _teams_body(activity) - assert "" not in _teams_header(activity) - - def test_a_teams_display_name_cannot_carry_the_mention_markup_itself() -> None: - """The other half of the same hole: `…` written straight into a - name skips the marking pass, but the entity pass reads the markup back out - of the rendered body and pairs it.""" + """`…` written straight into a name skips the marking pass that + turns `@name` into mention markup, so the defusal has to hold on the way in + as well: what `_mention_entities` scans is the rendered card, and a tag it + can pair with a target becomes a real ping of that person.""" bridge = _bridge(_agent("switchdev", "alice")) adapter, connector = _teams_adapter(bridge) adapter.prime_mention_targets({"alice": "aad-alice"}) - _run( - adapter._apply_runtime_state( - TEAMS_CHAT, - "switchdev", - "awaiting-input", - mention_handle=None, - thread_root_id=None, - deeplink_url=None, - ) - ) + _run(adapter.send_message(TEAMS_CHAT, "switchdev", "hello")) - activity = connector.sends[-1] + activity = connector.sends[0] assert "msteams" not in _teams_card(activity) - assert "alice" not in _teams_body(activity) + assert "alice" not in _teams_header(activity) + assert "alice" not in _teams_card(activity)["fallbackText"] def test_teams_still_delivers_a_mention_the_agent_wrote() -> None: @@ -1427,30 +1326,6 @@ def test_telegram_escapes_a_display_name_exactly_once_in_the_prefix() -> None: assert "&amp;" not in text -def test_telegram_escapes_a_display_name_exactly_once_in_the_ping() -> None: - """The other pipeline, and the one that double-escaped: the base builds the - ping text around the label and hands the whole line to `translate_outbound`, - which escapes it. Both halves of this message carry the same name, and both - must have been escaped once.""" - bridge = _bridge(_agent("switchdev", "R&D ")) - adapter, bot = _telegram_adapter(bridge) - - _run( - adapter._apply_runtime_state( - TELEGRAM_CHAT, - "switchdev", - "awaiting-input", - mention_handle="opslead", - thread_root_id=None, - ) - ) - - text = bot.messages[-1]["text"] - assert text.count("R&D <Bot>") == 2 - assert "&amp;" not in text - assert "<b>" not in text - - def test_telegram_marks_two_agents_sharing_a_display_name_apart() -> None: """The mark is the only thing distinguishing agents under one bot identity. Keyed on the label, two agents a person happened to name the same would be @@ -1490,8 +1365,8 @@ def test_a_telegram_agents_mark_does_not_move_when_it_is_renamed() -> None: "[Switch Support](https://evil.example)", "[Switch Support]\u200b(https://evil.example)", ), - # No entities inserted, which is what lets the prefix and the ping each - # stay a single escape. + # No entities inserted, which is what lets the prefix stay a single + # escape. ("R&D ", "R&D "), ], ) @@ -1500,51 +1375,6 @@ def test_telegram_body_escape_cases(label: str, expected: str) -> None: assert adapter.escape_label_for_body(label) == expected -def test_a_telegram_display_name_cannot_forge_a_mention_of_a_real_person() -> None: - """A `tg://user?id=` anchor is a hard mention: Telegram notifies that - account whatever the visible text says. The ping inlines the label into - Markdown source the mention pass then runs over, so one is forgeable from a - display name alone.""" - bridge = _bridge(_agent("switchdev", "@ceo_person")) - adapter, bot = _telegram_adapter(bridge) - adapter._username_to_id["ceo_person"] = 777 - - _run( - adapter._apply_runtime_state( - TELEGRAM_CHAT, - "switchdev", - "awaiting-input", - mention_handle="opslead", - thread_root_id=None, - ) - ) - - text = bot.messages[-1]["text"] - assert "tg://user?id=777" not in text - assert "tg://user" not in text - - -def test_a_telegram_display_name_cannot_forge_a_link() -> None: - """Both halves of `[text](url)` are the name's to choose, and the anchor - lands in the sentence naming who is speaking.""" - bridge = _bridge(_agent("switchdev", "[Switch Support](https://evil.example)")) - adapter, bot = _telegram_adapter(bridge) - - _run( - adapter._apply_runtime_state( - TELEGRAM_CHAT, - "switchdev", - "awaiting-input", - mention_handle="opslead", - thread_root_id=None, - ) - ) - - text = bot.messages[-1]["text"] - assert 'href="https://evil.example"' not in text - assert "
None: """The prefix is message text, not a name field, so it takes the escaped label too. Telegram links a bare `@handle` out of ordinary text with no @@ -1691,81 +1521,6 @@ def test_mattermost_body_escape_cases(label: str, expected: str) -> None: assert adapter.escape_label_for_body(label) == expected -def test_mattermost_awaiting_input_ping_uses_the_display_name() -> None: - bridge = _bridge(_agent("switchdev", "Switch Dev")) - adapter, sent = _mattermost_adapter(bridge) - - _run( - adapter._apply_runtime_state( - MATTERMOST_CHANNEL, - "switchdev", - "awaiting-input", - mention_handle="opslead", - thread_root_id=None, - ) - ) - - assert "**Switch Dev**" in sent[-1] - assert "switchdev" not in sent[-1] - - -def test_a_mattermost_display_name_cannot_address_the_whole_channel() -> None: - """Switch ships Markdown verbatim, so an undefused `@channel` in the label - is resolved by Mattermost itself and notifies everyone in the room.""" - bridge = _bridge(_agent("switchdev", "@channel")) - adapter, sent = _mattermost_adapter(bridge) - - _run( - adapter._apply_runtime_state( - MATTERMOST_CHANNEL, - "switchdev", - "awaiting-input", - mention_handle="opslead", - thread_root_id=None, - ) - ) - - assert "@\u200bchannel" in sent[-1] - assert "@channel" not in sent[-1] - - -def test_a_mattermost_display_name_cannot_forge_a_link() -> None: - bridge = _bridge(_agent("switchdev", "[Switch Support](https://evil.example)")) - adapter, sent = _mattermost_adapter(bridge) - - _run( - adapter._apply_runtime_state( - MATTERMOST_CHANNEL, - "switchdev", - "awaiting-input", - mention_handle="opslead", - thread_root_id=None, - ) - ) - - assert "](https://evil.example)" not in sent[-1] - assert "]\u200b(https://evil.example)" in sent[-1] - - -def test_mattermost_still_delivers_the_owner_ping() -> None: - """The defusal lands on the label alone β€” the operator handle the ping is - built around is not the label and must still resolve.""" - bridge = _bridge(_agent("switchdev", "@channel")) - adapter, sent = _mattermost_adapter(bridge) - - _run( - adapter._apply_runtime_state( - MATTERMOST_CHANNEL, - "switchdev", - "awaiting-input", - mention_handle="opslead", - thread_root_id=None, - ) - ) - - assert sent[-1].startswith("@opslead ") - - # ── Mattermost bot identities ──────────────────────────────────────────────── # # Mattermost is the one platform that gives an agent an account of its own. Its diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_indicator_position.py b/core/tests/switch_core/bridges/collaboration/test_bridge_indicator_position.py deleted file mode 100644 index 2f269aa25..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_indicator_position.py +++ /dev/null @@ -1,205 +0,0 @@ -from __future__ import annotations - -import asyncio -from types import SimpleNamespace -from typing import Any - -from switch_core.bridges.collaboration.bridge_core import BridgeCore - - -class _FakeAdapter: - def __init__(self, live: list[str]) -> None: - self._live = live - self.moved: list[tuple[str, str]] = [] - self.targets: list[str | None] = [] - - def agents_with_live_runtime_state(self, channel_id: str) -> list[str]: - return list(self._live) - - async def reposition_runtime_state( - self, channel_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - self.moved.append((channel_id, agent_name)) - self.targets.append(thread_root_id) - - -def _bridge(live: list[str]) -> Any: - """A BridgeCore stand-in wired to the real positioning methods.""" - adapter = _FakeAdapter(live) - ns = SimpleNamespace( - _adapter=adapter, - _indicator_move_timers={}, - _indicator_move_targets={}, - _reported_anchors={}, - adapter_spy=adapter, - ) - for name in ( - "_move_indicator_for_sender", - "_schedule_indicator_move", - "_run_indicator_move", - "_follow_reported_anchor", - ): - setattr(ns, name, getattr(BridgeCore, name).__get__(ns)) - return ns - - -def _drain(bridge: Any, coro: Any) -> list[tuple[str, str]]: - """Run `coro`, then let every queued move fire, and report what moved.""" - - async def _go() -> None: - await coro - for timer in list(bridge._indicator_move_timers.values()): - timer.cancel() - for key in list(bridge._indicator_move_timers): - await bridge._run_indicator_move(key) - - asyncio.run(_go()) - return bridge.adapter_spy.moved - - -def _anchor( - bridge: Any, anchor_event_id: str | None, *, thread: str | None = None -) -> Any: - return bridge._follow_reported_anchor( - "chan-1", "agent-a", "working", anchor_event_id, thread - ) - - -# ── Inbound: driven by what the agent reports it has received ─────────────── - - -def test_a_new_reported_anchor_moves_the_indicator() -> None: - bridge = _bridge(["agent-a"]) - - async def go() -> None: - await _anchor(bridge, "$m1") - await _anchor(bridge, "$m2") - - assert _drain(bridge, go()) == [("chan-1", "agent-a")] - - -def test_the_first_anchor_of_a_turn_moves_nothing() -> None: - # The indicator was only just posted against that message. - bridge = _bridge(["agent-a"]) - - assert _drain(bridge, _anchor(bridge, "$m1")) == [] - - -def test_repeating_the_same_anchor_moves_nothing() -> None: - # The 5s activity refresh replays the current anchor; it must not churn. - bridge = _bridge(["agent-a"]) - - async def go() -> None: - await _anchor(bridge, "$m1") - for _ in range(5): - await _anchor(bridge, "$m1") - - assert _drain(bridge, go()) == [] - - -def test_a_message_the_agent_has_not_been_given_moves_nothing() -> None: - # The whole point of the redesign: arriving in the room is not evidence the - # agent saw it, so only what the agent reports may move the indicator. - bridge = _bridge(["agent-a"]) - - async def go() -> None: - await _anchor(bridge, "$m1") - # A message lands in the room, but the agent is busy and is never - # handed it β€” so no new anchor is ever reported. - await _anchor(bridge, "$m1") - - assert _drain(bridge, go()) == [] - - -def test_a_report_without_an_anchor_moves_nothing() -> None: - # Connectors that don't report anchors simply never reposition. - bridge = _bridge(["agent-a"]) - - async def go() -> None: - await _anchor(bridge, "$m1") - await _anchor(bridge, None) - - assert _drain(bridge, go()) == [] - - -def test_the_move_lands_in_the_thread_of_the_reported_message() -> None: - bridge = _bridge(["agent-a"]) - - async def go() -> None: - await _anchor(bridge, "$m1", thread=None) - await _anchor(bridge, "$m2", thread="root-7") - - _drain(bridge, go()) - - assert bridge.adapter_spy.targets == ["root-7"] - - -def test_the_anchor_resets_when_the_turn_ends() -> None: - # A later turn's first anchor must not be mistaken for a move within the - # previous one. - bridge = _bridge(["agent-a"]) - - async def go() -> None: - await _anchor(bridge, "$m1") - await bridge._follow_reported_anchor("chan-1", "agent-a", "idle", None, None) - await _anchor(bridge, "$m2") - - assert _drain(bridge, go()) == [] - - -# ── Outbound: the agent's own messages ────────────────────────────────────── - - -def test_indicator_follows_a_message_the_agent_posts() -> None: - # No ambiguity here β€” the agent demonstrably acted, so core may move it. - bridge = _bridge(["agent-a"]) - - moved = _drain(bridge, bridge._move_indicator_for_sender("chan-1", "agent-a", None)) - - assert moved == [("chan-1", "agent-a")] - - -def test_another_agents_message_does_not_move_this_indicator() -> None: - bridge = _bridge(["agent-a"]) - - moved = _drain(bridge, bridge._move_indicator_for_sender("chan-1", "agent-b", None)) - - assert moved == [] - - -def test_nothing_moves_when_no_indicator_is_live() -> None: - bridge = _bridge([]) - - moved = _drain(bridge, bridge._move_indicator_for_sender("chan-1", "agent-a", None)) - - assert moved == [] - - -def test_a_burst_of_agent_posts_costs_a_single_move() -> None: - bridge = _bridge(["agent-a"]) - scheduled: list[Any] = [] - - async def burst() -> None: - for _ in range(20): - await bridge._move_indicator_for_sender("chan-1", "agent-a", None) - scheduled.append(bridge._indicator_move_timers[("chan-1", "agent-a")]) - - moved = _drain(bridge, burst()) - - assert all(timer is scheduled[0] for timer in scheduled) - assert moved == [("chan-1", "agent-a")] - - -def test_a_platform_failure_during_a_move_does_not_escape() -> None: - # The indicator is cosmetic; a failed move must not propagate into the - # bridge callback that happened to trigger it. - bridge = _bridge(["agent-a"]) - - async def boom( - _channel_id: str, _agent_name: str, _thread_root_id: str | None - ) -> None: - raise RuntimeError("platform down") - - bridge._adapter.reposition_runtime_state = boom - - asyncio.run(bridge._run_indicator_move(("chan-1", "agent-a"))) diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_outbound_admin_rendering.py b/core/tests/switch_core/bridges/collaboration/test_bridge_outbound_admin_rendering.py index 97156798f..74b16c71e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_outbound_admin_rendering.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_outbound_admin_rendering.py @@ -73,7 +73,6 @@ def _bridge(adapter: _RecordingAdapter) -> BridgeCore: core._channel_to_room = {"C1": ("room-uuid", "!r:switch.local")} # type: ignore[assignment] core._room_tenant = _tenant # type: ignore[assignment] core._record_message_map = _noop # type: ignore[assignment] - core._move_indicator_for_sender = _noop # type: ignore[assignment] core._outbound_thread_root_ref = _none # type: ignore[assignment] return core diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_outbound_media.py b/core/tests/switch_core/bridges/collaboration/test_bridge_outbound_media.py index 7b9ab498c..5818799f3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_outbound_media.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_outbound_media.py @@ -63,9 +63,6 @@ def __init__(self) -> None: self.batches: list[dict[str, Any]] = [] self.messages: list[dict[str, Any]] = [] - def agents_with_live_runtime_state(self, channel_id: str) -> list[str]: - return [] - async def send_attachment( self, channel_id, @@ -147,7 +144,6 @@ async def _room_tenant(room_id: str) -> str: recorded=recorded, _outbound_groups={}, _outbound_group_timers={}, - _indicator_move_timers={}, _channel_to_room={"chan-1": ("room-uuid", "!room:s")}, _room_tenant=_room_tenant, ) @@ -164,8 +160,6 @@ async def _room_tenant(room_id: str) -> str: ) ns._relay_outbound_group = BridgeCore._relay_outbound_group.__get__(ns) ns._relay_outbound_media = BridgeCore._relay_outbound_media.__get__(ns) - ns._move_indicator_for_sender = BridgeCore._move_indicator_for_sender.__get__(ns) - ns._schedule_indicator_move = BridgeCore._schedule_indicator_move.__get__(ns) ns._flush_incomplete_outbound_group = ( BridgeCore._flush_incomplete_outbound_group.__get__(ns) ) diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_runtime_state_thread.py b/core/tests/switch_core/bridges/collaboration/test_bridge_runtime_state_thread.py deleted file mode 100644 index 9529a58a7..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_runtime_state_thread.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Where a runtime status surfaces when the agent was addressed at the root. - -A runtime-state report only carries a `thread_id` when the agent was addressed -inside an existing thread. Message the agent at channel level and it carries -none β€” yet the agent's reply still opens a thread on the triggering message. The -status and the answer to it then sit in two different places, which is what the -reader actually notices: a "working on it…" line stranded in the channel while -the reply is somewhere behind a "1 reply" link. - -The report does say which message the agent is working on β€” the anchor β€” so on -an adapter that asks for it that stands in for the missing thread. These pin -down that it is used only where it was asked for, and never over a real thread. -""" - -from __future__ import annotations - -import asyncio -from types import SimpleNamespace -from typing import Any - -from switch_core.bridges.collaboration.bridge_core import BridgeCore -from switch_core.events import AgentRuntimeStateEvent - - -class _FakeAdapter: - def __init__(self, *, follows_anchor: bool) -> None: - self.runtime_state_follows_anchor = follows_anchor - self.applied: list[str | None] = [] - self.trigger_threads: list[str | None] = [] - self.anchors: list[str | None] = [] - - def agents_with_live_runtime_state(self, channel_id: str) -> list[str]: - return [] - - async def apply_runtime_state( - self, - channel_id: str, - agent_name: str, - state: str, - *, - mention_handle: str | None, - thread_root_id: str | None, - deeplink_url: str | None, - detail: str | None, - trigger_thread_root_id: str | None = None, - anchor_message_ref: str | None = None, - ) -> None: - self.applied.append(thread_root_id) - self.trigger_threads.append(trigger_thread_root_id) - self.anchors.append(anchor_message_ref) - - async def reposition_runtime_state( - self, channel_id: str, agent_name: str, thread_root_id: str | None - ) -> None: - return None - - -def _bridge(*, follows_anchor: bool, posts: dict[str, str]) -> Any: - """A BridgeCore stand-in wired to the real runtime-state handler. - - `posts` is the message map: Matrix event id -> external post id. - """ - adapter = _FakeAdapter(follows_anchor=follows_anchor) - - async def external_post_for_matrix_event(event_id: str) -> str | None: - return posts.get(event_id) - - async def room_tenant(room_id: str) -> str: - return "tenant-1" - - ns = SimpleNamespace( - _adapter=adapter, - _indicator_move_timers={}, - _indicator_move_targets={}, - _reported_anchors={}, - _find_channel=lambda **kwargs: "chan-1", - _channel_to_room={"chan-1": ("room-1", "!room:switch.local")}, - _room_tenant=room_tenant, - _external_post_for_matrix_event=external_post_for_matrix_event, - adapter_spy=adapter, - ) - for name in ( - "handle_agent_runtime_state", - "_apply_runtime_state", - "_follow_reported_anchor", - "_schedule_indicator_move", - "_run_indicator_move", - ): - setattr(ns, name, getattr(BridgeCore, name).__get__(ns)) - return ns - - -def _report( - bridge: Any, *, thread_id: str | None, anchor_event_id: str | None -) -> str | None: - """Deliver one "working" report and return where the status was put.""" - event = AgentRuntimeStateEvent( - agent_id="agent-1", - agent_name="worker", - room_id="!room:switch.local", - state="working", - thread_id=thread_id, - anchor_event_id=anchor_event_id, - ) - room = SimpleNamespace(room_id="!room:switch.local") - asyncio.run(bridge.handle_agent_runtime_state(room, event)) - return bridge.adapter_spy.applied[-1] - - -def test_the_status_joins_the_thread_the_reply_will_open() -> None: - # Addressed at the channel root: no thread of its own, but the anchor names - # the message being worked on, and that is where the reply goes. - bridge = _bridge(follows_anchor=True, posts={"$trigger": "post-trigger"}) - - assert _report(bridge, thread_id=None, anchor_event_id="$trigger") == "post-trigger" - - -def test_a_real_thread_still_wins_over_the_anchor() -> None: - # Addressed inside a thread, having since been handed a newer message. The - # thread the turn belongs to is the one it was started in. - bridge = _bridge( - follows_anchor=True, - posts={"$thread": "post-thread", "$newer": "post-newer"}, - ) - - assert _report(bridge, thread_id="$thread", anchor_event_id="$newer") == ( - "post-thread" - ) - - -def test_an_adapter_that_did_not_ask_keeps_the_status_at_the_root() -> None: - # Slack renders a thread in a side panel, so moving the status into one - # hides it. Only an adapter that opts in gets the fallback. - bridge = _bridge(follows_anchor=False, posts={"$trigger": "post-trigger"}) - - assert _report(bridge, thread_id=None, anchor_event_id="$trigger") is None - - -def test_an_unmapped_anchor_falls_back_to_the_root() -> None: - # The triggering post was never relayed through this bridge, so there is no - # post to hang the thread on. Root beats guessing. - bridge = _bridge(follows_anchor=True, posts={}) - - assert _report(bridge, thread_id=None, anchor_event_id="$unknown") is None - - -def test_a_report_with_neither_stays_at_the_root() -> None: - bridge = _bridge(follows_anchor=True, posts={"$trigger": "post-trigger"}) - - assert _report(bridge, thread_id=None, anchor_event_id=None) is None - - -def test_the_trigger_keeps_its_own_place_even_as_the_status_moves() -> None: - # The two are reported separately: the status goes into the thread the - # answer will open, the trigger says where the person waiting is looking. - # Addressed at channel level, that is the root β€” so no thread. - bridge = _bridge(follows_anchor=True, posts={"$trigger": "post-trigger"}) - - _report(bridge, thread_id=None, anchor_event_id="$trigger") - - assert bridge.adapter_spy.applied == ["post-trigger"] - assert bridge.adapter_spy.trigger_threads == [None] - - -def test_a_threaded_trigger_reports_its_thread() -> None: - bridge = _bridge(follows_anchor=True, posts={"$thread": "post-thread"}) - - _report(bridge, thread_id="$thread", anchor_event_id="$thread") - - assert bridge.adapter_spy.trigger_threads == ["post-thread"] - - -# ── The message being answered, as distinct from where the status goes ─────── - - -def test_the_answered_message_is_reported_even_at_the_channel_root() -> None: - """Marking a message and moving the status onto it are separate choices. - - Discord leaves the status where the conversation is and puts a reaction on - the message instead, so it needs the anchor without opting into the move. - """ - bridge = _bridge(follows_anchor=False, posts={"$trigger": "post-trigger"}) - - _report(bridge, thread_id=None, anchor_event_id="$trigger") - - assert bridge.adapter_spy.applied == [None] - assert bridge.adapter_spy.anchors == ["post-trigger"] - - -def test_an_unmapped_answered_message_is_reported_as_absent() -> None: - bridge = _bridge(follows_anchor=False, posts={}) - - _report(bridge, thread_id=None, anchor_event_id="$unknown") - - assert bridge.adapter_spy.anchors == [None] diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py index fdd58134f..5cac95b50 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py @@ -1122,35 +1122,3 @@ async def scenario() -> None: assert adapter._client is None _run(scenario()) - - -def test_awaiting_input_with_nobody_linked_says_so() -> None: - """`_ping_operator` is shared and still used by the platforms that have - not migrated, so it is exercised here through a real adapter's delivery. - - The ping used to post with the mention simply missing, which on the channel - reads exactly like a ping that worked β€” an agent waiting on input nobody - knows to give. The handle is the agent owner's linked account, so "nobody" - means the owner has not said which account here is theirs, and the line - says that instead of trailing off. - """ - adapter = _adapter() - adapter._client = _FakeClient({CHANNEL_ID: _FakeChannel()}) - webhook = _FakeWebhook() - adapter._webhooks[(CHANNEL_ID, _WEBHOOK_NAME)] = webhook - - _run( - adapter._ping_operator( - str(CHANNEL_ID), - "my-agent", - None, - None, - ) - ) - - content = webhook.sent[-1]["content"] - assert "needs your input" in content - assert "pings no one" in content - # Named as a person would name it, not as the class is. - assert "Discord" in content - assert "Adapter" not in content diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index 9920ac5f1..895c1cc63 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -5,9 +5,8 @@ in place, taken down at the end of a turn where a thread is not holding it, found again after an uncertain delivery, and loud when any of that fails. -The old runtime-state renderer is still in the file (removing it is its own -task) but nothing routes to it any more. The first test holds that line: the -two renderers must not both draw, or every turn appears twice. +There is no longer a second renderer anywhere to fall back to, so what the +publication draws is the whole of what a channel sees of a turn. """ from __future__ import annotations @@ -350,28 +349,16 @@ async def _card(**kwargs: Any) -> RequestCard: return RequestCard(request, RequestReference(token="tok-1", handle="R7"), **kwargs) -# ── No legacy renderer ─────────────────────────────────────────────────────── +# ── The publication is the only account of the turn ────────────────────────── -async def test_nothing_draws_a_second_account_of_the_turn() -> None: - """This adapter's own renderer is gone, but the base class still defaults - the flag on for the platforms that have one, so the declaration is what - keeps the inherited fallback from drawing the turn a second time.""" - adapter, channel, _thread, webhook = _guild_setup() - - for state in ("working", "awaiting-input", "idle"): - await adapter.apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - state, - mention_handle="someone", - thread_root_id=None, - ) - await adapter.reposition_runtime_state(str(CHANNEL_ID), "my-agent", None) +async def test_the_publication_is_the_only_account_of_a_turn() -> None: + """There is no second renderer to fall back to, and `bridge_core` reads + this flag to decide whether to route sessions here at all β€” so a platform + that stopped declaring it would go quiet rather than draw the turn some + other way.""" + adapter, _channel, _thread, _webhook = _guild_setup() - assert webhook.sent == [] - assert channel.sent == [] - assert adapter.renders_legacy_runtime_state is False assert adapter.publishes_sdk_sessions is True @@ -418,6 +405,29 @@ async def test_a_card_names_the_asker_and_prints_the_handle_it_answers_to() -> N assert "request `R7`" in content +async def test_a_card_nobody_can_be_notified_about_says_so_on_the_card() -> None: + """A card posted with the mention simply missing reads on the channel + exactly like one that reached someone β€” an agent waiting on input nobody + knows to give. The handle is the agent owner's linked account, so nobody to + name means the owner has not said which account here is theirs, and the + card says that instead of trailing off. + """ + adapter, _channel, _thread, webhook = _guild_setup() + + await adapter.post_rich( + str(CHANNEL_ID), + "my-agent", + await _card(notify_unreachable=True), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + ) + + content = webhook.sent[0]["content"] + assert "this notified no one" in content + # Named as a person would name it, not as the class is. + assert "Link your Discord account" in content + assert "Adapter" not in content + + async def test_a_dm_inlines_the_agent_name_because_there_is_no_webhook() -> None: dm = _DMChannel() adapter = _adapter({DM_CHANNEL_ID: dm}) diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index 21729f168..880074edb 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -5,9 +5,8 @@ again after an uncertain delivery, and β€” unlike the old status line β€” loud when any of that fails. -The old runtime-state renderer is still in the file (removing it is its own -task) but nothing routes to it any more. The first test holds that line: the -two renderers must not both draw, or every turn appears twice. +There is no longer a second renderer anywhere to fall back to, so what the +publication draws is the whole of what a channel sees of a turn. """ from __future__ import annotations @@ -213,30 +212,6 @@ async def _card(**kwargs: Any) -> RequestCard: return RequestCard(request, RequestReference(token="tok-1", handle="R7"), **kwargs) -# ── The legacy renderer is off ─────────────────────────────────────────────── - - -async def test_the_legacy_renderer_no_longer_draws_alongside_the_sdk_one() -> None: - """Both would draw the same turn, and the channel would show it twice.""" - adapter = _adapter() - - for state in ("working", "awaiting-input", "idle"): - await adapter.apply_runtime_state( - "chan-1", - "worker", - state, - mention_handle="@owner", - thread_root_id="root-1", - detail="Private legacy status", - ) - await adapter.reposition_runtime_state("chan-1", "worker", "root-2") - - assert _posts(adapter).created == [] - assert _posts(adapter).patched == [] - assert adapter._working_msg == {} - assert adapter._runtime_locks == {} - - # ── Posting ────────────────────────────────────────────────────────────────── 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 deleted file mode 100644 index 2718cd774..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_runtime_indicator_race.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Moving the runtime indicator and refreshing it must not interleave. - -Two independent callers mutate ``_working_msg`` for the same agent: the -periodic activity refresh (``apply_runtime_state`` with ``working``, every few -seconds) and a reposition triggered by new traffic. Both read the entry, await -a platform call, then write it back. - -Interleaved, the refresh's write lands after the move's and restores the -superseded message ref. The entry then points at a message the move has just -deleted, and the message the move posted is referenced by nothing β€” so the -end-of-turn clear cannot remove it and it stays in the channel forever. - -The lock and the reposition are both the base class's, and Teams is the one -adapter still rendering through them, so that is what these run against. A -**chat**-layout channel is the case that repositions at all: in a posts channel -Teams declines the move rather than leave a tombstone per hop. - -The invariant each test asserts is the same: whatever is still posted on the -platform is exactly what the adapter thinks is posted. -""" - -from __future__ import annotations - -import asyncio -import time -from typing import Any, ClassVar - -from switch_core.bridges.collaboration.adapter import LiveRuntimeIndicator -from switch_core.bridges.collaboration.teams.adapter import ( - TeamsAdapter, - TeamsConnectionConfig, -) - - -class _LegacyIndicator(TeamsAdapter): - """Teams with the legacy runtime indicator still switched on. - - The lock these tests are about lives in the public `apply_runtime_state` / - `reposition_runtime_state`, above the flag that now turns the whole path - off β€” so calling the adapter's own `_apply_runtime_state` instead would - bypass the very thing under test. Re-enabling the flag keeps both callers - going through the real entry point. - """ - - renders_legacy_runtime_state: ClassVar[bool] = True - - -CHANNEL = "19:abc@thread.tacv2" -AGENT = "worker" -KEY = (CHANNEL, AGENT) - - -class _Platform: - """Records posts, edits and deletes, yielding once per call. - - The single yield is what lets the two coroutines interleave at all β€” it - stands in for the network round trip each of these calls really makes. - """ - - def __init__(self, adapter: TeamsAdapter, seeded_ref: str) -> None: - self.live: set[str] = {seeded_ref} - self.edits: list[tuple[str, str]] = [] - self._next = iter(f"msg-{n}" for n in range(2, 20)) - - async def send_message( - channel_id: str, - sender_name: str, - content: str, - thread_root_id: str | None = None, - ) -> str | None: - await asyncio.sleep(0) - ref = next(self._next) - self.live.add(ref) - return ref - - async def refresh_card( - channel_id: str, message_ref: str, agent_name: str, body: str - ) -> None: - await asyncio.sleep(0) - self.edits.append((message_ref, body)) - - async def delete_message(channel_id: str, message_ref: str) -> None: - await asyncio.sleep(0) - self.live.discard(message_ref) - - adapter.send_message = send_message # type: ignore[method-assign] - adapter._refresh_card = refresh_card # type: ignore[method-assign] - adapter.delete_message = delete_message # type: ignore[method-assign] - - -class _Graph: - """A chat-layout channel, the one where a delete leaves nothing behind.""" - - async def get_channel(self, *, team_id: str, channel_id: str) -> dict[str, Any]: - return {"id": channel_id, "displayName": "general", "layoutType": "chat"} - - -def _adapter() -> tuple[TeamsAdapter, _Platform]: - adapter = _LegacyIndicator( - config=TeamsConnectionConfig( - app_id="app-123", - app_password="secret", - tenant_id="tenant-9", - team_id="team-7", - public_base_url="https://switch.example", - client_state="s3cr3t", - ) - ) - adapter._graph = _Graph() # type: ignore[assignment] - adapter._default_service_url = "https://smba.example" - adapter._channel_type[CHANNEL] = "channel_public" - adapter._working_msg[KEY] = LiveRuntimeIndicator( - message_ref="msg-1", - body="βš™οΈ _Working on it…_", - thread_root_id=None, - started_at=time.monotonic(), - ) - return adapter, _Platform(adapter, "msg-1") - - -def _refresh(adapter: TeamsAdapter, detail: str) -> Any: - return adapter.apply_runtime_state( - CHANNEL, - AGENT, - "working", - mention_handle=None, - thread_root_id=None, - detail=detail, - ) - - -def _assert_consistent(adapter: TeamsAdapter, platform: _Platform) -> None: - live = adapter._working_msg.get(KEY) - tracked = {live.message_ref} if live is not None else set() - assert platform.live == tracked, ( - f"platform still shows {sorted(platform.live)} but the adapter tracks " - f"{sorted(tracked)} β€” the difference is stranded and nothing will remove it" - ) - - -async def test_a_refresh_landing_during_a_move_does_not_strand_the_new_message() -> ( - None -): - # The reported bug: the refresh read the entry before the move rewrote it, - # so its write restored the old ref and orphaned the message the move - # posted. The turn's clear then removed the already-deleted one. - adapter, platform = _adapter() - - await asyncio.gather( - adapter.reposition_runtime_state(CHANNEL, AGENT, None), - _refresh(adapter, "Ran tool post_message"), - ) - - _assert_consistent(adapter, platform) - - -async def test_a_move_still_moves_when_a_refresh_races_it() -> None: - # The same interleaving in the other order silently abandons the move: the - # indicator stays where it was, which is the whole defect this feature - # exists to fix. - adapter, platform = _adapter() - - await asyncio.gather( - _refresh(adapter, "Ran tool post_message"), - adapter.reposition_runtime_state(CHANNEL, AGENT, None), - ) - - live = adapter._working_msg[KEY] - assert live.message_ref != "msg-1", ( - "the indicator was never repositioned β€” a concurrent activity refresh " - "cancelled the move" - ) - _assert_consistent(adapter, platform) - - -async def test_the_refresh_body_survives_a_concurrent_move() -> None: - # Whichever order they run in, the latest activity line must end up on the - # message that is actually still posted β€” not on a deleted one. - adapter, platform = _adapter() - - await asyncio.gather( - adapter.reposition_runtime_state(CHANNEL, AGENT, None), - _refresh(adapter, "Editing foo.py"), - ) - - live = adapter._working_msg[KEY] - assert "Editing foo.py" in live.body - assert live.message_ref in platform.live - - -async def test_a_burst_of_moves_and_refreshes_leaves_exactly_one_message() -> None: - # The steady state under load: several repositions and refreshes overlapping - # must still converge on a single tracked, still-posted indicator. - adapter, platform = _adapter() - - await asyncio.gather( - *(adapter.reposition_runtime_state(CHANNEL, AGENT, None) for _ in range(4)), - *(_refresh(adapter, f"step {n}") for n in range(4)), - ) - - assert len(platform.live) == 1 - _assert_consistent(adapter, platform) - - -async def test_the_turn_end_clear_removes_everything() -> None: - # After a contended turn, going idle must leave the channel clean. - adapter, platform = _adapter() - - await asyncio.gather( - adapter.reposition_runtime_state(CHANNEL, AGENT, None), - _refresh(adapter, "Ran tool post_message"), - ) - await adapter.apply_runtime_state( - CHANNEL, - AGENT, - "idle", - mention_handle=None, - thread_root_id=None, - ) - - assert platform.live == set() - assert KEY not in adapter._working_msg diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py index 651ddcafa..261cae83a 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py @@ -1,4 +1,4 @@ -"""Slack uses SDK publication without legacy progress or native stop handling.""" +"""Slack uses SDK publication, and handles no native stop event.""" from types import SimpleNamespace from unittest.mock import AsyncMock @@ -27,25 +27,6 @@ def adapter(): return result, client -async def test_legacy_reports_cannot_fall_back_to_status_messages_or_mentions(): - slack, client = adapter() - for state in ("working", "awaiting-input", "idle"): - await slack.apply_runtime_state( - "C1", - "worker", - state, - mention_handle="UOWNER", - thread_root_id="C1:1.0", - deeplink_url="https://example.test", - detail="Private legacy status", - ) - await slack.reposition_runtime_state("C1", "worker", "C1:2.0") - assert not client.posted - assert not client.updated - assert not client.reactions - assert not slack._runtime_locks - - async def test_native_stop_event_is_acknowledged_without_interrupting_an_sdk_turn(): slack, _ = adapter() slack._on_command = AsyncMock() diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py b/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py index 07979b889..feafc7594 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py @@ -893,194 +893,7 @@ async def _resolver(agent_name: str) -> AgentPresentation | None: assert image["url"] == default_icon_url("worker") -# ── Runtime state ──────────────────────────────────────────────────────────── - - -class _CountingConnector: - """Connector fake that returns a distinct id per posted message.""" - - def __init__(self) -> None: - self.threads: list[dict[str, Any]] = [] - self.sends: list[dict[str, Any]] = [] - self.updates: list[dict[str, Any]] = [] - self.deletes: list[str] = [] - self._n = 0 - - def _next(self) -> str: - self._n += 1 - return f"M{self._n}" - - async def create_channel_thread( - self, *, service_url: str, channel_id: str, activity: dict[str, Any] - ) -> tuple[str, str]: - self.threads.append(activity) - mid = self._next() - return f"{channel_id};messageid={mid}", mid - - async def send_to_conversation( - self, *, service_url: str, conversation_id: str, activity: dict[str, Any] - ) -> str: - self.sends.append({"conversation_id": conversation_id, "activity": activity}) - return self._next() - - async def update_activity( - self, - *, - service_url: str, - conversation_id: str, - activity_id: str, - activity: dict[str, Any], - ) -> None: - self.updates.append({"activity_id": activity_id, "activity": activity}) - - async def delete_activity( - self, *, service_url: str, conversation_id: str, activity_id: str - ) -> None: - self.deletes.append(activity_id) - - def _card_text(activity: dict[str, Any]) -> str: """The body an agent card carries, whatever its shape.""" card = activity["attachments"][0]["content"] return "\n".join(str(block.get("text", "")) for block in card["body"]) - - -def _wire_counting(adapter: TeamsAdapter, connector: _CountingConnector) -> None: - adapter._connector = connector # type: ignore[assignment] - adapter._default_service_url = "https://smba.example/amer/" - adapter._channel_type["19:abc@thread.tacv2"] = "channel_public" - - -# The runtime-state tests below drive `_apply_runtime_state` directly: Teams -# publishes SDK sessions now and declares `renders_legacy_runtime_state = -# False`, so the public entry point returns before the adapter is reached. -# The renderer stays until the legacy indicator goes everywhere. - - -def test_working_posts_status_card() -> None: - adapter = _adapter() - fake = _CountingConnector() - _wire_counting(adapter, fake) - - _run( - adapter._apply_runtime_state( - "19:abc@thread.tacv2", - "worker", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - - assert len(fake.threads) == 1 - assert adapter._working_msg[("19:abc@thread.tacv2", "worker")].message_ref == "M1" - - -def test_working_detail_refreshes_in_place() -> None: - adapter = _adapter() - fake = _CountingConnector() - _wire_counting(adapter, fake) - - _run( - adapter._apply_runtime_state( - "19:abc@thread.tacv2", - "worker", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - _run( - adapter._apply_runtime_state( - "19:abc@thread.tacv2", - "worker", - "working", - mention_handle=None, - thread_root_id=None, - detail="Editing adapter.py", - ) - ) - - # One post, one in-place edit; the tracked ref is unchanged. - assert len(fake.threads) == 1 - assert len(fake.updates) == 1 - assert fake.updates[0]["activity_id"] == "M1" - assert adapter._working_msg[("19:abc@thread.tacv2", "worker")].message_ref == "M1" - - -def test_idle_retires_the_working_message_by_editing_it() -> None: - adapter = _adapter() - fake = _CountingConnector() - _wire_counting(adapter, fake) - - _run( - adapter._apply_runtime_state( - "19:abc@thread.tacv2", - "worker", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - _run( - adapter._apply_runtime_state( - "19:abc@thread.tacv2", - "worker", - "idle", - mention_handle=None, - thread_root_id=None, - ) - ) - - # A posts channel: Teams substitutes "This message has been deleted." for - # anything removed and keeps it in the post, so the status is edited into a - # terminal marker instead of being deleted. - assert fake.deletes == [] - assert [u["activity_id"] for u in fake.updates] == ["M1"] - assert "Done" in _card_text(fake.updates[-1]["activity"]) - assert ("19:abc@thread.tacv2", "worker") not in adapter._working_msg - - -def test_awaiting_input_keeps_working_and_pings() -> None: - adapter = _adapter() - fake = _CountingConnector() - _wire_counting(adapter, fake) - key = ("19:abc@thread.tacv2", "worker") - - _run( - adapter._apply_runtime_state( - "19:abc@thread.tacv2", - "worker", - "working", - mention_handle=None, - thread_root_id=None, - ) - ) - _run( - adapter._apply_runtime_state( - "19:abc@thread.tacv2", - "worker", - "awaiting-input", - mention_handle="louis", - thread_root_id=None, - ) - ) - - # Working indicator stays up; a ping is tracked separately. - assert adapter._working_msg[key].message_ref == "M1" - assert adapter._input_pings[key] == ["M2"] - - _run( - adapter._apply_runtime_state( - "19:abc@thread.tacv2", - "worker", - "idle", - mention_handle=None, - thread_root_id=None, - ) - ) - # Both are retired on idle β€” edited, not deleted, in a posts channel. - assert fake.deletes == [] - assert {u["activity_id"] for u in fake.updates} == {"M1", "M2"} - assert key not in adapter._working_msg - assert key not in adapter._input_pings diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_channel_layout.py b/core/tests/switch_core/bridges/collaboration/test_teams_channel_layout.py index f96416423..81ac913d7 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_channel_layout.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_channel_layout.py @@ -143,29 +143,6 @@ def test_a_chat_channel_puts_the_room_linked_notice_at_the_root() -> None: assert connector.replies == [] -# `_apply_runtime_state` directly, for the reason given in -# `test_teams_runtime_state_layout.py`: the legacy path has no caller left. -def test_a_chat_channel_keeps_the_runtime_status_where_the_message_was() -> None: - adapter, connector = _adapter(_Graph("chat")) - - # Triggered by a message at the root β†’ the status belongs at the root. - _run( - adapter._apply_runtime_state( - _CHANNEL, "james", "working", mention_handle=None, thread_root_id=None - ) - ) - assert connector.new_posts == [_CHANNEL] - assert connector.replies == [] - - # Triggered from inside a thread β†’ the status belongs in that thread. - _run( - adapter._apply_runtime_state( - _CHANNEL, "rita", "working", mention_handle=None, thread_root_id="msg-4" - ) - ) - assert connector.replies == [f"{_CHANNEL};messageid=msg-4"] - - def test_a_chat_channel_does_not_glue_an_agents_messages_together() -> None: # The bug Louis saw: everything an agent said after its first message was # rewritten into the thread that first message opened. diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py b/core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py deleted file mode 100644 index 22186e43d..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Teams deletes differently in its two channel layouts, so the status retires -differently too. - -In a **posts** channel Teams substitutes *"This message has been deleted."* and -keeps it in the post. A status line that appears and vanishes every turn -therefore leaves one tombstone per turn per agent, and repositioning it leaves -another per move β€” which is what a channel talking to agents actually looked -like. Nothing turns that off, so the answer is not to delete: the status is -edited into a terminal marker and left as the record of a finished turn, the -way Mattermost already does it for the same reason. - -A **chat**-layout channel drops a deleted message cleanly, so it keeps the -original behaviour: the status disappears when the turn ends. - -These drive `_apply_runtime_state` and `_reposition_runtime_state` rather than -the public entry points, because the public ones no longer reach them: Teams -now publishes SDK sessions and declares `renders_legacy_runtime_state = False`, -so the base class stops the legacy path before the adapter sees it. The -implementation is still here and still correct; what it no longer has is a -caller. Removing it is its own task β€” until then these keep it honest, and -`test_teams_sdk_only.py` covers what replaced it. -""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from switch_core.bridges.collaboration.teams.adapter import ( - TeamsAdapter, - TeamsConnectionConfig, -) - -_CHANNEL = "19:abc@thread.tacv2" -_AGENT = "worker" - - -def _run(coro: Any) -> Any: - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -class _Connector: - def __init__(self) -> None: - self.updates: list[dict[str, Any]] = [] - self.deletes: list[str] = [] - self.posted: list[str] = [] - self._n = 0 - - def _next(self) -> str: - self._n += 1 - return f"M{self._n}" - - async def create_channel_thread( - self, *, service_url: str, channel_id: str, activity: dict[str, Any] - ) -> tuple[str, str]: - mid = self._next() - self.posted.append(mid) - return f"{channel_id};messageid={mid}", mid - - async def send_to_conversation( - self, *, service_url: str, conversation_id: str, activity: dict[str, Any] - ) -> str: - mid = self._next() - self.posted.append(mid) - return mid - - async def update_activity( - self, - *, - service_url: str, - conversation_id: str, - activity_id: str, - activity: dict[str, Any], - ) -> None: - self.updates.append({"activity_id": activity_id, "activity": activity}) - - async def delete_activity( - self, *, service_url: str, conversation_id: str, activity_id: str - ) -> None: - self.deletes.append(activity_id) - - -class _Graph: - def __init__(self, layout: str) -> None: - self._layout = layout - - async def get_channel(self, *, team_id: str, channel_id: str) -> dict[str, Any]: - return {"id": channel_id, "displayName": "general", "layoutType": self._layout} - - -def _adapter(layout: str) -> tuple[TeamsAdapter, _Connector]: - adapter = TeamsAdapter( - config=TeamsConnectionConfig( - app_id="app-123", - app_password="secret", - tenant_id="tenant-9", - team_id="team-7", - public_base_url="https://switch.example", - client_state="s3cr3t", - ) - ) - connector = _Connector() - adapter._connector = connector # type: ignore[assignment] - adapter._graph = _Graph(layout) # type: ignore[assignment] - adapter._default_service_url = "https://smba.example" - adapter._channel_type[_CHANNEL] = "channel_public" - return adapter, connector - - -def _text(activity: dict[str, Any]) -> str: - card = activity["attachments"][0]["content"] - return "\n".join(str(block.get("text", "")) for block in card["body"]) - - -def _work(adapter: TeamsAdapter, **kw: Any) -> None: - _run( - adapter._apply_runtime_state( - _CHANNEL, _AGENT, "working", mention_handle=None, thread_root_id=None, **kw - ) - ) - - -def _idle(adapter: TeamsAdapter) -> None: - _run( - adapter._apply_runtime_state( - _CHANNEL, _AGENT, "idle", mention_handle=None, thread_root_id=None - ) - ) - - -# ── posts layout: nothing is ever deleted ───────────────────────────────────── - - -def test_a_finished_turn_edits_the_status_rather_than_deleting_it() -> None: - adapter, connector = _adapter("post") - - _work(adapter) - _idle(adapter) - - assert connector.deletes == [] - assert [u["activity_id"] for u in connector.updates] == ["M1"] - assert "Done" in _text(connector.updates[-1]["activity"]) - - -def test_the_marker_says_how_long_the_turn_took() -> None: - adapter, connector = _adapter("post") - - _work(adapter) - _idle(adapter) - - # Format is "βœ“ Done Β· 0s" β€” the duration matters more than the exact word. - assert "Β·" in _text(connector.updates[-1]["activity"]) - - -def test_the_marker_drops_the_session_link() -> None: - # The link is worth following while an agent is working. On the record of a - # turn that is over it is clutter, and this line stays for good. - adapter, connector = _adapter("post") - - _work(adapter, deeplink_url="https://switch.example/deeplink/session?x=1") - _idle(adapter) - - assert "deeplink" not in _text(connector.updates[-1]["activity"]) - - -def test_an_operator_ping_is_resolved_by_editing_too() -> None: - adapter, connector = _adapter("post") - - _work(adapter) - _run( - adapter._apply_runtime_state( - _CHANNEL, - _AGENT, - "awaiting-input", - mention_handle="ada", - thread_root_id=None, - ) - ) - _idle(adapter) - - assert connector.deletes == [] - assert {u["activity_id"] for u in connector.updates} == {"M1", "M2"} - - -def test_the_status_does_not_move_to_follow_the_conversation() -> None: - # A move is a repost plus a delete, and that delete scars once per move β€” - # so the busier the channel, the more of them. - adapter, connector = _adapter("post") - - _work(adapter) - posted_before = list(connector.posted) - - _run(adapter._reposition_runtime_state(_CHANNEL, _AGENT, "post-9")) - - assert connector.posted == posted_before - assert connector.deletes == [] - assert adapter._working_msg[(_CHANNEL, _AGENT)].message_ref == "M1" - - -# ── chat layout: unchanged, the status disappears ───────────────────────────── - - -def test_a_chat_channel_still_removes_the_status_when_the_turn_ends() -> None: - adapter, connector = _adapter("chat") - - _work(adapter) - _idle(adapter) - - assert connector.deletes == ["M1"] - assert (_CHANNEL, _AGENT) not in adapter._working_msg - - -def test_a_chat_channel_still_moves_the_status_to_follow_the_conversation() -> None: - adapter, connector = _adapter("chat") - - _work(adapter) - _run(adapter._reposition_runtime_state(_CHANNEL, _AGENT, "msg-9")) - - # Reposted first, then the original removed β€” never briefly absent. - assert connector.deletes == ["M1"] - assert adapter._working_msg[(_CHANNEL, _AGENT)].message_ref == "M2" - - -def test_a_chat_channel_still_removes_an_operator_ping() -> None: - adapter, connector = _adapter("chat") - - _work(adapter) - _run( - adapter._apply_runtime_state( - _CHANNEL, - _AGENT, - "awaiting-input", - mention_handle="ada", - thread_root_id=None, - ) - ) - _idle(adapter) - - assert set(connector.deletes) == {"M1", "M2"} diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py index b03bca87d..1e80649d0 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py @@ -7,9 +7,8 @@ according to what a deletion leaves behind in each of Teams' two channel layouts, and truthful about which failures mean "nothing was written". -The old runtime-state renderer is still in the file (removing it is its own -task) but nothing routes to it any more. The first test holds that line: the -two renderers must not both draw, or every turn appears twice. +There is no longer a second renderer anywhere to fall back to, so what the +publication draws is the whole of what a post sees of a turn. """ from __future__ import annotations @@ -189,25 +188,16 @@ async def _card(**kwargs: Any) -> RequestCard: return RequestCard(request, RequestReference(token="tok-1", handle="R7"), **kwargs) -# ── The legacy renderer is off ─────────────────────────────────────────────── +# ── The publication is the only account of the turn ────────────────────────── -def test_the_legacy_renderer_no_longer_draws_alongside_the_sdk_one() -> None: - """Both would draw the same turn, and the post would show it twice.""" - adapter, connector = _teams() - - for state in ("working", "awaiting-input", "idle"): - _run( - adapter.apply_runtime_state( - CHANNEL, AGENT, state, mention_handle=None, thread_root_id=None - ) - ) - _run(adapter.reposition_runtime_state(CHANNEL, AGENT, ROOT)) +def test_the_publication_is_the_only_account_of_a_turn() -> None: + """There is no second renderer to fall back to, and `bridge_core` reads + this flag to decide whether to route sessions here at all β€” so a platform + that stopped declaring it would go quiet rather than draw the turn some + other way.""" + adapter, _ = _teams() - assert connector.threads == [] - assert connector.sends == [] - assert connector.updates == [] - assert adapter.renders_legacy_runtime_state is False assert adapter.publishes_sdk_sessions is True diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index ab351e458..8489c7d86 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -7,9 +7,8 @@ depending on what the chat actually is, edited in place, paced under Telegram's own limits, and loud when any of that fails. -This adapter has no legacy renderer left, but the base class defaults the flag -on for the platforms that still do. The first test holds that line: nothing -inherited may draw alongside the publication, or every turn appears twice. +There is no longer a second renderer anywhere to fall back to, so what the +publication draws is the whole of what a chat sees of a turn. """ from __future__ import annotations @@ -131,27 +130,15 @@ def _edited(adapter: TelegramAdapter) -> dict[str, Any]: return _bot(adapter).edits[-1] -# ── The legacy renderer is off ─────────────────────────────────────────────── +# ── The publication is the only account of the turn ────────────────────────── -async def test_the_legacy_renderer_no_longer_draws_alongside_the_sdk_one() -> None: - """Both would draw the same turn, and the chat would show it twice.""" - adapter = _adapter() - - for state in ("working", "awaiting-input", "idle"): - await adapter.apply_runtime_state( - CHANNEL, - "my-agent", - state, - mention_handle="someone", - thread_root_id=None, - ) - await adapter.reposition_runtime_state(CHANNEL, "my-agent", None) - - assert _bot(adapter).messages == [] - assert _bot(adapter).edits == [] - assert adapter.renders_legacy_runtime_state is False - assert adapter.publishes_sdk_sessions is True +def test_the_publication_is_the_only_account_of_a_turn() -> None: + """There is no second renderer to fall back to, and `bridge_core` reads + this flag to decide whether to route sessions here at all β€” so a platform + that stopped declaring it would go quiet rather than draw the turn some + other way.""" + assert _adapter().publishes_sdk_sessions is True def test_telegram_notifies_a_chat_without_anybody_being_named() -> None: From 83cdc9aa16f4cdfa22635f57fbd264379dca8a3e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Tue, 15 Sep 2026 23:12:02 +0100 Subject: [PATCH 059/120] docs(bridges): soften two overclaims in the Discord and Slack pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord's status-recovery paragraph said the lookup's reliance on a printed handle was a platform limitation and that the handle was the only marker available. Neither is established: alternative carriers were assessed and deferred, not ruled out. Say what the bridge does today and that the alternatives remain open. Both pages said a shared ⏳ "stays until both are done", which reads as a promise that the mark outlives the turns. A queued turn releases the mark when it starts running, so a correctly disappearing hourglass looked like a bug against the old wording. Co-Authored-By: Claude Opus 5 --- docs/old/bridges/DISCORD_SETUP.md | 15 ++++++++++----- docs/old/bridges/SLACK_SETUP.md | 5 ++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/old/bridges/DISCORD_SETUP.md b/docs/old/bridges/DISCORD_SETUP.md index 7e78c8f43..382fbd5eb 100644 --- a/docs/old/bridges/DISCORD_SETUP.md +++ b/docs/old/bridges/DISCORD_SETUP.md @@ -127,7 +127,10 @@ When a Switch Console-managed agent starts on a message, two things appear: two people at once marks both. A mark comes off a message once the last turn holding it has ended, which is not always the same moment the turn that put it there ends: two prompts queued behind one message share its ⏳, and it - stays until both are done. It needs the **Add Reactions** permission; without + stays until neither of them is queued behind anything any more. A queued turn + that starts running releases the ⏳ before it finishes, so the hourglass + going while an agent is still busy is the mark working, not failing. It needs + the **Add Reactions** permission; without it the bridge logs a warning and posts no reaction rather than a mark that is not there. - **A status message** posted under the agent's own name and avatar, edited in @@ -165,10 +168,12 @@ the heading picks the right card out of the other publications beside it. **This does not recover a status message.** A turn's status prints no handle, so there is nothing to match on and the lookup declines rather than guessing; -the status stays unconfirmed and is not posted a second time. That is a Discord -limitation rather than a decision: a webhook message carries no metadata this -bridge can set, so the handle a card prints is the only marker available. On a -platform that can carry one β€” Slack does β€” a status is as findable as a card. +the status stays unconfirmed and is not posted a second time. The current +bridge emits no discriminator by which it can recover a status: a webhook +message carries no metadata this bridge sets, so the handle a card prints is +what the lookup has to match on. Alternative recovery approaches remain +deferred. On a platform that can carry a marker β€” Slack does β€” a status is as +findable as a card. Both are minted on demand the first time the bridge needs them in a channel, and both need **Manage Webhooks**. Discord's limit is 15 webhooks per channel. diff --git a/docs/old/bridges/SLACK_SETUP.md b/docs/old/bridges/SLACK_SETUP.md index d2680b504..9c9c30262 100644 --- a/docs/old/bridges/SLACK_SETUP.md +++ b/docs/old/bridges/SLACK_SETUP.md @@ -286,7 +286,10 @@ an agent is working on it, **⏳** while a prompt is waiting behind one already running. The message itself, not the thread it sits in, so it works at the channel root as well as inside a thread. A mark comes off once the last turn holding it has ended: two prompts queued behind one message share its ⏳, and it -stays until both are done. This needs nothing but the reaction scopes. +stays until neither of them is queued behind anything any more. A queued turn +that starts running releases the ⏳ before it finishes, so the hourglass going +while an agent is still busy is the mark working, not failing. This needs +nothing but the reaction scopes. **A status message** is posted under the agent's own name and icon, carrying the **Open in Switch Console** link, and edited in place as the activity changes: From a2ef1a4d183318f117f8f02c2e2f56d18dbb1701 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 09:35:34 +0100 Subject: [PATCH 060/120] Stop a permission option saying its own scope twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `acceptForSession` option was always given " (applies for the rest of this session)", including when its label already said so. With Claude Code's "Allow for this session" that reads as one fact twice, and on Telegram it was worse than verbose: every other option is carried by its button, so the tautology was the only body line the card had, sitting under three buttons that already spelled the choices out. `_scope` now says the reach only where the label left it unsaid, which is what its docstring already claimed. A label silent about reach, or misleading about it β€” "Always allow", which does not outlive the session β€” still gets the line, and a platform drawing no buttons still lists every option. Co-Authored-By: Claude Opus 5 --- .../session/renderers/neutral.py | 23 ++++++++++--- .../test_session_neutral_forms.py | 34 +++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 95ddbdecc..ff17ed699 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -32,6 +32,7 @@ from __future__ import annotations +import re from collections.abc import Callable from dataclasses import dataclass @@ -117,6 +118,11 @@ # comparing them should not have to work out that they agree. _FOR_SESSION = " (applies for the rest of this session)" +# A label that has already said it. Narrow on purpose: "this session" and "the +# session" are a label describing its own reach, where a bare "session" is as +# likely to be the subject of the permission being asked about. +_SAYS_THE_SESSION = re.compile(r"\b(?:this|the)\s+session\b", re.IGNORECASE) + _QUESTION_HEADINGS = { "open": "Questions", "submitting": "Questions", @@ -917,13 +923,22 @@ def __call__( def _scope(option: ApprovalOption) -> str: - """How far an approval reaches, where the label may not have said. + """How far an approval reaches, where the label has not already said. The same wording the answered card uses, on the form itself: two options can be labelled the same and mean "this once" and "from now on", and a reader choosing between them by number needs the difference said. + + A label that already says it is left to say it. "Allow for this session + (applies for the rest of this session)" is one fact twice, and the second + copy is the longest line on the card β€” on a phone, the thing that pushes + the question off the screen. A host whose labels are silent about reach, + or misleading about it ("Always allow", which does not outlive the + session), still gets the line. """ - return _FOR_SESSION if option.decision == "acceptForSession" else "" + if option.decision != "acceptForSession": + return "" + return "" if _SAYS_THE_SESSION.search(option.label) else _FOR_SESSION def _carried(option: ApprovalOption, control_label_limit: int | None) -> bool: @@ -934,8 +949,8 @@ def _carried(option: ApprovalOption, control_label_limit: int | None) -> bool: the card off the screen. It is only the same choice if the control shows all of it, which is two things: a label short enough that the button did not have to cut it, and nothing said beside the label that a button has no - room for. A scope is exactly that, so an option reaching past this turn - keeps its line while the ones a button says in full lose theirs. + room for. A scope is exactly that, so an option whose reach the label left + unsaid keeps its line while the ones a button says in full lose theirs. Every line is fitted before this is asked, kept or not. A label too long for the card is a form that cannot honestly ask for a number, and that is diff --git a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py index c02a955f6..4d225d464 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py @@ -348,17 +348,47 @@ def test_an_option_that_outlasts_the_turn_keeps_the_line_saying_so(): lines = _pressable( _approval( _option("once", "Allow once"), - _option("always", "Allow for this session", decision="acceptForSession"), + _option("always", "Always allow", decision="acceptForSession"), _option("no", "Decline", decision="decline"), ) ) assert lines[-2:] == [ - "2. Allow for this session (applies for the rest of this session)", + "2. Always allow (applies for the rest of this session)", "Reply with `R42` and your choice, e.g. `R42 1`.", ] +def test_a_label_that_already_named_the_session_is_not_made_to_say_it_twice(): + """The scope is printed because the label may not have said it. This one + did, so the line is the same fact twice β€” and with buttons carrying the + other two options, the only line left in the body.""" + lines = _pressable( + _approval( + _option("once", "Allow once"), + _option("always", "Allow for this session", decision="acceptForSession"), + _option("no", "Decline", decision="decline"), + ) + ) + + assert lines[-1] == "Reply with `R42` and your choice, e.g. `R42 1`." + assert not any("applies for the rest" in line for line in lines) + + +def test_dropping_the_scope_does_not_drop_the_option_where_nothing_else_has_it(): + """Without buttons the line is the only place the choice exists, so it + loses the parenthetical and keeps the option.""" + text = _render( + _approval( + _option("once", "Allow once"), + _option("always", "Allow for this session", decision="acceptForSession"), + ) + ) + + assert "2. Allow for this session" in text + assert "applies for the rest" not in text + + def test_a_kept_line_keeps_the_number_its_button_was_given(): """Pressing and typing have to mean the same thing by the same number, so the surviving lines are not renumbered around the dropped ones.""" From b2d0e12e981e7b6869adb4d06729bda860efc355 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 10:04:09 +0100 Subject: [PATCH 061/120] Recognise scope-stating labels instead of reading them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on a2ef1a4d: suppressing the scope suffix wherever the label mentioned "this session" read a label for something it does not say. Two options both labelled "Inspect this session" β€” the session as the subject of the approval, not its duration β€” rendered as identical numbered choices with the accept and accept-for-session distinction gone, which is the confusion the suffix exists to prevent. "Run once in this session" lost the line correcting it. Neither failure is the safe direction I claimed. The suffix is now suppressed only for whole labels Switch itself writes for a host reporting a bare decision: Claude Code's, Codex's and OpenCode's. Arbitrary host text keeps the suffix, which covers Gemini and Cursor, who pass their host's own wording through. Simon's original Telegram card is unchanged by the narrowing. An allowlist is only safe while it matches its source, so test_session_provider_labels.py reads the three adapters and fails on a label they write that the renderer does not know. Co-Authored-By: Claude Opus 5 --- .../session/renderers/neutral.py | 38 ++++++++--- .../test_session_neutral_forms.py | 52 +++++++++++++++ .../test_session_provider_labels.py | 63 +++++++++++++++++++ .../test_session_settled_cards.py | 28 ++++++++- 4 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_session_provider_labels.py diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index ff17ed699..7c154141e 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -32,7 +32,6 @@ from __future__ import annotations -import re from collections.abc import Callable from dataclasses import dataclass @@ -118,10 +117,26 @@ # comparing them should not have to work out that they agree. _FOR_SESSION = " (applies for the rest of this session)" -# A label that has already said it. Narrow on purpose: "this session" and "the -# session" are a label describing its own reach, where a bare "session" is as -# likely to be the subject of the permission being asked about. -_SAYS_THE_SESSION = re.compile(r"\b(?:this|the)\s+session\b", re.IGNORECASE) +# The labels that have already said it: the whole label, matched whole, and +# only ones Switch itself writes. `console/packages/agent-providers/src/` +# mints these for the hosts that report a bare decision β€” Claude Code, Codex +# and OpenCode respectively β€” and `test_session_provider_labels.py` fails if +# that list grows a label this one does not have. +# +# Recognition rather than reading, because a label is host text and a mention +# of the session is not a statement about how long an approval lasts. "Inspect +# this session" is two options' subject, not their reach, and suppressing the +# suffix there leaves the accept and the accept-for-session choices identical +# on the screen β€” the exact confusion the suffix exists to prevent. Gemini and +# Cursor pass their host's own wording straight through, so everything from +# them lands in the fallback, which is to say it. +_LABELS_STATING_THE_SESSION = frozenset( + { + "allow for this session", + "approve for this session", + "allow for the rest of this session", + } +) _QUESTION_HEADINGS = { "open": "Questions", @@ -932,13 +947,18 @@ def _scope(option: ApprovalOption) -> str: A label that already says it is left to say it. "Allow for this session (applies for the rest of this session)" is one fact twice, and the second copy is the longest line on the card β€” on a phone, the thing that pushes - the question off the screen. A host whose labels are silent about reach, - or misleading about it ("Always allow", which does not outlive the - session), still gets the line. + the question off the screen. + + Only a label Switch recognises whole earns that, and anything else keeps + the suffix. Silence about reach ("Run the tests") and an overstatement of + it ("Always allow", which does not outlive the session) both need the line + for the same reason: a reader choosing by number is choosing on what is + printed beside the number. """ if option.decision != "acceptForSession": return "" - return "" if _SAYS_THE_SESSION.search(option.label) else _FOR_SESSION + normalised = " ".join(option.label.split()).casefold().rstrip(".") + return "" if normalised in _LABELS_STATING_THE_SESSION else _FOR_SESSION def _carried(option: ApprovalOption, control_label_limit: int | None) -> bool: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py index 4d225d464..efe2cccce 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py @@ -9,6 +9,8 @@ from __future__ import annotations +import pytest + from switch_core.bridges.collaboration.session.renderers import ( MARKDOWN, RequestReference, @@ -303,6 +305,12 @@ def _pressable(request: SnapshotRequest, *, limit: int = 4000) -> list[str]: ).text.splitlines() +def _joined_pressable(request: SnapshotRequest, *, limit: int = 4000) -> str: + """`_pressable` as one string, for a case asserted the same way with and + without controls.""" + return "\n".join(_pressable(request, limit=limit)) + + def test_an_option_its_button_says_in_full_is_not_printed_under_it(): lines = _pressable( _approval( @@ -389,6 +397,50 @@ def test_dropping_the_scope_does_not_drop_the_option_where_nothing_else_has_it() assert "applies for the rest" not in text +# A label is host text, and a label mentioning the session is not a label +# saying how long the approval lasts. These are the two ways that goes wrong if +# the suffix is suppressed by reading the label rather than recognising it. + + +SUBJECT_IS_THE_SESSION = _approval( + _option("once", "Inspect this session"), + _option("always", "Inspect this session", decision="acceptForSession"), +) + + +def test_a_label_whose_subject_is_the_session_still_says_its_scope(): + """ "Inspect this session" names what is being approved, not for how long. + Suppress the suffix there and the two options are the same words under two + numbers β€” the state this suffix exists to prevent.""" + text = _render(SUBJECT_IS_THE_SESSION) + + assert "1. Inspect this session" in text + assert "2. Inspect this session (applies for the rest of this session)" in text + + +def test_the_button_that_says_no_more_than_its_twin_keeps_the_line_that_does(): + """Two buttons reading "Inspect this session" are one choice pressed two + ways. The kept line is the whole of how a reader tells them apart, so the + one carrying the scope survives even though the label fits a button.""" + lines = _pressable(SUBJECT_IS_THE_SESSION) + + assert "2. Inspect this session (applies for the rest of this session)" in lines + + +@pytest.mark.parametrize("renders", (_render, _joined_pressable)) +def test_a_label_contradicting_its_own_decision_is_corrected_not_trusted(renders): + """ "Run once in this session" mentions the session and means the opposite + of what the decision does. The suffix is the only thing on the card that + tells the reader the truth about what they are about to grant.""" + text = renders( + _approval( + _option("always", "Run once in this session", decision="acceptForSession") + ) + ) + + assert "1. Run once in this session (applies for the rest of this session)" in text + + def test_a_kept_line_keeps_the_number_its_button_was_given(): """Pressing and typing have to mean the same thing by the same number, so the surviving lines are not renumbered around the dropped ones.""" diff --git a/core/tests/switch_core/bridges/collaboration/test_session_provider_labels.py b/core/tests/switch_core/bridges/collaboration/test_session_provider_labels.py new file mode 100644 index 000000000..ad8693b2a --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_provider_labels.py @@ -0,0 +1,63 @@ +"""The renderer's list of "this label already said its own scope", against the +provider adapters that write those labels. + +`_scope` suppresses its "(applies for the rest of this session)" suffix only +for labels it recognises whole. That is safe exactly as long as the list is the +labels Switch actually mints β€” a provider given a new wording, or an existing +one reworded, silently returns the duplicate this exists to stop, and nothing +in the Python tree would notice. So the list is checked against its source. + +Recognition is one-directional on purpose. Every Switch-written +`acceptForSession` label must be recognised; the renderer may also carry a +label no adapter writes today, because a label removed from a provider is +still on cards already posted. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from switch_core.bridges.collaboration.session.renderers.neutral import ( + _LABELS_STATING_THE_SESSION, +) + +PROVIDERS = Path(__file__).resolve().parents[5] / "console/packages/agent-providers/src" + +# The two shapes an adapter declares one in: an entry in an options list, and a +# value in a decision-keyed record. +_IN_A_LIST = re.compile( + r"decision:\s*'acceptForSession',\s*label:\s*'([^']+)'", +) +_IN_A_RECORD = re.compile(r"acceptForSession:\s*'([^']+)'") + + +def _written_labels() -> dict[str, str]: + """Every `acceptForSession` label in the provider sources, by file.""" + found: dict[str, str] = {} + for source in sorted(PROVIDERS.rglob("*-adapter.ts")): + for pattern in (_IN_A_LIST, _IN_A_RECORD): + for label in pattern.findall(source.read_text()): + found[label] = source.name + return found + + +def test_the_provider_sources_are_where_this_thinks_they_are(): + """A path that stopped resolving would make every check below vacuous, and + a renaming of the declaration would empty them just as quietly.""" + assert PROVIDERS.is_dir(), PROVIDERS + written = _written_labels() + assert len(written) >= 3, written + + +@pytest.mark.parametrize("label", sorted(_written_labels())) +def test_a_label_switch_writes_is_one_the_renderer_recognises(label: str): + """Add a provider, or reword one, and add it here: an unrecognised label + gets the suffix, which on Claude Code's wording is the duplicate that sent + us here in the first place.""" + assert " ".join(label.split()).casefold() in _LABELS_STATING_THE_SESSION, ( + f"{label!r} is written by {_written_labels()[label]} but is not in " + "_LABELS_STATING_THE_SESSION" + ) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py b/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py index 77a65428e..1e4860892 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py @@ -28,6 +28,10 @@ ALLOW = _option("opt-allow", "Allow once") ALWAYS = _option("opt-always", "Allow", decision="acceptForSession") DENY = _option("opt-deny", "Deny") +# The two ways an `acceptForSession` label meets the footer's scope: one Switch +# writes and recognises, and one whose mention of the session is its subject. +SAID_SO = _option("opt-said-so", "Allow for this session", decision="acceptForSession") +SUBJECT = _option("opt-subject", "Inspect this session", decision="acceptForSession") def _settled( @@ -66,7 +70,7 @@ def _settled( kind="approval", title="Run project tests", detail=detail, - options=[ALLOW, ALWAYS, DENY], + options=[ALLOW, ALWAYS, DENY, SAID_SO, SUBJECT], ).model_dump(by_alias=True), } ) @@ -132,6 +136,28 @@ def test_an_option_that_lasts_the_session_says_so_in_the_footer() -> None: ) +def test_a_recognised_label_does_not_repeat_its_scope_in_the_footer() -> None: + """The heading is the label here, so "Allow for this session" over "Chosen + … (applies for the rest of this session)" is the same duplicate the open + form had. Both drawings share `_scope`, so both are fixed by it.""" + lines = _lines(_settled(option_id="opt-said-so")) + + assert lines[0] == "**Allow for this session** Β· request `R42`" + assert lines[-1] == "Chosen by actor-demo from Mattermost." + + +def test_a_settled_label_whose_subject_is_the_session_keeps_its_scope() -> None: + """The heading names the option that was chosen and nothing more. Where + the label only mentioned the session, the footer is the only place the + reader learns that what was granted outlives the turn.""" + lines = _lines(_settled(option_id="opt-subject")) + + assert lines[0] == "**Inspect this session** Β· request `R42`" + assert lines[-1] == ( + "Chosen by actor-demo from Mattermost (applies for the rest of this session)." + ) + + def test_an_answer_from_nobody_in_particular_still_names_the_outcome() -> None: """A host may settle a request without saying who did it.""" lines = _lines(_settled(actor=None)) From 07f76272d7712af982550fcc72244814fffb43e3 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 10:27:42 +0100 Subject: [PATCH 062/120] Remove a Slack permission card once its approval is granted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An answered "yes" leaves a card with nothing left to ask. Slack can take it back, so it does; a refusal is kept, because the card is the channel's only durable record that permission was asked for and withheld. Probed against a live workspace before writing any of this. Slack restricts deleting an *impersonated* message β€” one sent as a real member β€” and our cards are posted with a per-agent name and icon through chat:write.customize, which is not that: chat.delete succeeded on the exact argument shape post_blocks sends. A card already gone, an address that never existed and a correct ts in the wrong channel all come back as message_not_found, so that error is read as "nothing remains there" and logged rather than raised, and every other error is a refusal. Four pieces, three of them shared with the platforms still to come: - `granted` reads a settled request. The outcome cannot answer this on its own β€” "answered" is equally a refusal β€” so the result's option is matched back against the options the request offered, and anything that does not match is read as no. - `remove_publication` is a seam that raises. `delete_message` logs and returns on four of the five platforms, which is fine for a typing indicator and not fine for something a caller writes down; the two are kept apart rather than one changed under its existing users. A platform that has not implemented it refuses, so it cannot be brought into the flow by doing nothing successfully. - `removes_approved_cards` gates the whole path, so the four platforms still to come behave exactly as they did. - `session_request_posts.removed_at` is the durable half. The row outlives the card because a typed handle still resolves to it, and without the mark a restarted publisher reads the row as a card that needs redrawing and tries to edit a message that is no longer there. The card is redrawn as settled first and removed second. A refused removal has to leave a card that says what was decided, and the mark is written before the platform is asked and cleared if it refuses β€” so every point this can stop at leaves the reader something true. Not yet decided, and reported rather than implemented: deleting a card that is itself a thread root with replies leaves a "This message was deleted." stub rather than removing anything. Human replies survive, which is required, but the channel is not cleaned. Awaiting a decision on leaving those cards settled. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 34 +++ .../bridges/collaboration/session/outbound.py | 62 +++++ .../bridges/collaboration/slack/adapter.py | 33 +++ core/switch_core/db/models.py | 10 + ...73a0d58_session_request_post_removed_at.py | 33 +++ core/switch_core/sessions/contract.py | 38 +++ core/switch_core/sessions/publication.py | 14 + .../collaboration/test_slack_card_removal.py | 123 +++++++++ .../sessions/test_approved_card_removal.py | 248 ++++++++++++++++++ .../switch_core/sessions/test_granted.py | 168 ++++++++++++ 10 files changed, 763 insertions(+) create mode 100644 core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py create mode 100644 core/tests/switch_core/sessions/test_approved_card_removal.py create mode 100644 core/tests/switch_core/sessions/test_granted.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index bf701e242..d2d9fbf5a 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -161,6 +161,23 @@ def __init__(self, message: str, *, text: str) -> None: self.text = text +class RemovalFailed(Exception): + """A published message could not be taken back, or not provably. + + Deliberately not `delete_message`, which every adapter but Teams' answers + by logging: that one is best-effort housekeeping for things like the + typing indicator, where a leftover is untidy and nothing more. Taking back + an answered permission card is the opposite β€” the caller has to write down + that the card is gone, and writing that down on a refusal it never heard + about is how a restart comes to skip a card still sitting in the channel. + + So this seam raises, the way `post_rich` and `update_rich` do, and a + platform's own error is chained as `__cause__`. A refusal and a request + that never came back are one exception because the caller does the same + thing with both: keep the settled card, and do not record a removal. + """ + + class RichContentThrottled(RichContentFailed): """The platform asked us to wait before attempting another update.""" @@ -835,6 +852,23 @@ async def find_request_card( @abstractmethod async def delete_message(self, channel_id: str, message_ref: str) -> None: ... + async def remove_publication(self, channel_id: str, message_ref: str) -> None: + """Take back a card this bridge published, or say why it is still there. + + Returning is the claim that nothing of the card remains at that + address β€” including the case where it had already gone, which is the + same fact arrived at differently and is logged rather than raised. + Anything else is `RemovalFailed`. + + Not reached unless the adapter also sets `removes_approved_cards`, + which is why this refuses rather than quietly doing nothing: a + platform brought into the removal flow without an implementation + should stop, not report success for a card still on the screen. + """ + raise RemovalFailed( + f"{type(self).__name__} cannot prove a published message was removed." + ) + @abstractmethod async def send_typing( self, channel_id: str, sender_name: str, is_typing: bool diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 63a5e8db9..ad2957a57 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -48,6 +48,7 @@ ActivityMark, ActivityMarkRefused, CollaborationAdapter, + RemovalFailed, RequestCard, RichContentFailed, RichContentThrottled, @@ -1473,6 +1474,18 @@ def discloses_unconfirmed_posts(self) -> bool: """ return bool(getattr(self._adapter, "discloses_unconfirmed_posts", False)) + @property + def removes_approved_cards(self) -> bool: + """Whether a granted card can be taken off this platform, provably. + + Two things have to hold, and a platform that manages only the first is + False: the platform will delete a message posted under an agent's own + name, and it will say so in a way the caller can tell apart from + having been ignored. Where it is False the card is left settled, which + is what every platform did before any of them could do better. + """ + return bool(getattr(self._adapter, "removes_approved_cards", False)) + async def post( self, request: SnapshotRequest, @@ -1733,6 +1746,55 @@ async def disclose_unconfirmed( post.handle, ) + async def remove(self, post: SessionRequestPost) -> None: + """Take a card off the platform now that its approval has been given. + + The mark goes down before the platform is asked, for the reason + `disclose_unconfirmed`'s does: this is reached from a publication + cycle, and a process that dies between the two has to leave behind the + state that is safe to act on. Here that is the mark, because the card + it describes has already been redrawn as settled β€” so the worst a + premature mark costs is an answered card left on the screen, while a + premature deletion would leave a row the publisher still reads as a + card to keep up to date, and every later cycle would try to edit a + message that is no longer there. + + A refusal puts the mark back. The card stays, settled and answering + nothing, which is the outcome asked for when a platform will not take + a card back β€” and leaving `removed_at` set would say in the record + that a card a reader can still see is gone. + """ + async with self._session_factory() as session: + stored = await session.get( + SessionRequestPost, post.id, with_for_update=True + ) + if stored is None or stored.removed_at is not None: + return + stored.removed_at = datetime.now(UTC) + await session.commit() + try: + await self._adapter.remove_publication( + post.external_channel_id, post.external_post_id + ) + except RemovalFailed as refusal: + async with self._session_factory() as session: + stored = await session.get( + SessionRequestPost, post.id, with_for_update=True + ) + if stored is not None: + stored.removed_at = None + await session.commit() + logger.warning( + "Card %s for request %s was granted but %s would not take it " + "back: %s. It stays in channel %s showing the decision, and " + "removal is not attempted again.", + post.handle, + post.request_id, + self._surface, + refusal, + post.external_channel_id, + ) + async def _reserve( self, session: AsyncSession, diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index d093d0fbf..3c7b74fdd 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -23,6 +23,7 @@ from switch_core.bridges.collaboration.adapter import ( ActivityMark, CollaborationAdapter, + RemovalFailed, RequestCard, RichContent, RichContentFailed, @@ -173,6 +174,12 @@ class SlackAdapter(CollaborationAdapter): supports_queue_reaction: ClassVar[bool] = True recovers_uncertain_posts: ClassVar[bool] = True + #: `chat.delete` takes back a card posted under an agent's own name and + #: icon. Slack restricts deleting an *impersonated* message, which is a + #: message sent as a real member; `chat:write.customize` is not that, and + #: the typing indicator has always been posted and deleted this way. + removes_approved_cards: ClassVar[bool] = True + #: Every publication carries its token in `block_id` and in the message's #: metadata, so a status is as findable as a card despite printing no #: handle of its own. @@ -979,6 +986,32 @@ async def delete_message(self, channel_id: str, message_ref: str) -> None: except SlackApiError as e: logger.error("Failed to delete Slack message %s: %s", message_ref, e) + async def remove_publication(self, channel_id: str, message_ref: str) -> None: + if not self._web_client: + raise RemovalFailed("Slack client not connected.") + + _, ts = self._parse_message_ref(message_ref) + if not ts: + raise RemovalFailed(f"Not a Slack message reference: {message_ref}.") + + try: + await self._web_client.chat_delete(channel=channel_id, ts=ts) + except SlackApiError as error: + if error.response.get("error") != "message_not_found": + raise RemovalFailed( + f"Slack would not delete {message_ref}: " + f"{error.response.get('error')}." + ) from error + # Slack says the same thing about a message already deleted and + # about an address it has never seen. The address here is the one + # Slack gave us when it accepted the card, so the first reading is + # the one that fits β€” but it is worth a line, because the second + # reading is what a deletion by hand or a channel-wide purge would + # also look like. + logger.warning( + "Slack card %s was already gone when it was taken back.", message_ref + ) + # ── Typing ─────────────────────────────────────────────────────────────── async def send_typing( diff --git a/core/switch_core/db/models.py b/core/switch_core/db/models.py index d295720db..6c7aea07c 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -1476,6 +1476,16 @@ class SessionRequestPost(TenantScoped, Base): unconfirmed_notice_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) + # When the card was taken off the platform after the approval it asked for + # was granted. The row outlives the card on purpose: it is what an answer + # typed against the handle still resolves to, and it is what stops a + # restart from treating a deleted card as one that merely needs redrawing + # and posting the approved question a second time. Set before the platform + # is asked and cleared if it refuses, so the state that survives a crash + # mid-removal is the one that leaves the settled card alone. + removed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py b/core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py new file mode 100644 index 000000000..908429c3c --- /dev/null +++ b/core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py @@ -0,0 +1,33 @@ +"""record when an approved request card was taken off the platform + +The row has to outlive the card it named: a typed answer still resolves +against it, and without a mark saying the card is gone a restart reads the row +as a card that merely needs redrawing and posts the granted question again. + +Null for every card still standing, which is every card there is when this +runs β€” so no backfill, and an older row is correctly read as not removed. + +Revision ID: c1e4b73a0d58 +Revises: d94a7f1b5230 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "c1e4b73a0d58" +down_revision: str | Sequence[str] | None = "d94a7f1b5230" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "session_request_posts", + sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("session_request_posts", "removed_at") diff --git a/core/switch_core/sessions/contract.py b/core/switch_core/sessions/contract.py index 79bd0be78..475b3bf22 100644 --- a/core/switch_core/sessions/contract.py +++ b/core/switch_core/sessions/contract.py @@ -464,3 +464,41 @@ def parse_snapshot(payload: Any) -> Snapshot: def parse_command(payload: Any) -> Command: return Command.model_validate(payload) + + +# The two decisions that grant what was asked for. `cancel` is not a refusal +# and `decline` is the refusal, but neither is a grant, and the pair is named +# once so that a third grant added to `ApprovalOption.decision` is added here. +GRANTS = frozenset({"accept", "acceptForSession"}) + + +def granted(request: SnapshotRequest) -> bool: + """Whether the host confirmed that this approval was given. + + Not "has it finished": `answered` is equally the outcome of a refusal, and + `resolved` equally its state, so neither says which way it went. The one + record of that is the decision carried by the option the result names, and + reaching it means matching the result back to the options the request was + asked with. + + Everything short of that match is read as not granted, because the caller + is a caller that acts on a yes. An answer naming an option the request + never offered, an approval settled with no result at all, a questions + result on an approval: each is a host saying something this cannot + interpret, and none of them is evidence of consent. + """ + if request.state != "resolved": + return False + settled = request.result + if settled is None or settled.outcome != "answered": + return False + answer = settled.result + content = request.content + if not isinstance(answer, ApprovalResult) or not isinstance( + content, ApprovalContent + ): + return False + return any( + option.option_id == answer.option_id and option.decision in GRANTS + for option in content.options + ) diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 453563849..945fe45cd 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -32,6 +32,7 @@ Command, Snapshot, TurnUpsert, + granted, ) from switch_core.sessions.presentation import ( activity_error_summary, @@ -317,6 +318,12 @@ async def refresh_cards( continue post_succeeded(attempt) refreshed(new_post.token, state) + elif post.removed_at is not None: + # The card was taken back when its approval was granted. The + # row stays so a typed answer still resolves, but there is no + # longer a message at that address: redrawing it would fail, + # and recovering it would find whatever now sits where it was. + continue elif post.external_post_id == post.token: if post.unconfirmed_notice_at is not None: # Already disclosed as undeliverable. There is no message @@ -364,6 +371,13 @@ async def refresh_cards( ), ) refreshed(post.token, state) + if cards.removes_approved_cards and granted(request): + # After the redraw, not instead of it. A refused removal + # has to leave a card showing what was decided, and this + # is the pass that makes it show it β€” so the settled + # drawing is put up first and taken away second, and every + # point this can stop at leaves the reader something true. + await cards.remove(post) except RichContentThrottled: backed_off += 1 except Exception as error: diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py new file mode 100644 index 000000000..17463f10f --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py @@ -0,0 +1,123 @@ +"""Taking a Slack card back, and being able to say whether it worked. + +Slack restricts deleting a message posted *as a member*; a card posted under +an agent's name and icon through `chat:write.customize` is still the app's own +message and comes back. Probed against a live workspace before this was +written: `chat.delete` on the exact argument shape `post_blocks` sends +succeeded, and the errors below are the ones it actually returned. + +The other half is that a caller acting on the result β€” writing down that a +card is gone β€” must not be told success where none was established. That is +why this is not `delete_message`, which logs and returns either way. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest +from slack_sdk.errors import SlackApiError + +from switch_core.bridges.collaboration.adapter import ( + CollaborationAdapter, + RemovalFailed, +) +from switch_core.bridges.collaboration.slack.adapter import SlackAdapter + +from .slack_fakes import FakeResponse +from .test_slack_adapter import _adapter, _FakeWebClient, _run + +CARD = "C123:999.9" + + +class _RefusingWebClient(_FakeWebClient): + def __init__(self, error: str) -> None: + super().__init__() + self._error = error + + async def chat_delete(self, **kwargs: Any) -> dict[str, bool]: + self.deletes.append(kwargs) + raise SlackApiError("no", FakeResponse({"error": self._error})) + + +def _connected(client: Any) -> SlackAdapter: + adapter = _adapter() + adapter._web_client = client + return adapter + + +def test_a_card_is_taken_back_at_the_address_slack_gave_us() -> None: + """`external_post_id` is `"{channel}:{ts}"`, and `chat.delete` wants the + two apart. Deleting by the whole reference deletes nothing.""" + client = _FakeWebClient() + + _run(_connected(client).remove_publication("C123", CARD)) + + assert client.deletes == [{"channel": "C123", "ts": "999.9"}] + + +def test_a_refusal_is_raised_rather_than_logged() -> None: + """The caller records that the card is gone. A refusal it never hears + about is a record saying a card in the channel is not there, and the + publisher then stops redrawing a card that still offers buttons.""" + client = _RefusingWebClient("cant_delete_message") + + with pytest.raises(RemovalFailed) as raised: + _run(_connected(client).remove_publication("C123", CARD)) + + assert "cant_delete_message" in str(raised.value) + assert isinstance(raised.value.__cause__, SlackApiError) + + +def test_a_card_already_gone_is_reported_as_gone_but_said_out_loud( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing remains at the address, which is what the caller asked for, so + this is not a failure. It is still worth a line: Slack says the same thing + about an address it has never seen, and the only innocent explanation for + one it gave us is that someone else deleted the card first. + """ + client = _RefusingWebClient("message_not_found") + + with caplog.at_level(logging.WARNING): + _run(_connected(client).remove_publication("C123", CARD)) + + assert "already gone" in caplog.text + + +def test_an_unparseable_reference_never_reaches_slack() -> None: + """A bare ts would be deleted from whatever channel was passed alongside + it. Refusing is the only safe reading of an address we cannot split.""" + client = _FakeWebClient() + + with pytest.raises(RemovalFailed): + _run(_connected(client).remove_publication("C123", "nonsense")) + + assert client.deletes == [] + + +def test_a_disconnected_adapter_does_not_claim_the_card_was_removed() -> None: + """The one case the old helper got half right β€” it declined to try β€” and + half wrong, because it returned as though it had.""" + with pytest.raises(RemovalFailed): + _run(_adapter().remove_publication("C123", CARD)) + + +def test_slack_is_the_platform_that_says_it_can_do_this() -> None: + """The capability is what routes a granted card here at all, and the four + platforms still to come are the ones that have not claimed it.""" + assert SlackAdapter.removes_approved_cards is True + + +def test_a_platform_with_no_implementation_refuses_rather_than_pretends() -> None: + """Inherited by every adapter that has not written one yet. Returning + would have the caller record the removal of a card still on the screen β€” + and then never redraw it, because the row says there is nothing there.""" + + class Unimplemented: + remove_publication = CollaborationAdapter.remove_publication + + assert getattr(Unimplemented(), "removes_approved_cards", False) is False + with pytest.raises(RemovalFailed): + _run(Unimplemented().remove_publication("C123", CARD)) # type: ignore[arg-type] diff --git a/core/tests/switch_core/sessions/test_approved_card_removal.py b/core/tests/switch_core/sessions/test_approved_card_removal.py new file mode 100644 index 000000000..4e1a51fa5 --- /dev/null +++ b/core/tests/switch_core/sessions/test_approved_card_removal.py @@ -0,0 +1,248 @@ +"""A permission card after the permission has been given. + +A card that has been answered yes has nothing left to ask, and on a platform +that can prove it took the message back it is removed rather than left as a +settled notice. A refusal is not removed: it is the only durable record in the +channel that someone said no. + +The row outlives the card either way, because it is what a handle typed into +the channel still resolves to, and because without it a restarted publisher +would read the request as one whose card had never been drawn. + +`test_publication.py` covers the same path up to settlement; this is what +happens after it. +""" + +from __future__ import annotations + +import logging + +import pytest +from sqlalchemy import select + +from switch_core.bridges.collaboration.adapter import RemovalFailed +from switch_core.bridges.collaboration.models import InboundInteraction +from switch_core.bridges.collaboration.session.inbound import SessionInteractions +from switch_core.bridges.collaboration.session.outbound import SessionRequestCards +from switch_core.bridges.collaboration.session.renderers import ANSWER_ACTION +from switch_core.db.models import SessionRequestPost +from switch_core.db.stores.session_request_post_store import SessionRequestPostStore +from switch_core.sessions.publication import refresh_cards + +from .test_authority import host_event, opened, setup +from .test_publication import Platform + + +class Removing(Platform): + """A platform that can take a card back, and remembers being asked to.""" + + removes_approved_cards = True + + def __init__(self) -> None: + super().__init__() + self.removed: list[tuple[str, str]] = [] + self.refuse: str | None = None + + async def remove_publication(self, channel: str, message_ref: str) -> None: + self.removed.append((channel, message_ref)) + if self.refuse is not None: + raise RemovalFailed(self.refuse) + + +async def _card(session_factory, platform): + """Open a request, post its card, and hand back everything to answer it.""" + service, epoch = await setup(session_factory) + await opened(service, epoch) + posts = SessionRequestPostStore() + cards = SessionRequestCards( + platform, + bridge_id="bridge", + surface="slack", + posts=posts, + session_factory=session_factory, + ) + await refresh_cards(session_factory, "bridge", "session-demo", cards) + async with session_factory() as db: + post = await db.scalar(select(SessionRequestPost)) + db.expunge(post) + return service, epoch, posts, cards, post + + +async def _answer(service, epoch, posts, post, session_factory, option_id): + """Press an option and have the host confirm it, as a real settlement.""" + + async def identify(interaction): + return "@owner:example.test" + + async def first_reply(channel, root, message): + return False + + interactions = SessionInteractions( + bridge_id="bridge", + surface="slack", + posts=posts, + session_factory=session_factory, + identify=identify, + is_first_reply=first_reply, + ) + command = await interactions.command_for( + InboundInteraction( + channel_id="channel-demo", + sender_id="platform-owner", + sender_name="Owner", + action_id=f"{ANSWER_ACTION}:{option_id}", + value=post.token, + message_ref=post.external_post_id, + ) + ) + assert command is not None + await service.submit(command, user_id=None, bridge_id="bridge") + await service.ingest( + "agent-demo", + "host-demo", + host_event( + epoch, + 3, + { + "type": "request.settled", + "requestId": command.body.request_id, + "revision": 2, + "outcome": "answered", + "commandId": command.command_id, + "result": command.body.answer.model_dump(by_alias=True), + }, + ), + ) + + +async def _removed_at(session_factory): + async with session_factory() as db: + return (await db.scalar(select(SessionRequestPost))).removed_at + + +async def test_a_granted_card_is_drawn_settled_and_then_taken_away(session_factory): + """Settled first, removed second, and not the other way round. + + The redraw is what a refused removal falls back to, so the card has to be + made to say what was decided before anything tries to delete it β€” and if + the process stops in between, what is left behind is an honest card. + """ + platform = Removing() + service, epoch, posts, cards, post = await _card(session_factory, platform) + + await _answer(service, epoch, posts, post, session_factory, "allow-once") + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert "Allow once" in platform.edits[-1][2] + assert platform.removed == [("channel-demo", post.external_post_id)] + assert await _removed_at(session_factory) is not None + + +async def test_a_refusal_keeps_its_card(session_factory): + """The channel's only record that permission was asked for and withheld. + Deleting it would leave the audit holding the one copy of that.""" + platform = Removing() + service, epoch, posts, cards, post = await _card(session_factory, platform) + + await _answer(service, epoch, posts, post, session_factory, "deny") + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert "Deny" in platform.edits[-1][2] + assert platform.removed == [] + assert await _removed_at(session_factory) is None + + +async def test_a_platform_that_cannot_prove_a_removal_is_not_asked_to_try( + session_factory, +): + """The four platforms still to come. Their cards settle exactly as they + did before, which is the behaviour a checkpoint at a time has to preserve. + """ + platform = Platform() + service, epoch, posts, cards, post = await _card(session_factory, platform) + + await _answer(service, epoch, posts, post, session_factory, "allow-once") + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert "Allow once" in platform.edits[-1][2] + assert await _removed_at(session_factory) is None + + +async def test_a_card_already_taken_away_is_not_drawn_again(session_factory): + """What stops a restart reposting a question already answered. + + The publisher reaches every row it has for as long as the session is + around, and nothing else in the row distinguishes a card that was removed + from one that merely needs bringing up to date. Editing a deleted message + fails, and that failure posts "this card could not be updated" into the + channel the card was just taken out of. + """ + platform = Removing() + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + await refresh_cards(session_factory, "bridge", "session-demo", cards) + drawn = len(platform.edits) + + await refresh_cards(session_factory, "bridge", "session-demo", cards) + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert len(platform.edits) == drawn + assert len(platform.posts) == 1 + assert len(platform.removed) == 1 + + +async def test_a_refused_removal_leaves_the_settled_card_and_says_nothing_more( + session_factory, caplog +): + """A platform that will not delete is the case the mark must not survive. + + Left set, the row would claim a card a reader can plainly see is gone, and + the publisher would stop maintaining it. Cleared, the card is exactly what + a platform without the capability would have left β€” settled, answering + nothing β€” and the refusal is in the log rather than in the channel. + """ + platform = Removing() + platform.refuse = "cant_delete_message" + service, epoch, posts, cards, post = await _card(session_factory, platform) + + await _answer(service, epoch, posts, post, session_factory, "allow-once") + with caplog.at_level(logging.WARNING): + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert "Allow once" in platform.edits[-1][2] + assert await _removed_at(session_factory) is None + assert "cant_delete_message" in caplog.text + + +async def test_a_removed_card_still_answers_to_its_handle(session_factory): + """The row is not the card. Someone who typed the handle before the card + went, or who is reading the audit, still resolves to the same request β€” + and the session, not this, is what refuses an answer already given. + """ + platform = Removing() + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + async with session_factory() as db: + found = await posts.get_by_handle(db, "bridge", "channel-demo", post.handle) + + assert found is not None + assert found.request_id == post.request_id + + +@pytest.mark.parametrize("calls", [1, 2, 3]) +async def test_removal_is_attempted_once_however_often_the_publisher_runs( + session_factory, calls +): + """Every cycle reaches every row, and a second delete of the same message + is a second chance to act on `message_not_found` from an address that has + since been reused.""" + platform = Removing() + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + + for _ in range(calls): + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert len(platform.removed) == 1 diff --git a/core/tests/switch_core/sessions/test_granted.py b/core/tests/switch_core/sessions/test_granted.py new file mode 100644 index 000000000..c7258b133 --- /dev/null +++ b/core/tests/switch_core/sessions/test_granted.py @@ -0,0 +1,168 @@ +"""Whether a settled request is one the person said yes to. + +`granted` is what decides that an approval card has served its purpose and can +be taken off the platform, so it is the one place a wrong answer costs +something irreversible: a card removed on a refusal is a refusal nobody can +read afterwards. Everything here is about the ways a settled request can look +finished without being a yes. +""" + +from __future__ import annotations + +import pytest + +from switch_core.sessions.contract import SnapshotRequest, granted + +OPTIONS = [ + {"optionId": "once", "label": "Allow once", "decision": "accept"}, + {"optionId": "session", "label": "Allow", "decision": "acceptForSession"}, + {"optionId": "no", "label": "Deny", "decision": "decline"}, + {"optionId": "stop", "label": "Cancel", "decision": "cancel"}, +] + + +def _request( + *, + state: str, + outcome: str | None, + result: dict | None, + content: dict | None = None, +) -> SnapshotRequest: + return SnapshotRequest.model_validate( + { + "requestId": "req-1", + "turnId": "turn-1", + "revision": 2, + "state": state, + "expiresAt": None, + "content": content + or { + "kind": "approval", + "title": "Run project tests", + "detail": "pnpm test", + "options": OPTIONS, + }, + "result": ( + None + if outcome is None + else { + "type": "request.settled", + "requestId": "req-1", + "revision": 2, + "outcome": outcome, + "commandId": "cmd-1", + "result": result, + } + ), + "decidedBy": None, + } + ) + + +def _answered(option_id: str) -> SnapshotRequest: + return _request( + state="resolved", + outcome="answered", + result={"kind": "approval", "optionId": option_id}, + ) + + +@pytest.mark.parametrize("option_id", ["once", "session"]) +def test_both_ways_of_saying_yes_are_a_grant(option_id: str) -> None: + """One turn's permission and the session's are the same answer to the + question the card asked, and the card has no further use after either.""" + assert granted(_answered(option_id)) is True + + +@pytest.mark.parametrize("option_id", ["no", "stop"]) +def test_the_answers_that_are_not_yes_are_not_grants(option_id: str) -> None: + """`outcome` is "answered" for a refusal too, so the outcome alone would + remove the record of every decline ever made.""" + assert granted(_answered(option_id)) is False + + +def test_an_option_the_request_never_offered_decides_nothing() -> None: + """A host naming an option that is not on the card is a host saying + something we cannot read. The safe reading of an unreadable answer is that + consent was not established β€” so the card stays and can be read by hand.""" + assert granted(_answered("invented")) is False + + +@pytest.mark.parametrize("outcome", ["cancelled", "expired", "interrupted"]) +def test_a_request_that_ended_without_an_answer_is_not_a_grant(outcome: str) -> None: + assert granted(_request(state="closed", outcome=outcome, result=None)) is False + + +def test_a_provider_error_is_not_a_grant() -> None: + """The one outcome that could plausibly carry a stale result alongside a + failure, and the failure is what it settled as.""" + assert ( + granted( + _request( + state="closed", + outcome="provider-error", + result={"kind": "approval", "optionId": "once"}, + ) + ) + is False + ) + + +def test_an_answer_the_host_never_described_is_not_a_grant() -> None: + """Answered, with nothing said about what the answer was. The settled card + already has to print "the host did not say which option was chosen"; it + must not be deleted on the strength of it.""" + assert granted(_request(state="resolved", outcome="answered", result=None)) is False + + +@pytest.mark.parametrize("state", ["open", "submitting"]) +def test_a_request_still_in_flight_is_not_a_grant(state: str) -> None: + """`submitting` carries a chosen option before the host has confirmed it. + Taking the card away then would delete a question still being asked, on + the strength of a press rather than a decision.""" + assert ( + granted( + _request( + state=state, + outcome="answered", + result={"kind": "approval", "optionId": "once"}, + ) + ) + is False + ) + + +def test_a_questions_answer_to_an_approval_is_not_a_grant() -> None: + """The two result shapes are discriminated on the wire but nothing makes + the pairing match its content, and an answer of the wrong kind says + nothing about the approval it arrived against.""" + assert ( + granted( + _request( + state="resolved", + outcome="answered", + result={"kind": "questions", "answers": []}, + ) + ) + is False + ) + + +def test_a_question_is_not_an_approval_however_it_settles() -> None: + """Only an approval can be granted. A form that has been filled in is + finished, not consented to, and its card is the record of the answers.""" + assert ( + granted( + _request( + state="resolved", + outcome="answered", + result={"kind": "questions", "answers": []}, + content={ + "kind": "questions", + "title": "Which suite?", + "questions": [], + }, + ) + ) + is False + ) From bf701f4c3e0a7e0ea5cacb6361686fe34ad162fc Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 10:56:17 +0100 Subject: [PATCH 063/120] Owe a card's removal to the record, not to the redraw that preceded it Three faults in the first cut of Slack approved-card removal, all found in review, all reproduced against it before being fixed. The mark said a card was gone before anything had established that it was. A cancellation or a crash between the write and the platform call left a durable claim that a visible card had been taken away, and the publisher skips such a row for good, so the card kept offering its decision forever with nothing left that would look at it again. Nothing is written now until the platform has either deleted the message or reported nothing at the address. That leaves the opposite gap -- a deletion whose reply was lost -- and it closes itself: asking again returns absence, and absence is the same fact as having just deleted it. Removal hung off the branch that redraws a changed card, so a single refusal lost it. The next cycle saw the same revision and state, correctly drew nothing, and skipped the deletion along with it for the life of the process. It is a stage of its own now, reached whenever the record says a granted card has not been taken back, with its own widening interval and the platform's own Retry-After honoured -- being asked to wait is not being told no, and Slack's rate limit no longer arrives as a refusal. A failed cleanup reports the cycle incomplete, which is what has the session tried again at all. Being reachable only from the redraw also meant a card recovered after its approval was granted -- approved in Console while the send was unconfirmed -- was never removed by any cycle, since recovery is the only one in which anything about it changes. Unchanged: denials, cancellations and anything unresolved keep their cards; a card that cannot be taken back is left settled and readable; the row and its audit outlive the card. Still not decided, and still reported rather than implemented: a card that is itself a thread root with replies is deleted, and Slack leaves a "This message was deleted." stub in its place. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 9 +- .../bridges/collaboration/session/outbound.py | 59 ++---- .../bridges/collaboration/slack/adapter.py | 14 +- core/switch_core/sessions/publication.py | 71 ++++++- .../collaboration/test_slack_card_removal.py | 33 ++- .../sessions/test_approved_card_removal.py | 197 +++++++++++++++++- 6 files changed, 320 insertions(+), 63 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index d2d9fbf5a..0b5338179 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -174,7 +174,10 @@ class RemovalFailed(Exception): So this seam raises, the way `post_rich` and `update_rich` do, and a platform's own error is chained as `__cause__`. A refusal and a request that never came back are one exception because the caller does the same - thing with both: keep the settled card, and do not record a removal. + thing with both: keep the settled card, record no removal, and try again + on a widening interval. What is *not* folded in here is a wait the + platform asked for β€” that is `RichContentThrottled`, and it carries the + delay, so being busy cannot be read as being unable. """ @@ -858,7 +861,9 @@ async def remove_publication(self, channel_id: str, message_ref: str) -> None: Returning is the claim that nothing of the card remains at that address β€” including the case where it had already gone, which is the same fact arrived at differently and is logged rather than raised. - Anything else is `RemovalFailed`. + That second case is what lets a deletion whose response was lost be + settled by simply asking again. `RichContentThrottled` where the + platform named a wait, `RemovalFailed` for anything else. Not reached unless the adapter also sets `removes_approved_cards`, which is why this refuses rather than quietly doing nothing: a diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index ad2957a57..c9371bf89 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -48,7 +48,6 @@ ActivityMark, ActivityMarkRefused, CollaborationAdapter, - RemovalFailed, RequestCard, RichContentFailed, RichContentThrottled, @@ -1749,21 +1748,29 @@ async def disclose_unconfirmed( async def remove(self, post: SessionRequestPost) -> None: """Take a card off the platform now that its approval has been given. - The mark goes down before the platform is asked, for the reason - `disclose_unconfirmed`'s does: this is reached from a publication - cycle, and a process that dies between the two has to leave behind the - state that is safe to act on. Here that is the mark, because the card - it describes has already been redrawn as settled β€” so the worst a - premature mark costs is an answered card left on the screen, while a - premature deletion would leave a row the publisher still reads as a - card to keep up to date, and every later cycle would try to edit a - message that is no longer there. - - A refusal puts the mark back. The card stays, settled and answering - nothing, which is the outcome asked for when a platform will not take - a card back β€” and leaving `removed_at` set would say in the record - that a card a reader can still see is gone. + Nothing is written until the platform has said the card is gone. + `removed_at` is the publisher's evidence that there is no message left + to redraw, so it cannot be laid down in advance of the fact the way + `disclose_unconfirmed`'s mark is: that one bounds a notice to one + attempt and loses only the notice, whereas a mark that outlived a + cancellation here would hide a card still showing its decision, for + good, and no later cycle would look at it again. + + Which leaves the opposite gap β€” a deletion that succeeded and was + never recorded β€” and it closes itself. The next cycle asks again, the + platform reports nothing at the address, and that is a removal + confirmed rather than an error, so the record catches up. + + A failure is raised, not absorbed. The card it leaves behind is + settled and readable, which is the intended fallback, but the cleanup + is still owed: only the caller knows how long to wait before asking + again, and swallowing the exception here would make a rate limit + indistinguishable from a refusal and close a removal that never + happened. """ + await self._adapter.remove_publication( + post.external_channel_id, post.external_post_id + ) async with self._session_factory() as session: stored = await session.get( SessionRequestPost, post.id, with_for_update=True @@ -1772,28 +1779,6 @@ async def remove(self, post: SessionRequestPost) -> None: return stored.removed_at = datetime.now(UTC) await session.commit() - try: - await self._adapter.remove_publication( - post.external_channel_id, post.external_post_id - ) - except RemovalFailed as refusal: - async with self._session_factory() as session: - stored = await session.get( - SessionRequestPost, post.id, with_for_update=True - ) - if stored is not None: - stored.removed_at = None - await session.commit() - logger.warning( - "Card %s for request %s was granted but %s would not take it " - "back: %s. It stays in channel %s showing the decision, and " - "removal is not attempted again.", - post.handle, - post.request_id, - self._surface, - refusal, - post.external_channel_id, - ) async def _reserve( self, diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 3c7b74fdd..0962a7c44 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -997,10 +997,18 @@ async def remove_publication(self, channel_id: str, message_ref: str) -> None: try: await self._web_client.chat_delete(channel=channel_id, ts=ts) except SlackApiError as error: - if error.response.get("error") != "message_not_found": + refusal = error.response.get("error") + if refusal == "ratelimited": + # Separated from every other error because it is the one that + # says nothing about the card: the deletion is still owed, and + # Slack has named how long to wait before owing it again. + raise RichContentThrottled( + retry_after=_retry_after_seconds(error), + text=f"Waiting for Slack to allow {message_ref} to be deleted.", + ) from error + if refusal != "message_not_found": raise RemovalFailed( - f"Slack would not delete {message_ref}: " - f"{error.response.get('error')}." + f"Slack would not delete {message_ref}: {refusal}." ) from error # Slack says the same thing about a message already deleted and # about an address it has never seen. The address here is the one diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 945fe45cd..49e2bba77 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -9,7 +9,10 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from switch_core.bridges.collaboration.adapter import RichContentThrottled +from switch_core.bridges.collaboration.adapter import ( + RemovalFailed, + RichContentThrottled, +) from switch_core.bridges.collaboration.session.outbound import ( CardRefused, SessionRequestCards, @@ -107,6 +110,9 @@ async def refresh_cards( post_delayed: Callable[[str, float], None] = _ignore_delay, refresh_needed: Callable[[str, tuple[int, str]], bool] = _always_refresh, refreshed: Callable[[str, tuple[int, str]], None] = _ignore_refresh, + removal_allowed: Callable[[str], bool] = _always_recover, + removal_succeeded: Callable[[str], None] = _ignore_recovery, + removal_delayed: Callable[[str, float], None] = _ignore_delay, ) -> None: """Bring a session's cards up to date with its persisted requests. @@ -156,6 +162,18 @@ async def refresh_cards( which always act and track nothing: every confirmed card is compared against what is actually recorded for it, every time. + `removal_allowed` / `removal_succeeded` / `removal_delayed` are the same + three-part gate as the post's, for taking a granted card back, and they + are kept apart from `refresh_needed` on purpose: whether a card still owes + a deletion is a fact about the record, not about whether anything has + changed since it was last drawn. Sharing the redraw's gate meant one + refused deletion was never attempted again by that process, because every + later cycle saw an unchanged card and skipped the branch. There is no + `removal_spent`: a card that cannot be taken back is left settled and + readable, which is a tolerable end state, but it is not one to write down + as done β€” so the attempt keeps stretching rather than stopping, exactly as + a recovery search does. + `gateway_public_url` is here for one message: the notice sent when a card's delivery can never be confirmed, which is only useful if it can say where the request *can* be answered. It may be None, and then the notice names @@ -371,13 +389,46 @@ async def refresh_cards( ), ) refreshed(post.token, state) - if cards.removes_approved_cards and granted(request): - # After the redraw, not instead of it. A refused removal - # has to leave a card showing what was decided, and this - # is the pass that makes it show it β€” so the settled - # drawing is put up first and taken away second, and every - # point this can stop at leaves the reader something true. - await cards.remove(post) + if post is not None and cards.removes_approved_cards and granted(request): + # A stage of its own, deliberately not a step of the redraw + # above. A granted card with no removal recorded is one still + # owed, and that stays true on a cycle where nothing about the + # card changed β€” which is every cycle after the one that drew + # it settled. Hanging the removal off `refresh_needed` meant a + # single rate limit lost the cleanup for the life of the + # process, and left the card recovered a cycle late never + # reached at all. + # + # A card already taken back never arrives here: the branch + # above skips its row outright, which is also what stops the + # address being deleted once a cycle forever. + # + # It runs after the redraw rather than instead of it for the + # same reason it retries: the card that a failure leaves + # behind has to be one showing what was decided. + if not removal_allowed(post.token): + backed_off += 1 + else: + try: + await cards.remove(post) + except RichContentThrottled as throttled: + removal_delayed(post.token, throttled.retry_after) + backed_off += 1 + except RemovalFailed as refusal: + backed_off += 1 + logger.warning( + "Card %s for request %s was granted but %s would " + "not take it back: %s. It stays in channel %s " + "showing the decision until a later attempt gets " + "through.", + post.handle, + post.request_id, + cards.surface, + refusal, + post.external_channel_id, + ) + else: + removal_succeeded(post.token) except RichContentThrottled: backed_off += 1 except Exception as error: @@ -996,6 +1047,7 @@ def __init__( self._published: dict[str, tuple[int, bool]] = {} self._recovery = _RecoveryBackoff() self._card_post = _RecoveryBackoff() + self._card_removal = _RecoveryBackoff() self._redraw = _RedrawGuard() self._turn_redraw = _TurnRedrawGuard() self._activity_retry = _RecoveryBackoff(max_interval=30.0) @@ -1119,6 +1171,9 @@ async def publish_pending(self) -> None: post_delayed=self._card_post.delay, refresh_needed=self._redraw.needed, refreshed=self._redraw.drawn, + removal_allowed=self._card_removal.allowed, + removal_succeeded=self._card_removal.succeeded, + removal_delayed=self._card_removal.delay, ) except PublicationIncomplete as incomplete: ok = False diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py index 17463f10f..98e31fc76 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py @@ -22,6 +22,7 @@ from switch_core.bridges.collaboration.adapter import ( CollaborationAdapter, RemovalFailed, + RichContentThrottled, ) from switch_core.bridges.collaboration.slack.adapter import SlackAdapter @@ -32,13 +33,16 @@ class _RefusingWebClient(_FakeWebClient): - def __init__(self, error: str) -> None: + def __init__(self, error: str, headers: dict[str, str] | None = None) -> None: super().__init__() self._error = error + self._headers = headers async def chat_delete(self, **kwargs: Any) -> dict[str, bool]: self.deletes.append(kwargs) - raise SlackApiError("no", FakeResponse({"error": self._error})) + raise SlackApiError( + "no", FakeResponse({"error": self._error}, headers=self._headers) + ) def _connected(client: Any) -> SlackAdapter: @@ -86,6 +90,31 @@ def test_a_card_already_gone_is_reported_as_gone_but_said_out_loud( assert "already gone" in caplog.text +def test_being_asked_to_wait_is_kept_apart_from_being_refused() -> None: + """A rate limit says nothing about the card, so it must not arrive as + `RemovalFailed`: the caller's backoff would then double its own interval + against a delay Slack had already named, and a channel busy enough for + long enough would look like one that will not delete.""" + client = _RefusingWebClient("ratelimited", {"Retry-After": "31"}) + + with pytest.raises(RichContentThrottled) as raised: + _run(_connected(client).remove_publication("C123", CARD)) + + assert raised.value.retry_after == 31 + assert isinstance(raised.value.__cause__, SlackApiError) + + +def test_a_rate_limit_with_no_header_still_waits_rather_than_refusing() -> None: + """Slack does not always send the header. The wait is a guess then, but + the classification is not: the deletion is still owed.""" + client = _RefusingWebClient("ratelimited") + + with pytest.raises(RichContentThrottled) as raised: + _run(_connected(client).remove_publication("C123", CARD)) + + assert raised.value.retry_after > 0 + + def test_an_unparseable_reference_never_reaches_slack() -> None: """A bare ts would be deleted from whatever channel was passed alongside it. Refusing is the only safe reading of an address we cannot split.""" diff --git a/core/tests/switch_core/sessions/test_approved_card_removal.py b/core/tests/switch_core/sessions/test_approved_card_removal.py index 4e1a51fa5..9c9ff1423 100644 --- a/core/tests/switch_core/sessions/test_approved_card_removal.py +++ b/core/tests/switch_core/sessions/test_approved_card_removal.py @@ -15,26 +15,37 @@ from __future__ import annotations +import asyncio import logging import pytest from sqlalchemy import select -from switch_core.bridges.collaboration.adapter import RemovalFailed +from switch_core.bridges.collaboration.adapter import ( + RemovalFailed, + RichContentThrottled, +) from switch_core.bridges.collaboration.models import InboundInteraction from switch_core.bridges.collaboration.session.inbound import SessionInteractions from switch_core.bridges.collaboration.session.outbound import SessionRequestCards from switch_core.bridges.collaboration.session.renderers import ANSWER_ACTION from switch_core.db.models import SessionRequestPost from switch_core.db.stores.session_request_post_store import SessionRequestPostStore -from switch_core.sessions.publication import refresh_cards +from switch_core.sessions.publication import PublicationIncomplete, refresh_cards from .test_authority import host_event, opened, setup from .test_publication import Platform class Removing(Platform): - """A platform that can take a card back, and remembers being asked to.""" + """A platform that can take a card back, and remembers being asked to. + + `lose_response` models the gap a deletion cannot avoid: the message goes, + and the answer saying so does not arrive. The attempt after it returns + normally, which is what the Slack adapter does when it asks about an + address the platform no longer knows β€” the deletion is confirmed by its + own absence rather than by the reply that went missing. + """ removes_approved_cards = True @@ -42,11 +53,29 @@ def __init__(self) -> None: super().__init__() self.removed: list[tuple[str, str]] = [] self.refuse: str | None = None + self.throttle: float | None = None + self.lose_response = False async def remove_publication(self, channel: str, message_ref: str) -> None: self.removed.append((channel, message_ref)) + if self.throttle is not None: + raise RichContentThrottled( + retry_after=self.throttle, text="Waiting for the platform." + ) if self.refuse is not None: raise RemovalFailed(self.refuse) + if self.lose_response: + self.lose_response = False + raise TimeoutError("the delete was accepted; the reply never came") + + +def _guards() -> dict[str, object]: + """The real redraw pair, so a cycle that changes nothing draws nothing.""" + seen: dict[str, tuple[int, str]] = {} + return { + "refresh_needed": lambda token, state: seen.get(token) != state, + "refreshed": lambda token, state: seen.__setitem__(token, state), + } async def _card(session_factory, platform): @@ -191,15 +220,19 @@ async def test_a_card_already_taken_away_is_not_drawn_again(session_factory): assert len(platform.removed) == 1 -async def test_a_refused_removal_leaves_the_settled_card_and_says_nothing_more( +async def test_a_refused_removal_leaves_the_settled_card_and_stays_owed( session_factory, caplog ): - """A platform that will not delete is the case the mark must not survive. + """A platform that will not delete is the case the mark must not appear. - Left set, the row would claim a card a reader can plainly see is gone, and - the publisher would stop maintaining it. Cleared, the card is exactly what + Written, the row would claim a card a reader can plainly see is gone, and + the publisher would stop maintaining it. Absent, the card is exactly what a platform without the capability would have left β€” settled, answering nothing β€” and the refusal is in the log rather than in the channel. + + The cycle is also reported incomplete, which is the whole of what makes + the next one happen: `SessionPublisher` records a session as published + only when its cards came back clean. """ platform = Removing() platform.refuse = "cant_delete_message" @@ -207,13 +240,153 @@ async def test_a_refused_removal_leaves_the_settled_card_and_says_nothing_more( await _answer(service, epoch, posts, post, session_factory, "allow-once") with caplog.at_level(logging.WARNING): - await refresh_cards(session_factory, "bridge", "session-demo", cards) + with pytest.raises(PublicationIncomplete) as incomplete: + await refresh_cards(session_factory, "bridge", "session-demo", cards) + assert incomplete.value.backed_off == 1 assert "Allow once" in platform.edits[-1][2] assert await _removed_at(session_factory) is None assert "cant_delete_message" in caplog.text +async def test_a_cancelled_removal_records_nothing(session_factory): + """The mark cannot be laid down in advance of the fact it records. + + A publisher cancelled mid-cycle β€” a shutdown, a lease lost β€” must not + leave behind a row saying a visible card is gone. Nothing would ever look + at that row again, so the card would keep its decision on screen forever + while the record said it had been taken away. + """ + + class Cancelling(Removing): + async def remove_publication(self, channel: str, message_ref: str) -> None: + raise asyncio.CancelledError() + + platform = Cancelling() + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + + with pytest.raises(asyncio.CancelledError): + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert await _removed_at(session_factory) is None + + +async def test_a_deletion_whose_reply_was_lost_is_settled_by_asking_again( + session_factory, +): + """The other half of recording nothing in advance: recording late. + + The card goes and the acknowledgement does not arrive, so the row still + says a removal is owed. Asking a second time is what closes it β€” the + platform reports nothing at the address, which is the same fact as having + just deleted it, and the record finally catches up. + """ + platform = Removing() + platform.lose_response = True + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + + with pytest.raises(TimeoutError): + await refresh_cards(session_factory, "bridge", "session-demo", cards) + assert await _removed_at(session_factory) is None + + await refresh_cards(session_factory, "bridge", "session-demo", cards) + + assert len(platform.removed) == 2 + assert await _removed_at(session_factory) is not None + + +async def test_a_rate_limited_removal_carries_the_wait_the_platform_asked_for( + session_factory, +): + """Being told to wait is not being told no. + + A throttle has to reach the backoff as the platform's own delay, or the + generic doubling would either hammer a busy channel or sit out a wait far + longer than the one asked for. Nothing about the card is recorded either + way: the deletion is still owed. + """ + platform = Removing() + platform.throttle = 27.0 + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + delays: list[tuple[str, float]] = [] + + with pytest.raises(PublicationIncomplete): + await refresh_cards( + session_factory, + "bridge", + "session-demo", + cards, + removal_delayed=lambda token, seconds: delays.append((token, seconds)), + ) + + assert delays == [(post.token, 27.0)] + assert await _removed_at(session_factory) is None + + +async def test_a_transient_refusal_is_retried_without_another_redraw(session_factory): + """Cleanup is owed by the record, not by anything having changed. + + This is the failure that hanging removal off the redraw produced: the + second cycle sees a card at the same revision and state, draws nothing β€” + correctly β€” and under the old shape skipped the deletion with it, for the + life of the process. The card has to be tried again anyway. + """ + platform = Removing() + platform.refuse = "ratelimited" + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + guards = _guards() + + with pytest.raises(PublicationIncomplete): + await refresh_cards(session_factory, "bridge", "session-demo", cards, **guards) + drawn = len(platform.edits) + platform.refuse = None + await refresh_cards(session_factory, "bridge", "session-demo", cards, **guards) + + assert len(platform.edits) == drawn + assert len(platform.removed) == 2 + assert await _removed_at(session_factory) is not None + + +async def test_a_card_recovered_after_it_was_granted_is_taken_back(session_factory): + """Approved in Console while the send was still unconfirmed. + + Recovery binds the reservation to the message it finds and draws it + settled, and that is the only cycle in which anything about the card + changes. A removal reachable only from the redraw branch never ran here at + all, and no later cycle went near it. + """ + + class Recovering(Removing): + recovers_uncertain_posts = True + + def __init__(self) -> None: + super().__init__() + self.found = "" + + async def find_request_card(self, channel, thread, token, since, handle): + return self.found + + platform = Recovering() + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + platform.found = post.external_post_id + async with session_factory() as db: + stored = await db.get(SessionRequestPost, post.id) + stored.external_post_id = stored.token + await db.commit() + guards = _guards() + + await refresh_cards(session_factory, "bridge", "session-demo", cards, **guards) + await refresh_cards(session_factory, "bridge", "session-demo", cards, **guards) + + assert platform.removed == [("channel-demo", post.external_post_id)] + assert await _removed_at(session_factory) is not None + + async def test_a_removed_card_still_answers_to_its_handle(session_factory): """The row is not the card. Someone who typed the handle before the card went, or who is reading the audit, still resolves to the same request β€” @@ -235,9 +408,11 @@ async def test_a_removed_card_still_answers_to_its_handle(session_factory): async def test_removal_is_attempted_once_however_often_the_publisher_runs( session_factory, calls ): - """Every cycle reaches every row, and a second delete of the same message - is a second chance to act on `message_not_found` from an address that has - since been reused.""" + """The counterweight to retrying a failure: a removal that succeeded is + finished. Every cycle reaches every row, and the recorded mark is the only + thing standing between that and deleting the same address once a cycle for + as long as the session lives β€” by then an address Slack may have given to + somebody else's message.""" platform = Removing() service, epoch, posts, cards, post = await _card(session_factory, platform) await _answer(service, epoch, posts, post, session_factory, "allow-once") From dca280c35842733623ebadc12780d5d273f61bbc Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 11:22:21 +0100 Subject: [PATCH 064/120] Ask whether a granted card is still there before drawing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The removal of a granted card ran after the redraw, which is only safe while the card is certainly there. It is not: a deletion whose acknowledgement was lost, or a process that died before the timestamp committed, leaves a row still owing a removal and no message at the address. A restart has an empty redraw guard, so the first thing it does is edit that message. The edit fails, the "card could not be updated" fallback posts a notice about a card nobody can see, and it re-raises before the second delete β€” the one that would have found the address empty and settled it β€” is ever reached. Every restart says it again, and the row never catches up. Ask the platform first. A removal that succeeds leaves nothing to draw, so the cycle moves on; a removal refused or throttled falls through to the settled draw exactly as before, which is the card a failure is meant to leave behind. Recovery keeps its unconditional draw through a flag rather than the redraw gate: the gate was told about the post that went unconfirmed, so at the same revision and state it reads a message found by its handle as one already drawn. The fake that hid this now refuses edits to a message it has deleted. Co-Authored-By: Claude Opus 5 --- core/switch_core/sessions/publication.py | 64 ++++----- .../sessions/test_approved_card_removal.py | 125 +++++++++++++++++- 2 files changed, 151 insertions(+), 38 deletions(-) diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 49e2bba77..284d0e9b6 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -295,6 +295,10 @@ async def refresh_cards( else "" ), ) + # A card found again after an uncertain post is drawn whatever the + # redraw gate says: the gate was told about the post that went missing, + # so at this revision and state it reads as a card already drawn. + recovered = False try: if post is None: if request.state != "open": @@ -366,46 +370,29 @@ async def refresh_cards( continue post = await cards.recover(post) recovery_succeeded(post.token) - await cards.refresh( - post, - request, - agent_name=agent_name, - **( - {"unavailable_reason": unavailable_reason} - if unavailable_reason - else {} - ), - ) - refreshed(post.token, state) - elif refresh_needed(post.token, state): - await cards.refresh( - post, - request, - agent_name=agent_name, - **( - {"unavailable_reason": unavailable_reason} - if unavailable_reason - else {} - ), - ) - refreshed(post.token, state) + recovered = True if post is not None and cards.removes_approved_cards and granted(request): - # A stage of its own, deliberately not a step of the redraw - # above. A granted card with no removal recorded is one still - # owed, and that stays true on a cycle where nothing about the - # card changed β€” which is every cycle after the one that drew - # it settled. Hanging the removal off `refresh_needed` meant a + # A stage of its own, deliberately not a step of the redraw. A + # granted card with no removal recorded is one still owed, and + # that stays true on a cycle where nothing about the card + # changed β€” which is every cycle after the one that drew it + # settled. Hanging the removal off `refresh_needed` meant a # single rate limit lost the cleanup for the life of the # process, and left the card recovered a cycle late never # reached at all. # + # It is asked before the card is drawn because the two + # questions are not independent: a card that is already gone + # cannot be edited, so drawing first turns a deletion whose + # acknowledgement was lost into a failed edit β€” and the notice + # that failure posts is a claim about a card nobody can see, + # made on every restart, while the deletion that would settle + # the address is never reached. Asking first settles it either + # way, and a card the platform still holds is drawn below. + # # A card already taken back never arrives here: the branch # above skips its row outright, which is also what stops the # address being deleted once a cycle forever. - # - # It runs after the redraw rather than instead of it for the - # same reason it retries: the card that a failure leaves - # behind has to be one showing what was decided. if not removal_allowed(post.token): backed_off += 1 else: @@ -429,6 +416,19 @@ async def refresh_cards( ) else: removal_succeeded(post.token) + continue + if post is not None and (recovered or refresh_needed(post.token, state)): + await cards.refresh( + post, + request, + agent_name=agent_name, + **( + {"unavailable_reason": unavailable_reason} + if unavailable_reason + else {} + ), + ) + refreshed(post.token, state) except RichContentThrottled: backed_off += 1 except Exception as error: diff --git a/core/tests/switch_core/sessions/test_approved_card_removal.py b/core/tests/switch_core/sessions/test_approved_card_removal.py index 9c9ff1423..b70127925 100644 --- a/core/tests/switch_core/sessions/test_approved_card_removal.py +++ b/core/tests/switch_core/sessions/test_approved_card_removal.py @@ -23,6 +23,7 @@ from switch_core.bridges.collaboration.adapter import ( RemovalFailed, + RichContentFailed, RichContentThrottled, ) from switch_core.bridges.collaboration.models import InboundInteraction @@ -45,6 +46,10 @@ class Removing(Platform): normally, which is what the Slack adapter does when it asks about an address the platform no longer knows β€” the deletion is confirmed by its own absence rather than by the reply that went missing. + + Once the message is gone, editing it is refused. A fake that kept + accepting edits let the deleted card be drawn as though it were still + there, which is the one thing no real platform does. """ removes_approved_cards = True @@ -55,6 +60,7 @@ def __init__(self) -> None: self.refuse: str | None = None self.throttle: float | None = None self.lose_response = False + self.gone = False async def remove_publication(self, channel: str, message_ref: str) -> None: self.removed.append((channel, message_ref)) @@ -64,10 +70,18 @@ async def remove_publication(self, channel: str, message_ref: str) -> None: ) if self.refuse is not None: raise RemovalFailed(self.refuse) + self.gone = True if self.lose_response: self.lose_response = False raise TimeoutError("the delete was accepted; the reply never came") + async def update_rich(self, channel, agent, post, content, thread): + if self.gone: + raise RichContentFailed( + f"No message at {post}.", text="Permission granted." + ) + await super().update_rich(channel, agent, post, content, thread) + def _guards() -> dict[str, object]: """The real redraw pair, so a cycle that changes nothing draws nothing.""" @@ -149,21 +163,25 @@ async def _removed_at(session_factory): return (await db.scalar(select(SessionRequestPost))).removed_at -async def test_a_granted_card_is_drawn_settled_and_then_taken_away(session_factory): - """Settled first, removed second, and not the other way round. +async def test_a_granted_card_is_taken_away_rather_than_drawn_settled(session_factory): + """Asked for first, drawn only if the platform still has it. - The redraw is what a refused removal falls back to, so the card has to be - made to say what was decided before anything tries to delete it β€” and if - the process stops in between, what is left behind is an honest card. + The settled draw is the fallback for a removal that did not happen, not a + step on the way to one: editing a card into its final state and deleting + it in the same breath shows a reader nothing, and on the cycle after a + deletion whose reply was lost it is an edit to a message that is not + there β€” which fails, says so in the channel, and stops the deletion ever + being confirmed. """ platform = Removing() service, epoch, posts, cards, post = await _card(session_factory, platform) await _answer(service, epoch, posts, post, session_factory, "allow-once") + drawn = len(platform.edits) await refresh_cards(session_factory, "bridge", "session-demo", cards) - assert "Allow once" in platform.edits[-1][2] assert platform.removed == [("channel-demo", post.external_post_id)] + assert len(platform.edits) == drawn assert await _removed_at(session_factory) is not None @@ -297,6 +315,62 @@ async def test_a_deletion_whose_reply_was_lost_is_settled_by_asking_again( assert await _removed_at(session_factory) is not None +async def test_a_restart_confirms_a_deletion_the_process_never_wrote_down( + session_factory, +): + """The same gap, across the restart that makes it permanent. + + A new process has no memory of what it drew, so every card reads as one + due a redraw. The card here is not there to redraw: the edit fails, the + "could not be updated" notice goes into the channel the card was taken out + of, and the failure is raised before anything asks the platform whether the + message is still at that address. Every later restart repeats the notice, + and the row goes on owing a deletion that already happened. Asking first + ends it instead: the platform reports nothing there, which is the removal + confirmed, and the channel is told nothing it would have to unlearn. + """ + + class Talking(Removing): + """Says out loud when a failed redraw falls back to a reply.""" + + def __init__(self) -> None: + super().__init__() + self.notices: list[str] = [] + + def notice_address(self, message_ref: str, thread: str | None) -> str: + return thread or message_ref + + async def admin_message(self, channel, text, address, *, drawn): + self.notices.append(text) + return f"{channel}:notice" + + platform = Talking() + service, epoch, posts, cards, post = await _card(session_factory, platform) + await _answer(service, epoch, posts, post, session_factory, "allow-once") + platform.lose_response = True + with pytest.raises(TimeoutError): + await refresh_cards( + session_factory, "bridge", "session-demo", cards, **_guards() + ) + assert await _removed_at(session_factory) is None + + restarted = SessionRequestCards( + platform, + bridge_id="bridge", + surface="slack", + posts=posts, + session_factory=session_factory, + ) + await refresh_cards( + session_factory, "bridge", "session-demo", restarted, **_guards() + ) + + assert len(platform.removed) == 2 + assert platform.notices == [] + assert len(platform.posts) == 1 + assert await _removed_at(session_factory) is not None + + async def test_a_rate_limited_removal_carries_the_wait_the_platform_asked_for( session_factory, ): @@ -387,6 +461,45 @@ async def find_request_card(self, channel, thread, token, since, handle): assert await _removed_at(session_factory) is not None +async def test_a_card_found_again_is_drawn_whatever_the_gate_remembers( + session_factory, +): + """Recovery ends in a draw, and the redraw gate cannot say otherwise. + + The gate was told about the post whose delivery then went unconfirmed, so + at the same revision and state it reads the found message as a card + already drawn. It is not: it is a message matched by its handle, and + drawing it once is what makes what the channel shows and what the record + says agree. Sharing the settled card's gate would skip that on exactly the + cycle nothing else had changed. + """ + + class Recovering(Removing): + recovers_uncertain_posts = True + + def __init__(self) -> None: + super().__init__() + self.found = "" + + async def find_request_card(self, channel, thread, token, since, handle): + return self.found + + platform = Recovering() + service, epoch, posts, cards, post = await _card(session_factory, platform) + platform.found = post.external_post_id + guards = _guards() + await refresh_cards(session_factory, "bridge", "session-demo", cards, **guards) + drawn = len(platform.edits) + async with session_factory() as db: + stored = await db.get(SessionRequestPost, post.id) + stored.external_post_id = stored.token + await db.commit() + + await refresh_cards(session_factory, "bridge", "session-demo", cards, **guards) + + assert len(platform.edits) == drawn + 1 + + async def test_a_removed_card_still_answers_to_its_handle(session_factory): """The row is not the card. Someone who typed the handle before the card went, or who is reading the audit, still resolves to the same request β€” From 8b9f3a16212a29fd0eae5049ff528b1bf2601bfd Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 11:31:26 +0100 Subject: [PATCH 065/120] Take a permission card back on any answer, not only on a yes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested behaviour change: a card answered "Deny" is removed from the channel too. The reasoning that kept it β€” the channel holding the only readable record of a refusal β€” does not survive contact with where that record actually lives: the request, its decision and the person who made it are in the session, in Console and in the row, which outlives the card either way. What is left behind on a refusal is a settled message still occupying a channel. So the predicate stops asking which way the answer went. `granted` becomes `decided`, and `GRANTS` goes with it: what matters is that the host confirmed a person chose an option the card actually offered. An answer naming an option the request never had, an approval settled with no result, a questions result against an approval β€” each is still unreadable, and an unreadable answer still leaves a card someone can read by hand. So does a request that ended with no answer at all: expired, withdrawn, or reported as a provider error. `removes_approved_cards` is renamed `removes_answered_cards` to match, and the two test modules with "granted"/"approved" in their names are renamed rather than left describing a rule that no longer exists. Also corrects the `removed_at` comment in models.py, which still described the mark being written before the platform was asked and cleared on refusal β€” the ordering that was fixed two commits ago. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 2 +- .../bridges/collaboration/session/outbound.py | 8 +-- .../bridges/collaboration/slack/adapter.py | 2 +- core/switch_core/db/models.py | 11 +-- ...73a0d58_session_request_post_removed_at.py | 4 +- core/switch_core/sessions/contract.py | 42 +++++------- core/switch_core/sessions/publication.py | 14 ++-- .../collaboration/test_slack_card_removal.py | 6 +- ...moval.py => test_answered_card_removal.py} | 59 ++++++++++------ .../{test_granted.py => test_decided.py} | 68 +++++++++---------- 10 files changed, 115 insertions(+), 101 deletions(-) rename core/tests/switch_core/sessions/{test_approved_card_removal.py => test_answered_card_removal.py} (90%) rename core/tests/switch_core/sessions/{test_granted.py => test_decided.py} (66%) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 0b5338179..1764b93e5 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -865,7 +865,7 @@ async def remove_publication(self, channel_id: str, message_ref: str) -> None: settled by simply asking again. `RichContentThrottled` where the platform named a wait, `RemovalFailed` for anything else. - Not reached unless the adapter also sets `removes_approved_cards`, + Not reached unless the adapter also sets `removes_answered_cards`, which is why this refuses rather than quietly doing nothing: a platform brought into the removal flow without an implementation should stop, not report success for a card still on the screen. diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index c9371bf89..889c7f1b7 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -1474,8 +1474,8 @@ def discloses_unconfirmed_posts(self) -> bool: return bool(getattr(self._adapter, "discloses_unconfirmed_posts", False)) @property - def removes_approved_cards(self) -> bool: - """Whether a granted card can be taken off this platform, provably. + def removes_answered_cards(self) -> bool: + """Whether an answered card can be taken off this platform, provably. Two things have to hold, and a platform that manages only the first is False: the platform will delete a message posted under an agent's own @@ -1483,7 +1483,7 @@ def removes_approved_cards(self) -> bool: having been ignored. Where it is False the card is left settled, which is what every platform did before any of them could do better. """ - return bool(getattr(self._adapter, "removes_approved_cards", False)) + return bool(getattr(self._adapter, "removes_answered_cards", False)) async def post( self, @@ -1746,7 +1746,7 @@ async def disclose_unconfirmed( ) async def remove(self, post: SessionRequestPost) -> None: - """Take a card off the platform now that its approval has been given. + """Take a card off the platform now that it has been answered. Nothing is written until the platform has said the card is gone. `removed_at` is the publisher's evidence that there is no message left diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 0962a7c44..f68e95fe3 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -178,7 +178,7 @@ class SlackAdapter(CollaborationAdapter): #: icon. Slack restricts deleting an *impersonated* message, which is a #: message sent as a real member; `chat:write.customize` is not that, and #: the typing indicator has always been posted and deleted this way. - removes_approved_cards: ClassVar[bool] = True + removes_answered_cards: ClassVar[bool] = True #: Every publication carries its token in `block_id` and in the message's #: metadata, so a status is as findable as a card despite printing no diff --git a/core/switch_core/db/models.py b/core/switch_core/db/models.py index 6c7aea07c..22211ae7c 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -1476,13 +1476,14 @@ class SessionRequestPost(TenantScoped, Base): unconfirmed_notice_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) - # When the card was taken off the platform after the approval it asked for - # was granted. The row outlives the card on purpose: it is what an answer + # When the card was taken off the platform, once the question it asked had + # been answered. The row outlives the card on purpose: it is what an answer # typed against the handle still resolves to, and it is what stops a # restart from treating a deleted card as one that merely needs redrawing - # and posting the approved question a second time. Set before the platform - # is asked and cleared if it refuses, so the state that survives a crash - # mid-removal is the one that leaves the settled card alone. + # and posting the settled question a second time. Written only after the + # platform has confirmed the message is gone, so a crash mid-removal leaves + # a card that is asked about again rather than one recorded as removed and + # never looked at. removed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py b/core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py index 908429c3c..b18ef87f3 100644 --- a/core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py +++ b/core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py @@ -1,8 +1,8 @@ -"""record when an approved request card was taken off the platform +"""record when an answered request card was taken off the platform The row has to outlive the card it named: a typed answer still resolves against it, and without a mark saying the card is gone a restart reads the row -as a card that merely needs redrawing and posts the granted question again. +as a card that merely needs redrawing and posts the settled question again. Null for every card still standing, which is every card there is when this runs β€” so no backfill, and an older row is correctly read as not removed. diff --git a/core/switch_core/sessions/contract.py b/core/switch_core/sessions/contract.py index 475b3bf22..554be18eb 100644 --- a/core/switch_core/sessions/contract.py +++ b/core/switch_core/sessions/contract.py @@ -466,26 +466,23 @@ def parse_command(payload: Any) -> Command: return Command.model_validate(payload) -# The two decisions that grant what was asked for. `cancel` is not a refusal -# and `decline` is the refusal, but neither is a grant, and the pair is named -# once so that a third grant added to `ApprovalOption.decision` is added here. -GRANTS = frozenset({"accept", "acceptForSession"}) - - -def granted(request: SnapshotRequest) -> bool: - """Whether the host confirmed that this approval was given. - - Not "has it finished": `answered` is equally the outcome of a refusal, and - `resolved` equally its state, so neither says which way it went. The one - record of that is the decision carried by the option the result names, and - reaching it means matching the result back to the options the request was - asked with. - - Everything short of that match is read as not granted, because the caller - is a caller that acts on a yes. An answer naming an option the request - never offered, an approval settled with no result at all, a questions - result on an approval: each is a host saying something this cannot - interpret, and none of them is evidence of consent. +def decided(request: SnapshotRequest) -> bool: + """Whether the host confirmed which option a person chose on this card. + + Not "has it finished". `answered` is equally the outcome of a request that + ended without anyone reading it, and `resolved` equally its state; what + this asks is narrower, and deliberately says nothing about *which* way the + answer went. Both directions end the card's usefulness β€” the decision is + in the session, in Console and in the row, not in a message still offering + buttons that no longer do anything. + + Reaching that fact means matching the result back to the options the + request was asked with, and everything short of the match is read as + undecided, because the caller acts on it irreversibly. An answer naming an + option the request never offered, an approval settled with no result at + all, a questions result on an approval: each is a host saying something + this cannot interpret, and an unreadable answer leaves a card a person can + read by hand. """ if request.state != "resolved": return False @@ -498,7 +495,4 @@ def granted(request: SnapshotRequest) -> bool: content, ApprovalContent ): return False - return any( - option.option_id == answer.option_id and option.decision in GRANTS - for option in content.options - ) + return any(option.option_id == answer.option_id for option in content.options) diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index 284d0e9b6..f2142b903 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -35,7 +35,7 @@ Command, Snapshot, TurnUpsert, - granted, + decided, ) from switch_core.sessions.presentation import ( activity_error_summary, @@ -163,7 +163,7 @@ async def refresh_cards( against what is actually recorded for it, every time. `removal_allowed` / `removal_succeeded` / `removal_delayed` are the same - three-part gate as the post's, for taking a granted card back, and they + three-part gate as the post's, for taking an answered card back, and they are kept apart from `refresh_needed` on purpose: whether a card still owes a deletion is a fact about the record, not about whether anything has changed since it was last drawn. Sharing the redraw's gate meant one @@ -341,7 +341,7 @@ async def refresh_cards( post_succeeded(attempt) refreshed(new_post.token, state) elif post.removed_at is not None: - # The card was taken back when its approval was granted. The + # The card was taken back once its question was answered. The # row stays so a typed answer still resolves, but there is no # longer a message at that address: redrawing it would fail, # and recovering it would find whatever now sits where it was. @@ -371,9 +371,9 @@ async def refresh_cards( post = await cards.recover(post) recovery_succeeded(post.token) recovered = True - if post is not None and cards.removes_approved_cards and granted(request): - # A stage of its own, deliberately not a step of the redraw. A - # granted card with no removal recorded is one still owed, and + if post is not None and cards.removes_answered_cards and decided(request): + # A stage of its own, deliberately not a step of the redraw. An + # answered card with no removal recorded is one still owed, and # that stays true on a cycle where nothing about the card # changed β€” which is every cycle after the one that drew it # settled. Hanging the removal off `refresh_needed` meant a @@ -404,7 +404,7 @@ async def refresh_cards( except RemovalFailed as refusal: backed_off += 1 logger.warning( - "Card %s for request %s was granted but %s would " + "Card %s for request %s was answered but %s would " "not take it back: %s. It stays in channel %s " "showing the decision until a later attempt gets " "through.", diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py index 98e31fc76..0915c9e07 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py @@ -134,9 +134,9 @@ def test_a_disconnected_adapter_does_not_claim_the_card_was_removed() -> None: def test_slack_is_the_platform_that_says_it_can_do_this() -> None: - """The capability is what routes a granted card here at all, and the four + """The capability is what routes an answered card here at all, and the four platforms still to come are the ones that have not claimed it.""" - assert SlackAdapter.removes_approved_cards is True + assert SlackAdapter.removes_answered_cards is True def test_a_platform_with_no_implementation_refuses_rather_than_pretends() -> None: @@ -147,6 +147,6 @@ def test_a_platform_with_no_implementation_refuses_rather_than_pretends() -> Non class Unimplemented: remove_publication = CollaborationAdapter.remove_publication - assert getattr(Unimplemented(), "removes_approved_cards", False) is False + assert getattr(Unimplemented(), "removes_answered_cards", False) is False with pytest.raises(RemovalFailed): _run(Unimplemented().remove_publication("C123", CARD)) # type: ignore[arg-type] diff --git a/core/tests/switch_core/sessions/test_approved_card_removal.py b/core/tests/switch_core/sessions/test_answered_card_removal.py similarity index 90% rename from core/tests/switch_core/sessions/test_approved_card_removal.py rename to core/tests/switch_core/sessions/test_answered_card_removal.py index b70127925..64ee4ed4c 100644 --- a/core/tests/switch_core/sessions/test_approved_card_removal.py +++ b/core/tests/switch_core/sessions/test_answered_card_removal.py @@ -1,13 +1,14 @@ -"""A permission card after the permission has been given. +"""A permission card after somebody has answered it. -A card that has been answered yes has nothing left to ask, and on a platform -that can prove it took the message back it is removed rather than left as a -settled notice. A refusal is not removed: it is the only durable record in the -channel that someone said no. +An answered card has nothing left to ask, whichever way it was answered, and +on a platform that can prove it took the message back it is removed rather +than left as a settled notice. What stays is a card nobody has decided: +unanswered, still being submitted, or ended without an answer at all. -The row outlives the card either way, because it is what a handle typed into -the channel still resolves to, and because without it a restarted publisher -would read the request as one whose card had never been drawn. +The row outlives the card, because it is what a handle typed into the channel +still resolves to, and because without it a restarted publisher would read the +request as one whose card had never been drawn. The decision itself is in the +session and in Console; the channel is not where it is kept. `test_publication.py` covers the same path up to settlement; this is what happens after it. @@ -52,7 +53,7 @@ class Removing(Platform): there, which is the one thing no real platform does. """ - removes_approved_cards = True + removes_answered_cards = True def __init__(self) -> None: super().__init__() @@ -77,9 +78,7 @@ async def remove_publication(self, channel: str, message_ref: str) -> None: async def update_rich(self, channel, agent, post, content, thread): if self.gone: - raise RichContentFailed( - f"No message at {post}.", text="Permission granted." - ) + raise RichContentFailed(f"No message at {post}.", text="Allow once.") await super().update_rich(channel, agent, post, content, thread) @@ -163,7 +162,9 @@ async def _removed_at(session_factory): return (await db.scalar(select(SessionRequestPost))).removed_at -async def test_a_granted_card_is_taken_away_rather_than_drawn_settled(session_factory): +async def test_an_answered_card_is_taken_away_rather_than_drawn_settled( + session_factory, +): """Asked for first, drawn only if the platform still has it. The settled draw is the fallback for a removal that did not happen, not a @@ -185,16 +186,36 @@ async def test_a_granted_card_is_taken_away_rather_than_drawn_settled(session_fa assert await _removed_at(session_factory) is not None -async def test_a_refusal_keeps_its_card(session_factory): - """The channel's only record that permission was asked for and withheld. - Deleting it would leave the audit holding the one copy of that.""" +async def test_a_refusal_is_taken_away_too(session_factory): + """A card answered no is as finished as one answered yes. + + The refusal is not lost with it: the request, its decision and who made it + are in the session and in Console, and the row the handle resolves to + stays. What goes is a message in a channel still showing buttons for a + question that has been settled. + """ platform = Removing() service, epoch, posts, cards, post = await _card(session_factory, platform) await _answer(service, epoch, posts, post, session_factory, "deny") await refresh_cards(session_factory, "bridge", "session-demo", cards) - assert "Deny" in platform.edits[-1][2] + assert platform.removed == [("channel-demo", post.external_post_id)] + assert await _removed_at(session_factory) is not None + + +async def test_a_card_nobody_has_answered_is_left_alone(session_factory): + """The line the removal stands on: a decision, not a settlement. + + A request can end without anyone deciding it β€” it expires, the agent + withdraws it, the host reports an error β€” and the card is then the only + thing in the channel that says a question was asked at all. + """ + platform = Removing() + service, epoch, posts, cards, post = await _card(session_factory, platform) + + await refresh_cards(session_factory, "bridge", "session-demo", cards) + assert platform.removed == [] assert await _removed_at(session_factory) is None @@ -425,8 +446,8 @@ async def test_a_transient_refusal_is_retried_without_another_redraw(session_fac assert await _removed_at(session_factory) is not None -async def test_a_card_recovered_after_it_was_granted_is_taken_back(session_factory): - """Approved in Console while the send was still unconfirmed. +async def test_a_card_recovered_after_it_was_answered_is_taken_back(session_factory): + """Answered in Console while the send was still unconfirmed. Recovery binds the reservation to the message it finds and draws it settled, and that is the only cycle in which anything about the card diff --git a/core/tests/switch_core/sessions/test_granted.py b/core/tests/switch_core/sessions/test_decided.py similarity index 66% rename from core/tests/switch_core/sessions/test_granted.py rename to core/tests/switch_core/sessions/test_decided.py index c7258b133..dc742f334 100644 --- a/core/tests/switch_core/sessions/test_granted.py +++ b/core/tests/switch_core/sessions/test_decided.py @@ -1,17 +1,21 @@ -"""Whether a settled request is one the person said yes to. +"""Whether a settled request is one a person actually answered. -`granted` is what decides that an approval card has served its purpose and can -be taken off the platform, so it is the one place a wrong answer costs -something irreversible: a card removed on a refusal is a refusal nobody can -read afterwards. Everything here is about the ways a settled request can look -finished without being a yes. +`decided` is what decides that a permission card has served its purpose and +can be taken off the platform, so it is the one place a wrong answer costs +something irreversible: a card removed on a request nobody answered is a +question deleted while it was still being asked. Everything here is about the +ways a settled request can look answered without one having been given. + +Which way the answer went is deliberately not asked. A refusal ends the card's +usefulness exactly as a grant does, and the decision itself lives in the +session, in Console and in the row that outlives the card. """ from __future__ import annotations import pytest -from switch_core.sessions.contract import SnapshotRequest, granted +from switch_core.sessions.contract import SnapshotRequest, decided OPTIONS = [ {"optionId": "once", "label": "Allow once", "decision": "accept"}, @@ -67,37 +71,31 @@ def _answered(option_id: str) -> SnapshotRequest: ) -@pytest.mark.parametrize("option_id", ["once", "session"]) -def test_both_ways_of_saying_yes_are_a_grant(option_id: str) -> None: - """One turn's permission and the session's are the same answer to the - question the card asked, and the card has no further use after either.""" - assert granted(_answered(option_id)) is True - - -@pytest.mark.parametrize("option_id", ["no", "stop"]) -def test_the_answers_that_are_not_yes_are_not_grants(option_id: str) -> None: - """`outcome` is "answered" for a refusal too, so the outcome alone would - remove the record of every decline ever made.""" - assert granted(_answered(option_id)) is False +@pytest.mark.parametrize("option_id", ["once", "session", "no", "stop"]) +def test_every_option_a_person_can_press_is_a_decision(option_id: str) -> None: + """Yes for this turn, yes for the session, no, and stop. Each is somebody + answering the question the card asked, and the card has no further use + after any of them.""" + assert decided(_answered(option_id)) is True def test_an_option_the_request_never_offered_decides_nothing() -> None: """A host naming an option that is not on the card is a host saying something we cannot read. The safe reading of an unreadable answer is that - consent was not established β€” so the card stays and can be read by hand.""" - assert granted(_answered("invented")) is False + nothing was decided β€” so the card stays and can be read by hand.""" + assert decided(_answered("invented")) is False @pytest.mark.parametrize("outcome", ["cancelled", "expired", "interrupted"]) -def test_a_request_that_ended_without_an_answer_is_not_a_grant(outcome: str) -> None: - assert granted(_request(state="closed", outcome=outcome, result=None)) is False +def test_a_request_that_ended_without_an_answer_is_not_decided(outcome: str) -> None: + assert decided(_request(state="closed", outcome=outcome, result=None)) is False -def test_a_provider_error_is_not_a_grant() -> None: +def test_a_provider_error_is_not_a_decision() -> None: """The one outcome that could plausibly carry a stale result alongside a failure, and the failure is what it settled as.""" assert ( - granted( + decided( _request( state="closed", outcome="provider-error", @@ -108,20 +106,20 @@ def test_a_provider_error_is_not_a_grant() -> None: ) -def test_an_answer_the_host_never_described_is_not_a_grant() -> None: +def test_an_answer_the_host_never_described_is_not_a_decision() -> None: """Answered, with nothing said about what the answer was. The settled card already has to print "the host did not say which option was chosen"; it must not be deleted on the strength of it.""" - assert granted(_request(state="resolved", outcome="answered", result=None)) is False + assert decided(_request(state="resolved", outcome="answered", result=None)) is False @pytest.mark.parametrize("state", ["open", "submitting"]) -def test_a_request_still_in_flight_is_not_a_grant(state: str) -> None: +def test_a_request_still_in_flight_is_not_decided(state: str) -> None: """`submitting` carries a chosen option before the host has confirmed it. Taking the card away then would delete a question still being asked, on - the strength of a press rather than a decision.""" + the strength of a press rather than a confirmed answer.""" assert ( - granted( + decided( _request( state=state, outcome="answered", @@ -132,12 +130,12 @@ def test_a_request_still_in_flight_is_not_a_grant(state: str) -> None: ) -def test_a_questions_answer_to_an_approval_is_not_a_grant() -> None: +def test_a_questions_answer_to_an_approval_decides_nothing() -> None: """The two result shapes are discriminated on the wire but nothing makes the pairing match its content, and an answer of the wrong kind says nothing about the approval it arrived against.""" assert ( - granted( + decided( _request( state="resolved", outcome="answered", @@ -149,10 +147,10 @@ def test_a_questions_answer_to_an_approval_is_not_a_grant() -> None: def test_a_question_is_not_an_approval_however_it_settles() -> None: - """Only an approval can be granted. A form that has been filled in is - finished, not consented to, and its card is the record of the answers.""" + """Only a permission card is taken back. A form that has been filled in is + finished, and its card is the only record of the answers.""" assert ( - granted( + decided( _request( state="resolved", outcome="answered", From 1bd9f564a255d98ca65c788d7b6af504673295fb Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 11:37:56 +0100 Subject: [PATCH 066/120] Leave a cancelled card where it is Removing the card on any option the request offered went wider than the change that was asked for. The requested amendment was that a refusal removes its card as a grant does; `cancel` is a fourth decision and the contract has always held it apart from `decline`, so extending the rule to it was an unrequested behaviour change hidden inside a requested one. `DECISIONS` names the three that answer the question a permission card asks. Cancelling stops the operation instead of permitting or refusing it, and the card it leaves is the channel's only record of where a run was halted. Widening the set later is one line if that is what is wanted. Co-Authored-By: Claude Opus 5 --- core/switch_core/sessions/contract.py | 23 ++++++++++++++----- .../sessions/test_answered_card_removal.py | 5 ++-- .../switch_core/sessions/test_decided.py | 19 +++++++++++---- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/core/switch_core/sessions/contract.py b/core/switch_core/sessions/contract.py index 554be18eb..821dc313f 100644 --- a/core/switch_core/sessions/contract.py +++ b/core/switch_core/sessions/contract.py @@ -466,15 +466,23 @@ def parse_command(payload: Any) -> Command: return Command.model_validate(payload) +# The decisions that answer the question a permission card asks: yes for this +# turn, yes for the session, and no. `cancel` is left out β€” it stops the +# operation rather than deciding it, and its card is the channel's only record +# that a run was halted where it was. Named once so that a fifth value added to +# `ApprovalOption.decision` has to be considered here. +DECISIONS = frozenset({"accept", "acceptForSession", "decline"}) + + def decided(request: SnapshotRequest) -> bool: - """Whether the host confirmed which option a person chose on this card. + """Whether the host confirmed that a person answered this card. Not "has it finished". `answered` is equally the outcome of a request that ended without anyone reading it, and `resolved` equally its state; what - this asks is narrower, and deliberately says nothing about *which* way the - answer went. Both directions end the card's usefulness β€” the decision is - in the session, in Console and in the row, not in a message still offering - buttons that no longer do anything. + this asks is narrower. It deliberately does not ask which *way* the answer + went: a yes and a no both end the card's usefulness, because the decision + is in the session, in Console and in the row, not in a message still + offering buttons that no longer do anything. Reaching that fact means matching the result back to the options the request was asked with, and everything short of the match is read as @@ -495,4 +503,7 @@ def decided(request: SnapshotRequest) -> bool: content, ApprovalContent ): return False - return any(option.option_id == answer.option_id for option in content.options) + return any( + option.option_id == answer.option_id and option.decision in DECISIONS + for option in content.options + ) diff --git a/core/tests/switch_core/sessions/test_answered_card_removal.py b/core/tests/switch_core/sessions/test_answered_card_removal.py index 64ee4ed4c..6fc3cca86 100644 --- a/core/tests/switch_core/sessions/test_answered_card_removal.py +++ b/core/tests/switch_core/sessions/test_answered_card_removal.py @@ -2,8 +2,9 @@ An answered card has nothing left to ask, whichever way it was answered, and on a platform that can prove it took the message back it is removed rather -than left as a settled notice. What stays is a card nobody has decided: -unanswered, still being submitted, or ended without an answer at all. +than left as a settled notice. What stays is a card nobody has answered: +unanswered, still being submitted, ended without an answer at all, or +cancelled β€” which stops the operation rather than deciding it. The row outlives the card, because it is what a handle typed into the channel still resolves to, and because without it a restarted publisher would read the diff --git a/core/tests/switch_core/sessions/test_decided.py b/core/tests/switch_core/sessions/test_decided.py index dc742f334..c5c52549d 100644 --- a/core/tests/switch_core/sessions/test_decided.py +++ b/core/tests/switch_core/sessions/test_decided.py @@ -8,7 +8,8 @@ Which way the answer went is deliberately not asked. A refusal ends the card's usefulness exactly as a grant does, and the decision itself lives in the -session, in Console and in the row that outlives the card. +session, in Console and in the row that outlives the card. Cancelling is the +one option that is not an answer to the question, and it keeps its card. """ from __future__ import annotations @@ -71,14 +72,22 @@ def _answered(option_id: str) -> SnapshotRequest: ) -@pytest.mark.parametrize("option_id", ["once", "session", "no", "stop"]) -def test_every_option_a_person_can_press_is_a_decision(option_id: str) -> None: - """Yes for this turn, yes for the session, no, and stop. Each is somebody +@pytest.mark.parametrize("option_id", ["once", "session", "no"]) +def test_a_yes_and_a_no_both_answer_the_card(option_id: str) -> None: + """Yes for this turn, yes for the session, and no. Each is somebody answering the question the card asked, and the card has no further use - after any of them.""" + after any of them β€” the decision itself is kept elsewhere.""" assert decided(_answered(option_id)) is True +def test_cancelling_the_operation_is_not_answering_the_card() -> None: + """The fourth option is not a fourth answer. `cancel` stops what was being + asked about rather than permitting or refusing it, and its card is the + only thing in the channel that says where the run was halted. Widening the + rule to cover it is a product decision, not a reading of this one.""" + assert decided(_answered("stop")) is False + + def test_an_option_the_request_never_offered_decides_nothing() -> None: """A host naming an option that is not on the card is a host saying something we cannot read. The safe reading of an unreadable answer is that From a76300fc884542496b02d4dfda84b709c2d862c8 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 12:02:35 +0100 Subject: [PATCH 067/120] Take an answered card back on Discord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publication webhook deletes what it sent, so no Manage Messages permission is involved and none is in the documented install. A DM card has no webhook behind it and is deleted as the bot's own message. Only a rate limit survives as itself. A refusal, a server error and a request that never came back all become RemovalFailed, because the caller does the same thing with all three: keep the settled card, record nothing, and ask again later. The uncertainty an uncertain send has to preserve does not arise, since asking again about a deletion that did land is answered with "already gone" β€” which is logged rather than raised, and is how a lost acknowledgement settles itself. A reference that is not two snowflakes is refused before the channel is resolved, rather than partway through by an int() that happens to raise. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 65 +++++ .../test_discord_card_removal.py | 235 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 91e5df35b..71253749e 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -21,6 +21,7 @@ ActivityMark, ActivityMarkRefused, CollaborationAdapter, + RemovalFailed, RequestCard, RichContent, RichContentFailed, @@ -304,6 +305,12 @@ class DiscordAdapter(CollaborationAdapter): # marker carried some other way would make this True. carries_publication_marker: ClassVar[bool] = False + #: A webhook may delete the messages it sent, and the publication webhook + #: sent every card, so no Manage Messages permission is involved and none + #: is in the documented install. A DM card is the bot's own message, which + #: it may always delete. + removes_answered_cards: ClassVar[bool] = True + def __init__(self, *, config: DiscordConnectionConfig) -> None: super().__init__() self._config = config @@ -1171,6 +1178,64 @@ def _rich_failure(self, error: Exception, description: str, text: str) -> Except """ return _as_rich_failure(error, description=description, text=text) or error + @staticmethod + def _removal_failure(error: Exception, description: str) -> Exception: + """What a failed deletion should be reported as. + + Only a wait survives as itself. Everything else β€” a refusal, a server + error, a request that never came back β€” becomes `RemovalFailed`, + because the caller does the same thing with all three: keep the settled + card, record nothing, and ask again later. The uncertainty an + uncertain *send* has to preserve does not arise here, since asking + again about a deletion that did land is answered with "already gone". + """ + classified = _as_rich_failure(error, description=description, text="") + if isinstance(classified, RichContentThrottled): + return classified + return RemovalFailed(f"{description}: {error}") + + async def remove_publication(self, channel_id: str, message_ref: str) -> None: + if self._client is None: + raise RemovalFailed("Discord client not connected.") + + location_id, message_id = self._parse_message_ref(message_ref) + if not location_id.isdigit() or not message_id.isdigit(): + raise RemovalFailed( + f"Not a Discord location:message reference: {message_ref}." + ) + + description = f"Discord would not delete {message_ref} in channel {channel_id}" + try: + target = await self._get_channel(int(channel_id)) + except Exception as error: + raise self._removal_failure(error, description) from error + + try: + if self._channel_type_of(target) == "lobby": + # No webhook posted it and none could delete it; in a DM the + # card is the bot's own message. + location = await self._get_channel(int(location_id)) + await location.get_partial_message(int(message_id)).delete() + return + kwargs: dict[str, Any] = {} + if location_id != channel_id: + kwargs["thread"] = discord.Object(id=int(location_id)) + # The publication webhook, not the agents' one: a webhook may + # delete only what it sent, and this is what sent the card. + webhook = await self._publication_webhook(int(channel_id)) + await webhook.delete_message(int(message_id), **kwargs) + except discord.NotFound as error: + # Nothing at the address, which is what was asked for. Worth a line + # because the innocent reading β€” someone deleted the card by hand, + # or an acknowledgement we never saw was real β€” is not the only one. + logger.warning( + "Discord card %s was already gone when it was taken back: %s", + message_ref, + error, + ) + except Exception as error: + raise self._removal_failure(error, description) from error + async def find_request_card( self, channel_id: str, diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py new file mode 100644 index 000000000..baa15e1b9 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py @@ -0,0 +1,235 @@ +"""Taking a Discord card back, and being able to say whether it worked. + +A card is posted by the publication webhook, and a webhook may delete what it +sent β€” so no Manage Messages permission is needed, which matters because the +documented install does not grant one. In a DM there is no webhook and the card +is the bot's own message, which it may always delete. + +The other half is that a caller acting on the result β€” writing down that a card +is gone β€” must not be told success where none was established. That is why this +is not `delete_message`, which logs and returns either way. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import discord +import pytest + +from switch_core.bridges.collaboration.adapter import ( + RemovalFailed, + RichContentThrottled, +) +from switch_core.bridges.collaboration.discord.adapter import ( + _PUBLICATION_WEBHOOK_NAME, + _WEBHOOK_NAME, + DiscordAdapter, + DiscordConnectionConfig, +) + +from .test_discord_sdk_only import ( + CHANNEL_ID, + DM_CHANNEL_ID, + GUILD_ID, + ROOT_MESSAGE_ID, + _adapter, + _DMChannel, + _guild_setup, + _http_error, + _Response, + _Webhook, +) + +CARD_ID = 9999 +CARD = f"{CHANNEL_ID}:{CARD_ID}" +THREADED_CARD = f"{ROOT_MESSAGE_ID}:{CARD_ID}" + + +def _not_found() -> discord.NotFound: + return discord.NotFound(_Response(), "unknown message") # type: ignore[arg-type] + + +def _recording(lookup: Any, seen: list[int]) -> Any: + def record(channel_id: int) -> Any: + seen.append(channel_id) + return lookup(channel_id) + + return record + + +def _dm_setup() -> tuple[DiscordAdapter, _DMChannel]: + channel = _DMChannel() + return _adapter({DM_CHANNEL_ID: channel}), channel + + +async def test_a_card_is_taken_back_through_the_webhook_that_posted_it() -> None: + """Not the bot, and not the agents' webhook. A webhook may only delete its + own messages, and the publication webhook is what sent the card.""" + adapter, channel, _thread, publication = _guild_setup() + agents = channel.existing_webhooks[0] + + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert publication.deletes == [{"message_id": CARD_ID}] + assert agents.deletes == [] + + +async def test_a_card_in_a_thread_names_the_thread_it_is_in() -> None: + """A webhook belongs to the parent channel, so the thread has to be passed + alongside the id. Without it Discord looks at the channel root and reports + a message that is plainly there as missing.""" + adapter, _channel, _thread, publication = _guild_setup() + + await adapter.remove_publication(str(CHANNEL_ID), THREADED_CARD) + + assert len(publication.deletes) == 1 + assert publication.deletes[0]["message_id"] == CARD_ID + assert publication.deletes[0]["thread"].id == ROOT_MESSAGE_ID + + +async def test_a_card_in_a_dm_is_deleted_as_the_bot_s_own_message() -> None: + """A DM has no webhooks at all β€” asking for one raises β€” so the card was + posted by the bot and comes back the same way.""" + adapter, channel = _dm_setup() + + await adapter.remove_publication(str(DM_CHANNEL_ID), f"{DM_CHANNEL_ID}:{CARD_ID}") + + assert channel.deleted_ids == [CARD_ID] + + +async def test_a_refusal_is_raised_rather_than_logged() -> None: + """The caller records that the card is gone. A refusal it never hears about + is a record saying a card in the channel is not there, and the publisher + then stops redrawing a card that still offers buttons.""" + adapter, _channel, _thread, publication = _guild_setup() + publication.delete_error = _http_error(403) + + with pytest.raises(RemovalFailed) as raised: + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert isinstance(raised.value.__cause__, discord.HTTPException) + + +async def test_a_card_already_gone_is_reported_as_gone_but_said_out_loud( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing remains at the address, which is what the caller asked for, so + this is not a failure. It is still worth a line: it is also what a deletion + by hand looks like.""" + adapter, _channel, _thread, publication = _guild_setup() + publication.delete_error = _not_found() + + with caplog.at_level(logging.WARNING): + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert "already gone" in caplog.text + + +async def test_a_dm_card_already_gone_is_treated_the_same_way( + caplog: pytest.LogCaptureFixture, +) -> None: + """The DM path deletes through a different call, so it needs its own + evidence that a missing message is not an error.""" + adapter, channel = _dm_setup() + channel.delete_error = _not_found() + + with caplog.at_level(logging.WARNING): + await adapter.remove_publication( + str(DM_CHANNEL_ID), f"{DM_CHANNEL_ID}:{CARD_ID}" + ) + + assert "already gone" in caplog.text + + +async def test_being_asked_to_wait_is_kept_apart_from_being_refused() -> None: + """A rate limit says nothing about the card, so it must not arrive as + `RemovalFailed`: the caller's backoff would then double its own interval + against a delay Discord had already named.""" + adapter, _channel, _thread, publication = _guild_setup() + publication.delete_error = _http_error(429, headers={"Retry-After": "31"}) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert raised.value.retry_after == 31 + + +async def test_a_rate_limit_the_library_raised_itself_also_waits() -> None: + """discord.py reports a 429 two ways, and the one it raises before sending + carries the delay as an attribute rather than a header.""" + adapter, _channel, _thread, publication = _guild_setup() + publication.delete_error = discord.RateLimited(12.0) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert raised.value.retry_after == 12.0 + + +async def test_a_server_error_is_owed_rather_than_settled() -> None: + """An uncertain send has to keep its reservation, because a second attempt + would post a second card. A deletion has no such twin: asking again about + one that did land is answered with "already gone", so the uncertain case + joins the refusals and is simply owed.""" + adapter, _channel, _thread, publication = _guild_setup() + publication.delete_error = _http_error(503) + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + +async def test_a_channel_that_cannot_be_resolved_is_not_a_card_that_is_gone() -> None: + """Deciding where to send the deletion comes first, and failing there says + nothing about the card. Reading it as success would retire a card still on + the screen.""" + adapter, _channel, _thread, _publication = _guild_setup() + adapter._client.fetch_errors[CHANNEL_ID] = _not_found() # type: ignore[union-attr] + adapter._client._channels.pop(CHANNEL_ID) # type: ignore[union-attr] + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + +async def test_an_unparseable_reference_never_reaches_discord() -> None: + """A reference that is not two snowflakes is refused before anything is + asked of Discord β€” not partway through, once the channel has been + resolved, by an `int()` that happens to raise.""" + adapter, channel, _thread, publication = _guild_setup() + looked_up: list[int] = [] + client = adapter._client + client.get_channel = _recording(client.get_channel, looked_up) # type: ignore[union-attr] + + for reference in ("nonsense", f"{CHANNEL_ID}:", ":999", "abc:def"): + with pytest.raises(RemovalFailed): + await adapter.remove_publication(str(CHANNEL_ID), reference) + + assert publication.deletes == [] + assert channel.deleted_ids == [] + assert looked_up == [] + + +async def test_a_disconnected_adapter_does_not_claim_the_card_was_removed() -> None: + """Nothing was asked of Discord, so nothing is known about the card.""" + adapter = DiscordAdapter( + config=DiscordConnectionConfig(bot_token="token", guild_id=str(GUILD_ID)) + ) + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + +def test_discord_is_a_platform_that_says_it_can_do_this() -> None: + """The capability is what routes an answered card here at all.""" + assert DiscordAdapter.removes_answered_cards is True + + +def test_the_fakes_agree_on_which_webhook_is_which() -> None: + """Guards the test above that asserts the agents' webhook was left alone: + were both names to resolve to one fake, it would pass for the wrong + reason.""" + agents: Any = _Webhook(_WEBHOOK_NAME) + publications: Any = _Webhook(_PUBLICATION_WEBHOOK_NAME) + + assert agents.id != publications.id From 339740b811fbb02bb97db0fb1ffd229f9a80b105 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 12:07:22 +0100 Subject: [PATCH 068/120] Take an answered card back on Telegram A bot deletes its own messages in a group as an ordinary member, and in a broadcast channel under the Delete Messages right the install already asks for. The limit is time: after 48 hours Telegram refuses, and says so. That refusal is raised, so the card stays settled and readable rather than being recorded as gone; a card answered inside two days, which is every card anybody is waiting on, comes back. Two BadRequests that only their text tells apart: "message to delete not found" is the absence the caller asked for and is logged, and "message can't be deleted" is a real failure. The house pattern for reading a BadRequest by its text is already used for "not modified" on the edit path. A rate limit is charged to the bot rather than to the message, so a throttled deletion records the chat-wide quiet period the redraws share, and a deletion is not sent into a wait that is already running. Unlike the edit path, a reference with no chat is refused rather than completed from the channel argument: an edit that lands wrong rewrites one of our own messages or is refused, while a deletion is neither reversible nor confined to messages we posted. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/telegram/adapter.py | 89 +++++++++ .../test_telegram_card_removal.py | 169 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 8e01c7464..4ac6183b2 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -39,6 +39,7 @@ ActivityMark, ActivityMarkRefused, CollaborationAdapter, + RemovalFailed, RequestCard, RichContent, RichContentFailed, @@ -432,6 +433,13 @@ class TelegramAdapter(CollaborationAdapter): # adapter that happens to share the inability to search. discloses_unconfirmed_posts: ClassVar[bool] = True + #: A bot deletes its own messages in a group as an ordinary member, and in + #: a broadcast channel with the Delete Messages right the install already + #: asks for. What it cannot do is delete one older than 48 hours, and + #: Telegram says so plainly enough to tell apart from being ignored β€” which + #: is the second half of what this claims. See `remove_publication`. + removes_answered_cards: ClassVar[bool] = True + def __init__(self, *, config: TelegramConnectionConfig) -> None: super().__init__() self._config = config @@ -1517,6 +1525,87 @@ def _rich_failure(self, error: Exception, description: str, text: str) -> Except self._rich_update_after = time.monotonic() + failure.retry_after return failure or error + async def remove_publication(self, channel_id: str, message_ref: str) -> None: + """Take an answered card out of the chat, or say why it is still there. + + Telegram's own limit is the interesting case: a bot may delete its own + message for 48 hours and not after, and it reports the refusal as a + `BadRequest` saying the message cannot be deleted. That is a real + failure and is raised as one β€” the card stays, settled and readable, + which is the intended fallback. A card answered inside two days, which + is every card anybody is waiting on, is deleted. + + Told the message is not there, this returns: the address came from + Telegram when it accepted the card, so nothing remains at it, which is + what the caller asked for. + + No thread or topic is named. Telegram's `deleteMessage` takes a chat + and a message id, and a forum topic is a property of the message + rather than an address to re-supply. + """ + if self._bot is None: + raise RemovalFailed("Telegram bot not connected.") + + chat_ref, message_id = self._parse_message_ref(message_ref) + if not chat_ref or not message_id.isdigit(): + # The edit path falls back to the channel argument for a missing + # chat, and this does not. An edit that lands on the wrong message + # rewrites one of ours or is refused; a deletion is neither + # reversible nor confined to our own messages, so an address + # Telegram never issued is not one to complete from context. + raise RemovalFailed( + f"Not a Telegram chat:message reference: {message_ref}." + ) + + waiting = f"Waiting for Telegram to allow {message_ref} to be deleted." + # A 429 is charged to the bot, so a deletion sent into one is a second + # refusal and a longer wait. The caller is a publisher that can come + # back; it is told to. + self._refuse_while_throttled(waiting) + + try: + await self._bot.delete_message( + chat_id=self._chat_id(chat_ref), + message_id=int(message_id), + ) + except BadRequest as error: + if "message to delete not found" in str(error).lower(): + # Worth a line: the innocent reading is a deletion whose + # acknowledgement we lost, or one done by hand, but this is + # also what Telegram says about an address it never issued. + logger.warning( + "Telegram card %s was already gone when it was taken back.", + message_ref, + ) + return + raise self._removal_failure(error, message_ref, channel_id) from error + except Exception as error: + raise self._removal_failure(error, message_ref, channel_id) from error + + def _removal_failure( + self, error: Exception, message_ref: str, channel_id: str + ) -> Exception: + """What a failed deletion should be reported as. + + Only a wait survives as itself, and it goes through `_rich_failure` so + the chat's quiet period is recorded for every other publication in it. + Everything else β€” a refusal, the 48-hour limit, a request that never + came back β€” becomes `RemovalFailed`, because the caller does the same + thing with all three: keep the settled card, record nothing, and ask + again later. The uncertainty an uncertain *send* has to preserve does + not arise here, since asking again about a deletion that did land is + answered with "not found". + """ + description = f"Telegram would not delete {message_ref} in chat {channel_id}" + classified = self._rich_failure( + error, + description, + f"Waiting for Telegram to allow {message_ref} to be deleted.", + ) + if isinstance(classified, RichContentThrottled): + return classified + return RemovalFailed(f"{description}: {error}") + def _refuse_while_throttled(self, text: str) -> None: """Wait out a 429 Telegram has already sent for this bot. diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py new file mode 100644 index 000000000..b93e618a8 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py @@ -0,0 +1,169 @@ +"""Taking a Telegram card back, and being able to say whether it worked. + +A bot deletes its own messages in a group as an ordinary member, which is all +the install asks for, and in a broadcast channel under the Delete Messages +right it does ask for. The limit worth knowing is time: after 48 hours Telegram +refuses, and says so. A card answered inside two days β€” which is every card +anybody is waiting on β€” comes back. + +The other half is that a caller acting on the result β€” writing down that a card +is gone β€” must not be told success where none was established. That is why this +is not `delete_message`, which logs and returns either way. +""" + +from __future__ import annotations + +import logging +import time + +import pytest +from telegram.error import BadRequest, Forbidden, RetryAfter, TimedOut + +from switch_core.bridges.collaboration.adapter import ( + RemovalFailed, + RichContentThrottled, +) +from switch_core.bridges.collaboration.telegram.adapter import ( + TelegramAdapter, + TelegramConnectionConfig, +) + +from .test_telegram_adapter import BOT_USERNAME, CHAT_ID, _adapter, _bot + +CARD_ID = 777 +CARD = f"{CHAT_ID}:{CARD_ID}" +CHANNEL = str(CHAT_ID) + +# What Telegram answers with once a message is more than two days old. Written +# out because the classification turns on the text, so a change to it is a +# change this file should fail on rather than absorb. +TOO_OLD = "Bad Request: message can't be deleted for everyone" +NOT_THERE = "Bad Request: message to delete not found" + + +async def test_a_card_is_taken_back_from_the_chat_that_holds_it() -> None: + """The reference carries the chat Telegram echoed back, which is the one + that outlives a supergroup migration. It wins over the channel argument.""" + adapter = _adapter() + + await adapter.remove_publication("@handle-that-moved", CARD) + + assert _bot(adapter).deletes == [{"chat_id": CHAT_ID, "message_id": CARD_ID}] + + +async def test_a_card_too_old_to_delete_is_a_failure_not_a_removal() -> None: + """Telegram's 48-hour limit. The card stays where it is, settled and + readable, and the caller must not record it as gone β€” a row saying so is a + card nothing will ever look at again.""" + adapter = _adapter() + _bot(adapter).delete_error = BadRequest(TOO_OLD) + + with pytest.raises(RemovalFailed) as raised: + await adapter.remove_publication(CHANNEL, CARD) + + assert isinstance(raised.value.__cause__, BadRequest) + + +async def test_a_bot_thrown_out_of_the_chat_is_a_failure_too() -> None: + """A definite refusal that is not about the message. Nothing was deleted, + so nothing may be recorded.""" + adapter = _adapter() + _bot(adapter).delete_error = Forbidden("bot was kicked") + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARD) + + +async def test_a_card_already_gone_is_reported_as_gone_but_said_out_loud( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing remains at the address, which is what the caller asked for, so + this is not a failure β€” and it is how a deletion whose acknowledgement was + lost settles itself on the next attempt.""" + adapter = _adapter() + _bot(adapter).delete_error = BadRequest(NOT_THERE) + + with caplog.at_level(logging.WARNING): + await adapter.remove_publication(CHANNEL, CARD) + + assert "already gone" in caplog.text + + +async def test_being_asked_to_wait_is_kept_apart_from_being_refused() -> None: + """A rate limit says nothing about the card, so it must not arrive as + `RemovalFailed`: the caller's backoff would then double its own interval + against a delay Telegram had already named.""" + adapter = _adapter() + _bot(adapter).delete_error = RetryAfter(31) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(CHANNEL, CARD) + + assert raised.value.retry_after == 31 + + +async def test_a_rate_limit_is_charged_to_the_bot_and_remembered() -> None: + """Telegram limits the bot, not the message, so a throttled deletion is + the whole bridge being asked for quiet. Recording it is what stops the + next publication in any chat discovering the same thing for itself.""" + adapter = _adapter() + _bot(adapter).delete_error = RetryAfter(31) + + with pytest.raises(RichContentThrottled): + await adapter.remove_publication(CHANNEL, CARD) + + assert adapter._rich_update_after - time.monotonic() > 25 + + +async def test_a_wait_already_running_is_not_walked_into_again() -> None: + """A deletion sent inside a 429 is a second refusal and a longer wait. It + is not sent, and the caller is told how long is left rather than that the + card could not be removed.""" + adapter = _adapter() + adapter._rich_update_after = time.monotonic() + 20 + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(CHANNEL, CARD) + + assert raised.value.retry_after > 0 + assert _bot(adapter).deletes == [] + + +async def test_a_request_that_never_came_back_is_owed_rather_than_settled() -> None: + """An uncertain send has to keep its reservation, because a second attempt + would post a second card. A deletion has no such twin: asking again about + one that did land is answered with "not found", so the uncertain case + joins the refusals and is simply owed.""" + adapter = _adapter() + _bot(adapter).delete_error = TimedOut() + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARD) + + +async def test_an_unparseable_reference_never_reaches_telegram() -> None: + """A bare message id would be deleted from whatever chat was passed + alongside it, and a non-numeric one is an `int()` away from raising + halfway through. Refusing first is the only safe reading.""" + adapter = _adapter() + + for reference in ("nonsense", f"{CHAT_ID}:", ":777", f"{CHAT_ID}:abc"): + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, reference) + + assert _bot(adapter).deletes == [] + + +async def test_a_disconnected_adapter_does_not_claim_the_card_was_removed() -> None: + """Nothing was asked of Telegram, so nothing is known about the card.""" + adapter = TelegramAdapter( + config=TelegramConnectionConfig(bot_token="token", bot_username=BOT_USERNAME) + ) + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARD) + + +def test_telegram_is_a_platform_that_says_it_can_do_this() -> None: + """The capability is what routes an answered card here at all.""" + assert TelegramAdapter.removes_answered_cards is True From da27dd5c84cea550947f957266f4e54d6e982e2c Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 12:14:43 +0100 Subject: [PATCH 069/120] Take an answered card back on Mattermost Deleted as the admin: the card was posted by an agent's bot and the reference does not say which, and the admin is the account that may delete a post it did not write. A 404 means nothing remains at the address, which is what the caller asked for, so it is logged and returned rather than raised; a 429 keeps the wait Mattermost named; everything else is owed and asked about again. Clients with the channel open see Mattermost's own "(message deleted)" placeholder until the next load. Co-Authored-By: Claude Opus 5 --- .../collaboration/mattermost/adapter.py | 65 ++++++++ .../test_mattermost_card_removal.py | 151 ++++++++++++++++++ .../collaboration/test_mattermost_sdk_only.py | 13 ++ 3 files changed, 229 insertions(+) create mode 100644 core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 42bf661db..7fc525385 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -31,6 +31,7 @@ from switch_core.bridges.collaboration.adapter import ( ActivityMark, CollaborationAdapter, + RemovalFailed, RequestCard, RichContent, RichContentFailed, @@ -225,6 +226,12 @@ class MattermostAdapter(CollaborationAdapter): #: handle of its own. carries_publication_marker: ClassVar[bool] = True + #: The bridge connects as a system admin, which may delete any post in the + #: team, so an answered card comes back whichever bot posted it. Mattermost + #: leaves a "(message deleted)" placeholder for clients with the channel + #: already open; it goes on the next load. + removes_answered_cards: ClassVar[bool] = True + def __init__(self, *, config: MattermostConnectionConfig) -> None: super().__init__() self._config = config @@ -974,6 +981,64 @@ async def delete_message(self, channel_id: str, message_ref: str) -> None: except Exception as e: logger.error("Failed to delete Mattermost post %s: %s", message_ref, e) + async def remove_publication(self, channel_id: str, message_ref: str) -> None: + """Take an answered card out of the channel, or say why it is still there. + + Deleted as the admin, which is the account that may delete a post it + did not write β€” the card was posted by the agent's bot, and nothing in + the reference says which agent that was. `update_rich` can prefer the + narrower bot because it is handed the agent's name; this is not. + + Told the post does not exist, this returns: the id came from Mattermost + when it accepted the card, so nothing remains at it, which is what the + caller asked for. + + Not `delete_message`, which logs and returns either way. A caller + writing down that a card is gone must not be told success where none + was established. + """ + driver = self._admin_driver + loop = self._main_loop + if driver is None or loop is None: + raise RemovalFailed("Mattermost is not connected.") + if not message_ref.strip(): + # A blank id is a DELETE against the collection rather than a post. + raise RemovalFailed("No Mattermost post id to delete.") + + try: + await loop.run_in_executor(None, driver.posts.delete_post, message_ref) + except ResourceNotFound as error: + # Worth a line because the innocent reading β€” a deletion whose + # acknowledgement we lost, or one done by hand β€” is not the only one. + logger.warning( + "Mattermost card %s was already gone when it was taken back: %s", + message_ref, + error, + ) + except Exception as error: + raise self._removal_failure(error, message_ref, channel_id) from error + + @staticmethod + def _removal_failure( + error: Exception, message_ref: str, channel_id: str + ) -> Exception: + """What a failed deletion should be reported as. + + Only a wait survives as itself. Everything else β€” a refusal, a server + error, a request that never came back β€” becomes `RemovalFailed`, + because the caller does the same thing with all three: keep the settled + card, record nothing, and ask again later. The uncertainty an uncertain + *send* has to preserve does not arise here, since asking again about a + deletion that did land is answered with "not found". + """ + retry_after = _throttle_delay(error) + if retry_after is not None: + return RichContentThrottled(retry_after=retry_after, text="") + return RemovalFailed( + f"Mattermost would not delete post {message_ref} in channel " + f"{channel_id}: {error}" + ) + # ── Typing ─────────────────────────────────────────────────────────────── async def send_typing( diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py new file mode 100644 index 000000000..55a3c5436 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py @@ -0,0 +1,151 @@ +"""Taking a Mattermost card back, and being able to say whether it worked. + +The bridge connects as a system admin, so it may delete a post written by an +agent's bot β€” which every card is, and the reference does not say whose. What a +reader with the channel already open sees in its place is Mattermost's own +"(message deleted)" placeholder; it goes on the next load. + +The other half is that a caller acting on the result β€” writing down that a card +is gone β€” must not be told success where none was established. That is why this +is not `delete_message`, which logs and returns either way. +""" + +from __future__ import annotations + +import logging + +import pytest +from mattermostdriver.exceptions import NotEnoughPermissions, ResourceNotFound + +from switch_core.bridges.collaboration.adapter import ( + RemovalFailed, + RichContentThrottled, +) +from switch_core.bridges.collaboration.mattermost.adapter import ( + MattermostAdapter, + MattermostConnectionConfig, +) + +from .test_mattermost_sdk_only import _adapter, _http_error, _posts + +CARD = "post-card" +CHANNEL = "chan-1" + + +async def test_a_card_is_taken_back_as_the_account_that_may_delete_it() -> None: + """The card was posted by an agent's bot and the reference does not say + which. The admin is the account that may delete a post it did not write.""" + adapter = _adapter("worker") + + await adapter.remove_publication(CHANNEL, CARD) + + assert _posts(adapter).deleted == [CARD] + assert _posts(adapter).deleted_by == ["admin"] + + +async def test_a_refusal_is_raised_rather_than_logged() -> None: + """The caller records that the card is gone. A refusal it never hears about + is a record saying a card in the channel is not there, and the publisher + then stops redrawing a card that still offers buttons.""" + adapter = _adapter() + _posts(adapter).delete_error = NotEnoughPermissions("403") + + with pytest.raises(RemovalFailed) as raised: + await adapter.remove_publication(CHANNEL, CARD) + + assert isinstance(raised.value.__cause__, NotEnoughPermissions) + + +async def test_a_card_already_gone_is_reported_as_gone_but_said_out_loud( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing remains at the address, which is what the caller asked for, so + this is not a failure β€” and it is how a deletion whose acknowledgement was + lost settles itself on the next attempt.""" + adapter = _adapter() + _posts(adapter).delete_error = ResourceNotFound("404") + + with caplog.at_level(logging.WARNING): + await adapter.remove_publication(CHANNEL, CARD) + + assert "already gone" in caplog.text + + +async def test_being_asked_to_wait_is_kept_apart_from_being_refused() -> None: + """A rate limit says nothing about the card, so it must not arrive as + `RemovalFailed`: the caller's backoff would then double its own interval + against a delay Mattermost had already named.""" + adapter = _adapter() + _posts(adapter).delete_error = _http_error(429, **{"Retry-After": "17"}) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(CHANNEL, CARD) + + assert raised.value.retry_after == 17 + + +async def test_a_rate_limit_that_names_no_interval_still_names_a_wait() -> None: + """Mattermost is not obliged to send `Retry-After`, and a wait of zero is + an immediate second attempt into the same limit.""" + adapter = _adapter() + _posts(adapter).delete_error = _http_error(429) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(CHANNEL, CARD) + + assert raised.value.retry_after > 0 + + +async def test_a_server_error_is_owed_rather_than_settled() -> None: + """An uncertain send has to keep its reservation, because a second attempt + would post a second card. A deletion has no such twin: asking again about + one that did land is answered with "not found", so the uncertain case joins + the refusals and is simply owed.""" + adapter = _adapter() + _posts(adapter).delete_error = _http_error(503) + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARD) + + +async def test_a_request_that_never_came_back_is_owed_too() -> None: + """The same reading as a server error: nothing is known, so nothing may be + recorded, and the card is asked about again.""" + adapter = _adapter() + _posts(adapter).delete_error = TimeoutError("response lost") + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARD) + + +async def test_a_blank_reference_never_reaches_mattermost() -> None: + """An empty id is a DELETE against the posts collection rather than + against a post, and a 404 from one of those would be read as a card that + had already gone.""" + adapter = _adapter() + + for reference in ("", " "): + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, reference) + + assert _posts(adapter).deleted == [] + + +async def test_a_disconnected_adapter_does_not_claim_the_card_was_removed() -> None: + """Nothing was asked of Mattermost, so nothing is known about the card.""" + adapter = MattermostAdapter( + config=MattermostConnectionConfig( + url="http://mm", + admin_user="admin", + admin_password="pw", + team_name="team", + ) + ) + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARD) + + +def test_mattermost_is_a_platform_that_says_it_can_do_this() -> None: + """The capability is what routes an answered card here at all.""" + assert MattermostAdapter.removes_answered_cards is True diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index 880074edb..d76b89478 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -55,12 +55,15 @@ def __init__(self) -> None: # drivers, because which bot Mattermost saw is the thing under test. self.created_by: list[str] = [] self.patched_by: list[str] = [] + self.deleted_by: list[str] = [] self.thread: dict[str, dict[str, Any]] = {} self.channel: dict[str, dict[str, Any]] = {} self.create_error: Exception | None = None self.created_id: str | None = None self.patch_error: Exception | None = None self.read_error: Exception | None = None + self.deleted: list[str] = [] + self.delete_error: Exception | None = None self.thread_calls: list[str] = [] self.channel_calls: list[tuple[str, dict[str, Any] | None]] = [] self._next = iter(f"post-{n}" for n in range(1, 50)) @@ -79,6 +82,12 @@ def patch_post(self, post_id: str, body: dict[str, Any]) -> dict[str, str]: self.patched.append((post_id, body)) return {"id": post_id} + def delete_post(self, post_id: str) -> dict[str, str]: + if self.delete_error: + raise self.delete_error + self.deleted.append(post_id) + return {"status": "OK"} + def get_thread(self, root_id: str) -> dict[str, Any]: self.thread_calls.append(root_id) if self.read_error: @@ -161,6 +170,10 @@ def patch_post(self, post_id: str, body: dict[str, Any]) -> dict[str, str]: self._posts.patched_by.append(self._owner) return self._posts.patch_post(post_id, body) + def delete_post(self, post_id: str) -> dict[str, str]: + self._posts.deleted_by.append(self._owner) + return self._posts.delete_post(post_id) + class _FakeDriver: def __init__(self, posts: _FakePosts, users: _FakeUsers, owner: str) -> None: From f1a099dc53dc6e670ccc6f46bffa8337e23312fe Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 12:18:57 +0100 Subject: [PATCH 070/120] Take an answered card back on Teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressed through the publication reference β€” service URL, conversation and activity, all three as Teams confirmed them β€” rather than through the map a restart empties. That is also what makes a 404 readable: gone at an address Teams issued means gone, while gone at an address rebuilt from the channel is as likely to be the address, and is owed rather than recorded. A 412 is a wait for the same reason it is on a redraw. Gives `_Publication.trusted` its first reader. A posts-layout channel keeps "This message has been deleted." where the card was; a chat and a threads-layout channel take it away entirely. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/teams/adapter.py | 66 +++++++ .../collaboration/test_teams_card_removal.py | 178 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 6c47f6bb8..f63dff1ed 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -24,6 +24,7 @@ from switch_core.bridges.agent.commands import COMMANDS_BY_NAME from switch_core.bridges.collaboration.adapter import ( CollaborationAdapter, + RemovalFailed, RequestCard, RichContent, RichContentFailed, @@ -57,6 +58,7 @@ from switch_core.bridges.collaboration.teams.connector import ( BotConnectorClient, BotConnectorConflict, + BotConnectorGone, BotConnectorRefused, BotConnectorThrottled, ) @@ -537,6 +539,12 @@ class TeamsAdapter(CollaborationAdapter): recovers_uncertain_posts: ClassVar[bool] = False carries_publication_marker: ClassVar[bool] = False + #: A bot deletes its own activities through the Bot Connector, which is how + #: every card was posted. What a posts-layout channel leaves behind is + #: *"This message has been deleted."*; a chat and a threads-layout channel + #: take the card away entirely. See `remove_publication`. + removes_answered_cards: ClassVar[bool] = True + @classmethod async def prepare_config( cls, connection_config: dict[str, object] @@ -1666,6 +1674,64 @@ async def _edit_rich( text=text, ) from error + async def remove_publication(self, channel_id: str, message_ref: str) -> None: + """Take an answered card out of the conversation, or say why it is still there. + + Addressed through `_publication_address`, which reads back the service + URL and conversation Teams confirmed when it took the card, rather than + through `_locate`, whose map a restart empties. + + A 404 is read against that address rather than on its own. Gone at an + address Teams issued means nothing remains there, which is what the + caller asked for. Gone at an address this process rebuilt from the + channel and a stored root may be the guess being wrong instead, and an + outcome that cannot be told apart from a bad address is not one to + record: that is a failure, and the card is asked about again. + + A 412 is a wait for the same reason it is on a redraw β€” the activity is + there and something else wrote to it first β€” so it comes back as a + backoff rather than as a card that could not be removed. + + In a posts-layout channel Teams substitutes *"This message has been + deleted."* where the card was. A chat and a threads-layout channel take + it away entirely. + """ + connector = self._connector + if connector is None: + raise RemovalFailed("Teams is not connected.") + + address = self._publication_address(channel_id, message_ref, None) + try: + async with self._writes_to(address.conversation_id): + await connector.delete_activity( + service_url=address.service_url, + conversation_id=address.conversation_id, + activity_id=address.activity_id, + ) + except BotConnectorThrottled as error: + raise self._throttled(error, "") from error + except BotConnectorConflict as error: + raise self._conflicted(error, "") from error + except BotConnectorGone as error: + if not address.trusted: + raise RemovalFailed( + f"Teams found nothing at the address rebuilt for " + f"{message_ref} in channel {channel_id}, which is as likely " + f"to be the address as the card: {error}" + ) from error + # Worth a line because the innocent reading β€” a deletion whose + # acknowledgement we lost, or one done by hand β€” is not the only one. + logger.warning( + "Teams card %s was already gone when it was taken back: %s", + message_ref, + error, + ) + except Exception as error: + raise RemovalFailed( + f"Teams would not delete {address.activity_id} in conversation " + f"{address.conversation_id}: {error}" + ) from error + # ── Channels ───────────────────────────────────────────────────────────── @staticmethod diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py new file mode 100644 index 000000000..eec34ed00 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py @@ -0,0 +1,178 @@ +"""Taking a Teams card back, and being able to say whether it worked. + +A bot deletes its own activities through the Bot Connector, which is how every +card was posted. The address is the interesting part: it is the one Teams +confirmed and the caller wrote down, not one this process rebuilds β€” and where +it does have to rebuild one, a 404 is as likely to be the address as the card. + +The other half is that a caller acting on the result β€” writing down that a card +is gone β€” must not be told success where none was established. That is why this +is not `delete_message`, which addresses the message through a map a restart +empties. +""" + +from __future__ import annotations + +import logging + +import pytest + +from switch_core.bridges.collaboration.adapter import ( + RemovalFailed, + RichContentThrottled, +) +from switch_core.bridges.collaboration.teams.adapter import ( + TeamsAdapter, + _publication_ref, +) +from switch_core.bridges.collaboration.teams.connector import ( + BotConnectorConflict, + BotConnectorGone, + BotConnectorRefused, + BotConnectorThrottled, + BotConnectorUnavailable, +) + +from .test_teams_adapter import _adapter +from .test_teams_sdk_only import CHANNEL, ROOT, SERVICE_URL, _restart, _teams + +CONVERSATION = f"{CHANNEL};messageid={ROOT}" +CARD = "card-1" +# What `post_rich` handed back and the caller stored: the service that holds +# the conversation, the conversation, and the activity inside it. +CARRIED = _publication_ref(SERVICE_URL, CONVERSATION, CARD) + + +async def test_a_card_is_taken_back_at_the_address_teams_confirmed() -> None: + """All three parts come out of the reference rather than being worked out + again, which is what makes this survive a restart, a regional service URL + and a conversation Teams named itself.""" + adapter, connector = _teams() + _restart(adapter) + + await adapter.remove_publication(CHANNEL, CARRIED) + + assert connector.deletes == [ + { + "conversation_id": CONVERSATION, + "activity_id": CARD, + "service_url": SERVICE_URL, + } + ] + + +async def test_a_refusal_is_raised_rather_than_logged() -> None: + """The caller records that the card is gone. A refusal it never hears about + is a record saying a card in the post is not there, and the publisher then + stops redrawing a card that still offers buttons.""" + adapter, connector = _teams() + connector.fail_delete = BotConnectorRefused("no", status=403, retry_after=None) + + with pytest.raises(RemovalFailed) as raised: + await adapter.remove_publication(CHANNEL, CARRIED) + + assert isinstance(raised.value.__cause__, BotConnectorRefused) + + +async def test_a_card_already_gone_from_a_known_address_is_gone( + caplog: pytest.LogCaptureFixture, +) -> None: + """Teams issued the address, so nothing remains at it β€” which is what the + caller asked for, and how a deletion whose acknowledgement was lost settles + itself on the next attempt.""" + adapter, connector = _teams() + connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) + + with caplog.at_level(logging.WARNING): + await adapter.remove_publication(CHANNEL, CARRIED) + + assert "already gone" in caplog.text + + +async def test_nothing_at_a_rebuilt_address_is_not_a_card_that_is_gone() -> None: + """A bare message id has no address of its own, so one is guessed from the + channel. A 404 against a guess says the guess may be wrong, and an outcome + that cannot be told apart from a bad address must not retire the card.""" + adapter, connector = _teams() + connector.fail_delete = BotConnectorGone("gone", status=404, retry_after=None) + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, "MSG1") + + +async def test_being_asked_to_wait_is_kept_apart_from_being_refused() -> None: + """A rate limit says nothing about the card, so it must not arrive as + `RemovalFailed`: the caller's backoff would then double its own interval + against a delay Teams had already named.""" + adapter, connector = _teams() + connector.fail_delete = BotConnectorThrottled( + "slow down", status=429, retry_after=12.0 + ) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(CHANNEL, CARRIED) + + assert raised.value.retry_after == 12.0 + + +async def test_a_wait_teams_did_not_put_a_number_on_is_still_a_wait() -> None: + adapter, connector = _teams() + connector.fail_delete = BotConnectorThrottled( + "slow down", status=429, retry_after=None + ) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(CHANNEL, CARRIED) + + assert raised.value.retry_after > 0 + + +async def test_something_else_writing_first_is_a_wait_not_a_failure() -> None: + """A 412 says the activity is there and one revision ahead. Coming round + again removes it; calling it a failure buys a notice about a card that is + about to go anyway.""" + adapter, connector = _teams() + connector.fail_delete = BotConnectorConflict("busy", status=412, retry_after=None) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(CHANNEL, CARRIED) + + assert raised.value.retry_after > 0 + + +async def test_a_request_that_never_came_back_is_owed_rather_than_settled() -> None: + """An uncertain send has to keep its reservation, because a second attempt + would post a second card. A deletion has no such twin: asking again about + one that did land is answered with a 404 at an address Teams issued, so the + uncertain case joins the refusals and is simply owed.""" + adapter, connector = _teams() + connector.fail_delete = BotConnectorUnavailable( + "timeout", status=None, retry_after=None + ) + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARRIED) + + +async def test_a_disconnected_adapter_does_not_claim_the_card_was_removed() -> None: + """Nothing was asked of Teams, so nothing is known about the card.""" + adapter = _adapter() + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARRIED) + + +async def test_a_card_in_a_chat_is_taken_back_from_the_chat_itself() -> None: + """A chat is its own conversation, and it is the layout that deletes + cleanly rather than leaving a tombstone.""" + adapter, connector = _teams(chat=True) + chat_card = _publication_ref(SERVICE_URL, "a:1chat", CARD) + + await adapter.remove_publication("a:1chat", chat_card) + + assert connector.deletes[0]["conversation_id"] == "a:1chat" + + +def test_teams_is_a_platform_that_says_it_can_do_this() -> None: + """The capability is what routes an answered card here at all.""" + assert TeamsAdapter.removes_answered_cards is True From eb96bf496ec372ad20ba9f2df0193ca7d8dbfed0 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 12:21:39 +0100 Subject: [PATCH 071/120] Say which publications stay and which now come down Four docstrings and two test names still claimed nothing a bridge publishes is ever taken down. That is now true only of a status: an answered request card is removed, on every platform, through remove_publication and never through a redraw. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 14 +++++++------ .../bridges/collaboration/teams/adapter.py | 20 +++++++++++-------- .../bridges/collaboration/telegram/adapter.py | 5 +++-- .../collaboration/test_slack_card_removal.py | 5 ++--- .../collaboration/test_telegram_sdk_only.py | 5 +++-- .../sessions/test_answered_card_removal.py | 5 +++-- 6 files changed, 31 insertions(+), 23 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 71253749e..c613ed334 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -1099,12 +1099,14 @@ async def update_rich( ) -> None: """Redraw a publication in place, including the last time. - Nothing is taken down. A turn that has ended is edited to its final - state and stays where it was published β€” in a thread, at the channel - root or in a DM alike β€” as the record that the turn ran, how long it - took and where to open it. Deleting it at the channel root left a - reader scrolling back with none of that, and a request card was never - taken down anywhere for the same reason. + A status is never taken down. A turn that has ended is edited to its + final state and stays where it was published β€” in a thread, at the + channel root or in a DM alike β€” as the record that the turn ran, how + long it took and where to open it. Deleting it at the channel root left + a reader scrolling back with none of that. An answered request card is + the one thing that does come down, through `remove_publication`: it + offers buttons nobody may press again, and what it decided is in the + session rather than in the card. Not `update_message`, which logs and returns. That is right for a status line nobody is waiting on and wrong here: a card that failed to diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index f63dff1ed..8740115f1 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -1619,14 +1619,18 @@ async def update_rich( ) -> None: """Redraw a publication in place, including the last time. - Nothing is taken down. A turn that has ended is edited to its final - state and stays in the conversation as the record that it ran, how long - it took and where to open it. A chat-layout channel used to delete it, - on the reasoning that a bot's own message goes there without trace and - a finished status is clutter; what went with it was the only account of - the turn anybody scrolling back could read. A posts channel already - kept it, because Teams leaves *"This message has been deleted."* behind - and that is worse than the line it replaces. + A status is never taken down. A turn that has ended is edited to its + final state and stays in the conversation as the record that it ran, + how long it took and where to open it. A chat-layout channel used to + delete it, on the reasoning that a bot's own message goes there without + trace and a finished status is clutter; what went with it was the only + account of the turn anybody scrolling back could read. A posts channel + already kept it, because Teams leaves *"This message has been + deleted."* behind and that is worse than the line it replaces. + + An answered request card does come down, through `remove_publication`, + tombstone and all: a card offering buttons nobody may press again is + worse than a line saying a message was removed. Not `update_message`, for two reasons. That one replaces the whole activity with plain text, which would strip the agent's card off a diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 4ac6183b2..ecc825c64 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -1415,13 +1415,14 @@ async def update_rich( ) -> None: """Redraw a publication in place, including the last time. - Nothing is taken down. A finished status is edited to its final state + A status is never taken down. It is edited to its final state and stays in the chat as the record that the turn ran, how long it took, and where to open it β€” which is what a reader scrolling back wants and what a deletion left them without. It is compact for the same reason it used to be deleted: a Telegram chat or topic is the conversation itself, so the status is a line and its link rather than a - running commentary on tool calls. + running commentary on tool calls. An answered request card does come + down, through `remove_publication` and never through a redraw. `agent_name` is what the redraw writes back into the body. The name is the message here β€” one bot posts for every agent β€” so an edit that did diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py index 0915c9e07..9798ac3b8 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py @@ -133,9 +133,8 @@ def test_a_disconnected_adapter_does_not_claim_the_card_was_removed() -> None: _run(_adapter().remove_publication("C123", CARD)) -def test_slack_is_the_platform_that_says_it_can_do_this() -> None: - """The capability is what routes an answered card here at all, and the four - platforms still to come are the ones that have not claimed it.""" +def test_slack_is_a_platform_that_says_it_can_do_this() -> None: + """The capability is what routes an answered card here at all.""" assert SlackAdapter.removes_answered_cards is True diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 8489c7d86..8b7800935 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -785,9 +785,10 @@ async def test_a_finished_turn_that_still_has_a_problem_to_report_says_so() -> N assert "went away" in _edited(adapter)["text"] -async def test_nothing_this_bridge_publishes_is_ever_taken_down() -> None: +async def test_a_redraw_never_takes_a_publication_down() -> None: """A status and a card are both the record of something that happened, and - each says on its face what became of it.""" + each says on its face what became of it. An answered card is taken back, + but through `remove_publication` and never as part of a redraw.""" adapter = _adapter() status = await adapter.post_rich(CHANNEL, "my-agent", _running(), None) card = await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) diff --git a/core/tests/switch_core/sessions/test_answered_card_removal.py b/core/tests/switch_core/sessions/test_answered_card_removal.py index 6fc3cca86..d9112a679 100644 --- a/core/tests/switch_core/sessions/test_answered_card_removal.py +++ b/core/tests/switch_core/sessions/test_answered_card_removal.py @@ -224,8 +224,9 @@ async def test_a_card_nobody_has_answered_is_left_alone(session_factory): async def test_a_platform_that_cannot_prove_a_removal_is_not_asked_to_try( session_factory, ): - """The four platforms still to come. Their cards settle exactly as they - did before, which is the behaviour a checkpoint at a time has to preserve. + """Every bridge platform claims the capability now, so what this holds is + the seam itself: a platform that cannot prove a card was taken away has its + cards settled by an edit, exactly as they were before any of this. """ platform = Platform() service, epoch, posts, cards, post = await _card(session_factory, platform) From 2445b5cceca5d922b565447585d629a2198f1742 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 12:39:54 +0100 Subject: [PATCH 072/120] Only accept evidence about the card itself when retiring one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord answers "Unknown Webhook" with the same 404 as "Unknown Message", and the publication webhook is looked up by name and created when none is found β€” so a webhook deleted in the channel's settings is replaced by one that sent none of the cards already posted, and answers 404 about every one of them while they sit on the screen. Reading either as absence stamps removed_at on a card that is still there and nothing looks at the row again. Resolve the channel and the webhook outside the deletion's error handling, restrict the absence reading to code 10008, and confirm it through the channel route, which answers about the message. Teams named no wait on a 412 or on a 429 without Retry-After, and the synthetic interval substituted for them came back shorter than the interval the cleanup had grown to. The caller takes a named wait as authoritative and drops its own, so a channel refusing persistently was retried hardest. Both are now owed like any other failure and the growing backoff applies. Drop the claim that every awaited card is answered inside Telegram's 48 hours; nothing here bounds how long a card stays open. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 118 +++++++++++++++--- .../bridges/collaboration/teams/adapter.py | 27 +++- .../bridges/collaboration/telegram/adapter.py | 4 +- .../test_discord_card_removal.py | 72 ++++++++++- .../collaboration/test_discord_sdk_only.py | 3 + .../collaboration/test_teams_card_removal.py | 23 ++-- .../test_telegram_card_removal.py | 3 +- 7 files changed, 207 insertions(+), 43 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index c613ed334..d272bc3e5 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -74,6 +74,12 @@ # Discord's error code for "Maximum number of guild roles reached" (250). _MAX_GUILD_ROLES_CODE = 30005 +# "Unknown Message". The only 404 that is about the message rather than about +# what was asked to act on it β€” a webhook route answers 10015 "Unknown Webhook" +# with the same status, and reading that as a card that is gone retires a card +# still on the screen. +_UNKNOWN_MESSAGE_CODE = 10008 + # Applied to the bot posts that inline an agent's name into the body β€” the DM # path, which has no webhook identity to carry it. Escaping the text is not # enough on its own: Discord decides who a message pings from the raw content @@ -1197,6 +1203,13 @@ def _removal_failure(error: Exception, description: str) -> Exception: return RemovalFailed(f"{description}: {error}") async def remove_publication(self, channel_id: str, message_ref: str) -> None: + """Take an answered card out of the channel, or say why it is still there. + + Only the message being absent is success, and only the routes that + answer about the message may establish it. Finding the channel, and + finding the webhook that posted the card, are both steps before the + deletion; a 404 from either is about the step, not about the card. + """ if self._client is None: raise RemovalFailed("Discord client not connected.") @@ -1209,35 +1222,106 @@ async def remove_publication(self, channel_id: str, message_ref: str) -> None: description = f"Discord would not delete {message_ref} in channel {channel_id}" try: target = await self._get_channel(int(channel_id)) + lobby = self._channel_type_of(target) == "lobby" except Exception as error: raise self._removal_failure(error, description) from error + if lobby: + await self._remove_own_message( + int(location_id), int(message_id), message_ref, description + ) + return + try: - if self._channel_type_of(target) == "lobby": - # No webhook posted it and none could delete it; in a DM the - # card is the bot's own message. - location = await self._get_channel(int(location_id)) - await location.get_partial_message(int(message_id)).delete() - return - kwargs: dict[str, Any] = {} - if location_id != channel_id: - kwargs["thread"] = discord.Object(id=int(location_id)) # The publication webhook, not the agents' one: a webhook may - # delete only what it sent, and this is what sent the card. + # delete only what it sent, and this is what sent the card. A + # webhook that cannot be resolved is not a card that is gone, so + # this is outside the deletion's own error handling. webhook = await self._publication_webhook(int(channel_id)) + except Exception as error: + raise self._removal_failure(error, description) from error + + kwargs: dict[str, Any] = {} + if location_id != channel_id: + kwargs["thread"] = discord.Object(id=int(location_id)) + try: await webhook.delete_message(int(message_id), **kwargs) except discord.NotFound as error: - # Nothing at the address, which is what was asked for. Worth a line - # because the innocent reading β€” someone deleted the card by hand, - # or an acknowledgement we never saw was real β€” is not the only one. - logger.warning( - "Discord card %s was already gone when it was taken back: %s", - message_ref, - error, + if error.code != _UNKNOWN_MESSAGE_CODE: + # "Unknown Webhook", most often: this webhook is not there any + # more, which is a fact about the webhook and none about the + # card. + raise self._removal_failure(error, description) from error + await self._confirm_card_gone( + int(location_id), int(message_id), message_ref, error ) except Exception as error: raise self._removal_failure(error, description) from error + async def _remove_own_message( + self, location_id: int, message_id: int, message_ref: str, description: str + ) -> None: + """Delete a DM card, which the bot posted as itself. + + No webhook is involved, so the deletion goes through the channel β€” the + route that answers about the message β€” and a 404 from it is the card's + absence and nothing else. + """ + try: + location = await self._get_channel(location_id) + except Exception as error: + raise self._removal_failure(error, description) from error + try: + await location.get_partial_message(message_id).delete() + except discord.NotFound as error: + self._say_already_gone(message_ref, error) + except Exception as error: + raise self._removal_failure(error, description) from error + + async def _confirm_card_gone( + self, location_id: int, message_id: int, message_ref: str, error: Exception + ) -> None: + """Ask the channel whether the card is really gone. + + A webhook answers "Unknown Message" for a message that is not there + *and* for one it did not send, and it cannot tell them apart. That + second reading is not hypothetical: the publication webhook is looked + up by name and created when no match is found, so a webhook deleted in + the channel's settings is replaced by one that never sent any of the + cards already posted. Taking its 404 at face value would retire every + one of them while they stayed on the screen. + + The channel route answers about the message, so it is the one that can + settle it. + """ + try: + location = await self._get_channel(location_id) + await location.fetch_message(message_id) + except discord.NotFound: + self._say_already_gone(message_ref, error) + return + except Exception as failure: + raise RemovalFailed( + f"Discord said the webhook does not know message {message_ref}, " + f"and reading the channel to find out whether the card is still " + f"there did not work either: {failure}" + ) from failure + raise RemovalFailed( + f"Discord card {message_ref} is still in the channel: the " + f"publication webhook did not send it and so cannot delete it." + ) + + @staticmethod + def _say_already_gone(message_ref: str, error: Exception) -> None: + # Nothing at the address, which is what was asked for. Worth a line + # because the innocent reading β€” someone deleted the card by hand, or + # an acknowledgement we never saw was real β€” is not the only one. + logger.warning( + "Discord card %s was already gone when it was taken back: %s", + message_ref, + error, + ) + async def find_request_card( self, channel_id: str, diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 8740115f1..9ab52a1ec 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -1692,9 +1692,14 @@ async def remove_publication(self, channel_id: str, message_ref: str) -> None: outcome that cannot be told apart from a bad address is not one to record: that is a failure, and the card is asked about again. - A 412 is a wait for the same reason it is on a redraw β€” the activity is - there and something else wrote to it first β€” so it comes back as a - backoff rather than as a card that could not be removed. + Only a wait Teams put a number on arrives as one. A 412, and a 429 with + no `Retry-After`, are owed instead. The caller treats a named wait as + authoritative and replaces its own growing interval with it, so an + invented second β€” or the five seconds the edit path substitutes β€” comes + back as a *shorter* delay than the cleanup had already worked up to, + and resets the growth every sweep. A redraw invents one anyway because + it holds a reservation and content that goes stale; a deletion has + neither, and is content to be asked about later rather than sooner. In a posts-layout channel Teams substitutes *"This message has been deleted."* where the card was. A chat and a threads-layout channel take @@ -1713,9 +1718,19 @@ async def remove_publication(self, channel_id: str, message_ref: str) -> None: activity_id=address.activity_id, ) except BotConnectorThrottled as error: - raise self._throttled(error, "") from error - except BotConnectorConflict as error: - raise self._conflicted(error, "") from error + if error.retry_after is None: + # A rate limit Teams put no number on. Inventing one would + # overwrite the cleanup's own interval with something shorter + # and stop it growing, so this is owed like any other failure + # and the caller keeps widening the gap between attempts. + raise RemovalFailed( + f"Teams rate-limited the deletion of {address.activity_id} " + f"in conversation {address.conversation_id} and named no " + f"wait: {error}" + ) from error + raise RichContentThrottled( + retry_after=error.retry_after, text="" + ) from error except BotConnectorGone as error: if not address.trusted: raise RemovalFailed( diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index ecc825c64..202f96f42 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -1533,8 +1533,8 @@ async def remove_publication(self, channel_id: str, message_ref: str) -> None: message for 48 hours and not after, and it reports the refusal as a `BadRequest` saying the message cannot be deleted. That is a real failure and is raised as one β€” the card stays, settled and readable, - which is the intended fallback. A card answered inside two days, which - is every card anybody is waiting on, is deleted. + which is the intended fallback. Nothing here shortens the wait, so a + card left open long enough is one Telegram will not take back. Told the message is not there, this returns: the address came from Telegram when it accepted the card, so nothing remains at it, which is diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py index baa15e1b9..54b78e0b3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py @@ -38,6 +38,7 @@ _DMChannel, _guild_setup, _http_error, + _Message, _Response, _Webhook, ) @@ -47,8 +48,18 @@ THREADED_CARD = f"{ROOT_MESSAGE_ID}:{CARD_ID}" -def _not_found() -> discord.NotFound: - return discord.NotFound(_Response(), "unknown message") # type: ignore[arg-type] +def _unknown_message() -> discord.NotFound: + """The 404 that is about the message β€” the only one that can mean absence.""" + return discord.NotFound( # type: ignore[arg-type] + _Response(), {"code": 10008, "message": "Unknown Message"} + ) + + +def _unknown_webhook() -> discord.NotFound: + """The 404 that is about the webhook, and carries the same HTTP status.""" + return discord.NotFound( # type: ignore[arg-type] + _Response(), {"code": 10015, "message": "Unknown Webhook"} + ) def _recording(lookup: Any, seen: list[int]) -> Any: @@ -119,7 +130,7 @@ async def test_a_card_already_gone_is_reported_as_gone_but_said_out_loud( this is not a failure. It is still worth a line: it is also what a deletion by hand looks like.""" adapter, _channel, _thread, publication = _guild_setup() - publication.delete_error = _not_found() + publication.delete_error = _unknown_message() with caplog.at_level(logging.WARNING): await adapter.remove_publication(str(CHANNEL_ID), CARD) @@ -133,7 +144,7 @@ async def test_a_dm_card_already_gone_is_treated_the_same_way( """The DM path deletes through a different call, so it needs its own evidence that a missing message is not an error.""" adapter, channel = _dm_setup() - channel.delete_error = _not_found() + channel.delete_error = _unknown_message() with caplog.at_level(logging.WARNING): await adapter.remove_publication( @@ -143,6 +154,57 @@ async def test_a_dm_card_already_gone_is_treated_the_same_way( assert "already gone" in caplog.text +async def test_a_webhook_that_is_not_there_is_not_a_card_that_is_gone() -> None: + """Discord answers "Unknown Webhook" with the same 404 as "Unknown + Message". Reading the first as the second retires a card that is still on + the screen, and nothing later looks at the row again.""" + adapter, _channel, _thread, publication = _guild_setup() + publication.delete_error = _unknown_webhook() + + with pytest.raises(RemovalFailed) as raised: + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert isinstance(raised.value.__cause__, discord.NotFound) + + +async def test_failing_to_find_the_webhook_is_owed_and_deletes_nothing() -> None: + """Resolving the webhook is a step before the deletion. Whatever it + answers is about the webhook, and the card was never asked about.""" + adapter, channel, _thread, publication = _guild_setup() + channel.webhook_error = _unknown_webhook() + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert publication.deletes == [] + + +async def test_a_card_the_webhook_did_not_send_is_still_in_the_channel() -> None: + """The publication webhook is looked up by name and created when none is + found, so one deleted in the channel's settings is replaced by a webhook + that sent none of the cards already posted. It answers "Unknown Message" + for every one of them while they sit there, so the channel is asked.""" + adapter, channel, _thread, publication = _guild_setup() + publication.delete_error = _unknown_message() + channel.messages[CARD_ID] = _Message(channel, CARD_ID) + + with pytest.raises(RemovalFailed) as raised: + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert "still in the channel" in str(raised.value) + + +async def test_a_card_that_cannot_be_read_back_is_owed_rather_than_gone() -> None: + """The confirming read is the whole of the evidence. Without it there is + nothing to record, so a read that fails leaves the removal owed.""" + adapter, channel, _thread, publication = _guild_setup() + publication.delete_error = _unknown_message() + channel.fetch_error = _http_error(503) + + with pytest.raises(RemovalFailed): + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + async def test_being_asked_to_wait_is_kept_apart_from_being_refused() -> None: """A rate limit says nothing about the card, so it must not arrive as `RemovalFailed`: the caller's backoff would then double its own interval @@ -185,7 +247,7 @@ async def test_a_channel_that_cannot_be_resolved_is_not_a_card_that_is_gone() -> nothing about the card. Reading it as success would retire a card still on the screen.""" adapter, _channel, _thread, _publication = _guild_setup() - adapter._client.fetch_errors[CHANNEL_ID] = _not_found() # type: ignore[union-attr] + adapter._client.fetch_errors[CHANNEL_ID] = _unknown_message() # type: ignore[union-attr] adapter._client._channels.pop(CHANNEL_ID) # type: ignore[union-attr] with pytest.raises(RemovalFailed): diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index 895c1cc63..e873698a3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -154,6 +154,7 @@ def __init__(self, channel_id: int = CHANNEL_ID, *, guild: Any | None = None): self.history_error: Exception | None = None self.send_error: Exception | None = None self.delete_error: Exception | None = None + self.fetch_error: Exception | None = None self.existing_webhooks: list[Any] = [] self.webhook_error: Exception | None = None self.thread_error: Exception | None = None @@ -189,6 +190,8 @@ async def typing(self) -> None: self.typing_count += 1 async def fetch_message(self, message_id: int) -> _Message: + if self.fetch_error is not None: + raise self.fetch_error message = self.messages.get(message_id) if message is None: raise discord.NotFound(_Response(), "message not found") # type: ignore[arg-type] diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py index eec34ed00..2e2201da4 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py @@ -115,30 +115,31 @@ async def test_being_asked_to_wait_is_kept_apart_from_being_refused() -> None: assert raised.value.retry_after == 12.0 -async def test_a_wait_teams_did_not_put_a_number_on_is_still_a_wait() -> None: +async def test_a_wait_teams_put_no_number_on_is_owed_rather_than_invented() -> None: + """The caller takes a named wait as authoritative and drops its own + interval for it, so a number this side made up is not a smaller lie than a + wrong one: five seconds substituted every sweep holds the cleanup at its + floor while Teams is refusing everything.""" adapter, connector = _teams() connector.fail_delete = BotConnectorThrottled( "slow down", status=429, retry_after=None ) - with pytest.raises(RichContentThrottled) as raised: + with pytest.raises(RemovalFailed): await adapter.remove_publication(CHANNEL, CARRIED) - assert raised.value.retry_after > 0 - -async def test_something_else_writing_first_is_a_wait_not_a_failure() -> None: - """A 412 says the activity is there and one revision ahead. Coming round - again removes it; calling it a failure buys a notice about a card that is - about to go anyway.""" +async def test_something_else_writing_first_is_owed_rather_than_waited_out() -> None: + """A 412 is contention over a dependency, and Teams names no interval with + it. A second of invented backoff would come back shorter than the interval + the cleanup had already grown to, and reset the growth each time round, so + a channel conflicting persistently would be retried hardest.""" adapter, connector = _teams() connector.fail_delete = BotConnectorConflict("busy", status=412, retry_after=None) - with pytest.raises(RichContentThrottled) as raised: + with pytest.raises(RemovalFailed): await adapter.remove_publication(CHANNEL, CARRIED) - assert raised.value.retry_after > 0 - async def test_a_request_that_never_came_back_is_owed_rather_than_settled() -> None: """An uncertain send has to keep its reservation, because a second attempt diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py index b93e618a8..30bf89a54 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py @@ -3,8 +3,7 @@ A bot deletes its own messages in a group as an ordinary member, which is all the install asks for, and in a broadcast channel under the Delete Messages right it does ask for. The limit worth knowing is time: after 48 hours Telegram -refuses, and says so. A card answered inside two days β€” which is every card -anybody is waiting on β€” comes back. +refuses, and says so, and a card open that long stays where it is. The other half is that a caller acting on the result β€” writing down that a card is gone β€” must not be told success where none was established. That is why this From cab06a6865fff0be2cae5bdbd529495e43819603 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 13:02:38 +0100 Subject: [PATCH 073/120] Answer a Teams permission card by pressing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's options become Action.Execute buttons in an ActionSet, which is the one card action that reaches the bot and can answer with something only the presser sees. A press posts nothing into the conversation; a refusal comes back as the invoke's own response and stays with the person who made it. An Action.Submit would have posted the refusal where everybody reading the post could see whose answer failed. Only a card that carries buttons asks for schema 1.5. A client too old for the universal action model renders the whole card as its fallback text, so the version is raised where there is something to gain by it and nowhere else, and the buttons themselves fall back to `drop`. The body still lists every option in full, unlike Telegram's, because those buttons are exactly what an old client drops β€” and a card whose options only ever lived on them would drop the question with them. Typing still answers it, by the same numbers the buttons carry. What travels in a button is the card's opaque token and the option's position, and nothing else: no option id, no actor, no label. Who pressed comes from the activity, and which card comes from the conversation, region and replyToId the activity names, so the reference is the one the request was stored under rather than one rebuilt from what this process last learned. A press on a card that outlived a restart resolves the same way. Controls are absent wherever a press cannot land: a status, a settled card, one that says it cannot be answered here, one whose decision text was cut, and a bridge with no interaction handler to route a press to. A press that lands is answered with nothing at all β€” the card's own redraw is what says the answer was taken, and saying so here would be saying it before the redraw that proves it. Owed live check: the private-response behaviour is read from Microsoft's documented invoke contract, not observed. A transport prototype on a real tenant still has to establish that a press submits invisibly and that a refusal is visible to the presser alone. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/teams/adapter.py | 309 +++++++++-- .../bridges/collaboration/teams/cards.py | 109 +++- .../collaboration/test_teams_adapter.py | 10 +- .../collaboration/test_teams_card_buttons.py | 495 ++++++++++++++++++ .../test_teams_outbound_rendering.py | 12 +- 5 files changed, 888 insertions(+), 47 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_teams_card_buttons.py diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 9ab52a1ec..bcd0d14cb 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -11,6 +11,7 @@ from collections import OrderedDict from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager +from contextvars import ContextVar from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta from typing import Any, ClassVar @@ -39,12 +40,18 @@ InboundAgentJoin, InboundAppJoin, InboundCommand, + InboundInteraction, InboundMessage, InboundUserJoin, ) -from switch_core.bridges.collaboration.session.renderers import Markup +from switch_core.bridges.collaboration.session.renderers import ( + Drawn, + Markup, + offered_controls, + position_action, +) from switch_core.bridges.collaboration.session.renderers.neutral import ( - request_summary, + render_request, turn_status, ) from switch_core.bridges.collaboration.teams.auth import ( @@ -53,7 +60,9 @@ ) from switch_core.bridges.collaboration.teams.cards import ( agent_message_card, + answer_actions, card_attachment, + read_answer_action, ) from switch_core.bridges.collaboration.teams.connector import ( BotConnectorClient, @@ -123,6 +132,41 @@ shape has to be told from this one rather than guessed at. """ +_INVOKE_CARD_ACTION = "adaptiveCard/action" +"""The invoke a press on an `Action.Execute` arrives as.""" + +_MESSAGE_RESPONSE = "application/vnd.microsoft.activity.message" +"""The invoke response that shows a line of text to whoever pressed, alone.""" + +_ERROR_RESPONSE = "application/vnd.microsoft.error" +"""The invoke response for a press this bridge could not finish reading.""" + +_PRESS_NOTICE: ContextVar[list[str] | None] = ContextVar( + "switch_teams_press_notice", default=None +) +"""Where a refusal waits while a press is being handled. + +`tell_actor` has no press to answer and the press has nothing to say until the +handler returns, so the two meet here. A list rather than one string, and a +context rather than a field: two people pressing at once are two tasks with a +context each, and the same person pressing twice is two presses rather than one +notice overwriting another. +""" + + +def _invoke_error(status: int, code: str, message: str) -> dict[str, Any]: + """An invoke answered with a failure the presser alone is shown. + + The alternative is an empty success, which takes the button out of its + loading state and says the answer landed. Nothing that reaches here got as + far as an answer, so the press is closed by saying so. + """ + return { + "statusCode": status, + "type": _ERROR_RESPONSE, + "value": {"code": code, "message": message}, + } + def _publication_ref(service_url: str, conversation_id: str, activity_id: str) -> str: """The durable address of a publication, as one string. @@ -1110,7 +1154,9 @@ async def _post_to_answer_in( return None return self._last_post.get(channel_id) - async def _message_activity(self, sender_name: str, body: str) -> dict[str, Any]: + async def _message_activity( + self, sender_name: str, body: str, actions: list[dict[str, Any]] + ) -> dict[str, Any]: agent = await self.agent_rendering(sender_name) mentions = self._mention_entities(body) return { @@ -1119,7 +1165,9 @@ async def _message_activity(self, sender_name: str, body: str) -> dict[str, Any] # "cards.unsupported" placeholder in toasts, mobile, and link previews. # Plain text, rendered by no markup engine, so the label goes in raw. "summary": f"{agent.field_label}: {body}", - "attachments": [card_attachment(agent_message_card(agent, body, mentions))], + "attachments": [ + card_attachment(agent_message_card(agent, body, mentions, actions)) + ], } # ── Messaging ──────────────────────────────────────────────────────────── @@ -1137,7 +1185,7 @@ async def send_message( # `content` arrives rendered: every caller of `send_message` runs # `translate_outbound` first, and rendering again here put the body # through the conversion twice. - activity = await self._message_activity(sender_name, content) + activity = await self._message_activity(sender_name, content, []) thread_root_id = await self._post_to_answer_in(channel_id, thread_root_id) return await self._relay(self._connector, channel_id, thread_root_id, activity) @@ -1302,7 +1350,7 @@ def rich_fallback_text(self, content: RichContent) -> str: mention=None, responder=None, notice=self.unnotified_notice() if content.notify_unreachable else None, - ) + ).text def _draw( self, @@ -1311,13 +1359,20 @@ def _draw( mention: str | None, responder: str | None, notice: str | None, - ) -> str: + ) -> Drawn: """The body of a publication, as a card will render it. Line breaks are the card's problem rather than this text's: the body is written with one newline to a line and `cards.body_blocks` turns those into blocks. So the budget here is measured on what a reader actually reads, with no display syntax counted against it. + + The body prints every option in full even where a button carries one. + A control here is an `Action.Execute`, which a client too old for it + drops β€” and a card whose options only ever existed on the buttons would + drop the question with them. What that costs is a line of repetition on + a current client; what it buys is a card that can still be answered by + typing on any of them. """ escape = self._rich_escape limit = self.rich_fallback_limit() @@ -1327,7 +1382,7 @@ def _draw( # just fits, plus a line saying it reached nobody, is a body over # the budget. tail = f"\n{notice}" if notice else "" - drawn = ( + text = ( turn_status( content.items, content.turn, @@ -1342,23 +1397,23 @@ def _draw( ) + tail ) - else: - # The mention goes on a line of its own rather than in front of the - # heading: the card is a block, and a name wedged before "Permission - # needed" reads as part of it. - lead = f"{mention}\n" if mention else "" - tail = f"\n{notice}" if notice else "" - body = request_summary( - content.request, - content.reference, - escape=escape, - limit=max(1, limit - len(lead) - len(tail)), - markup=markup, - responder=responder, - unavailable_reason=content.unavailable_reason, - ) - drawn = f"{lead}{body}{tail}" - return drawn + return Drawn(text=text, answerable=False) + # The mention goes on a line of its own rather than in front of the + # heading: the card is a block, and a name wedged before "Permission + # needed" reads as part of it. + lead = f"{mention}\n" if mention else "" + tail = f"\n{notice}" if notice else "" + drawn = render_request( + content.request, + content.reference, + escape=escape, + limit=max(1, limit - len(lead) - len(tail)), + markup=markup, + responder=responder, + unavailable_reason=content.unavailable_reason, + control_label_limit=None, + ) + return replace(drawn, text=f"{lead}{drawn.text}{tail}") def _mention(self, external_id: str | None) -> str | None: """`` markup naming whoever holds this AAD id, or None. @@ -1398,7 +1453,37 @@ def _unmentionable_notice(self) -> str: f"this {self.platform_name} team is what failed." ) - def _render_rich(self, content: RichContent) -> str: + def _controls(self, content: RichContent, drawn: Drawn) -> list[dict[str, Any]]: + """The card's options as buttons, or nothing where a press cannot land. + + Nothing at all is the ordinary answer: a status has no options, a + settled card has none left, and a card that cannot be answered where it + is showing says so β€” a live control under that sentence invites the + refusal the sentence just explained. Because every redraw rebuilds the + card, the buttons come off at the moment a card stops being pressable + without anything having to remember that it once had them. + + Whether the drawing earned them comes from `drawn` rather than from + reading the request again. A body cut short of the difference between + two options is one the reader cannot decide from, and only the renderer + that cut it knows. A press would still resolve and settle the request, + so the whole of the protection is not offering the button. + + A card with no interaction handler gets no buttons either. Every press + on this platform arrives as an invoke that has to be answered, and one + answered with nothing to route it to is a control that spins and then + reports a failure of its own. + """ + if not isinstance(content, RequestCard) or not drawn.answerable: + return [] + if self._on_interaction is None: + return [] + controls = offered_controls(content.request) + if not controls: + return [] + return answer_actions(content.reference.token, controls) + + def _render_rich(self, content: RichContent) -> Drawn: """Draw a publication, saying so when the mention could not be made. Two different failures reach the same reader. The publisher sets @@ -1568,14 +1653,17 @@ async def post_rich( that makes a redraw after a restart address the conversation the post actually went to. """ - text = self._render_rich(content) + drawn = self._render_rich(content) + text = drawn.text if self._connector is None: raise RichContentFailed( "Teams is not connected, so the publication was not sent.", text=text ) carried = _read_publication_ref(thread_root_id) if thread_root_id else None service_url = carried[0] if carried else self._service_url_for(channel_id) - activity = await self._message_activity(agent_name, text) + activity = await self._message_activity( + agent_name, text, self._controls(content, drawn) + ) opening = self._is_channel(channel_id) and thread_root_id is None # A new post has no conversation to queue behind yet, so its writes are # ordered against the channel instead. @@ -1642,7 +1730,8 @@ async def update_rich( posts for every agent here, so the name is part of what was drawn, and an edit that did not know it would republish the turn as somebody else. """ - text = self._render_rich(replace(content, notify_external_id=None)) + drawn = self._render_rich(replace(content, notify_external_id=None)) + text = drawn.text connector = self._connector if connector is None: raise RichContentFailed( @@ -1650,7 +1739,9 @@ async def update_rich( text=text, ) address = self._publication_address(channel_id, message_ref, thread_root_id) - await self._edit_rich(connector, agent_name, address, text) + await self._edit_rich( + connector, agent_name, address, text, self._controls(content, drawn) + ) async def _edit_rich( self, @@ -1658,6 +1749,7 @@ async def _edit_rich( agent_name: str, address: _Publication, text: str, + actions: list[dict[str, Any]], ) -> None: try: async with self._writes_to(address.conversation_id): @@ -1665,7 +1757,7 @@ async def _edit_rich( service_url=address.service_url, conversation_id=address.conversation_id, activity_id=address.activity_id, - activity=await self._message_activity(agent_name, text), + activity=await self._message_activity(agent_name, text, actions), ) except BotConnectorThrottled as error: raise self._throttled(error, text) from error @@ -2131,13 +2223,18 @@ async def _handle_http_messages(self, request: web.Request) -> web.Response: return web.Response(status=400, text="invalid json") try: - await self._dispatch_activity(activity) + answer = await self._dispatch_activity(activity) except Exception: logger.exception("Failed to handle inbound Teams activity") + return web.Response(status=200) - return web.Response(status=200) + if answer is None: + return web.Response(status=200) + return web.json_response(answer) - async def _dispatch_activity(self, activity: dict[str, Any]) -> None: + async def _dispatch_activity( + self, activity: dict[str, Any] + ) -> dict[str, Any] | None: service_url = str(activity.get("serviceUrl", "")).strip() activity_type = activity.get("type") @@ -2160,6 +2257,9 @@ async def _dispatch_activity(self, activity: dict[str, Any]) -> None: await self._dispatch_message(activity, channel_id, channel_type) elif activity_type == "conversationUpdate": await self._dispatch_conversation_update(activity, channel_id, channel_type) + elif activity_type == "invoke" and activity.get("name") == _INVOKE_CARD_ACTION: + return await self._dispatch_card_action(activity, channel_id) + return None @staticmethod def _channel_from_activity( @@ -2239,6 +2339,147 @@ async def _dispatch_message( is_targeted=bool((activity.get("recipient") or {}).get("isTargeted")), ) + async def _dispatch_card_action( + self, activity: dict[str, Any], channel_id: str + ) -> dict[str, Any] | None: + """Someone pressed a button on a card this bridge posted. + + Who pressed comes from `from`, which the Bot Connector fills in and the + button's data cannot: what travels in the button is which card and which + option, never who. So a press replayed from another client is still + attributed to whoever actually sent it, and the identity check + downstream is against a real account rather than a claim. + + Which card comes from the activity too, and for the same reason. Teams + names the message the press was on in `replyToId`, and the conversation + and the region around it, which is the whole of a publication's address + β€” so the card is found the way an edit finds it rather than by trusting + a reference the presser's client could have carried anything in. The + token in the button still has to resolve to that same address + downstream, so a token lifted from one card cannot be pressed against + another. + + Every path out of here answers the press. Until it is answered the + button spins on the presser's client and then reports a failure of its + own, which is a worse account of what happened than any of these. A + press that landed is answered with nothing at all: the card's own redraw + is what says the answer was taken, and saying so here would be saying it + before the redraw that proves it. A refusal reaches the presser through + `tell_actor`, which leaves it in `_PRESS_NOTICE` for the answer below β€” + seen by them alone, which is the one thing this platform can do that + Telegram's alert and Slack's ephemeral also do. + + Nothing here dedupes. Teams retries an invoke it got no answer for, and + the same press twice is the same option, by the same person, against the + same revision β€” which the shared layer derives one command id from, so + the second is the first rather than a second answer. + """ + press = read_answer_action(activity.get("value") or {}) + if press is None: + logger.warning( + "Ignoring a card action in channel %s: it is not a Switch answer.", + channel_id, + ) + return _invoke_error(400, "BadRequest", "This is not a Switch card action.") + if self._on_interaction is None: + logger.warning( + "A press on a Switch card in channel %s has nowhere to go: this " + "bridge handles no interactions, so the card should not have " + "been drawn with buttons.", + channel_id, + ) + return _invoke_error( + 500, + "InternalServerError", + "This card is not connected to anything that can take an answer.", + ) + + token, position = press + card = self._pressed_card(activity) + if card is None: + logger.warning( + "Ignoring a press in channel %s: Teams named no message for it, " + "so there is no card to match it against.", + channel_id, + ) + return _invoke_error( + 400, "BadRequest", "This press does not say which card it is on." + ) + + sender = activity.get("from") or {} + sender_id = str(sender.get("aadObjectId") or sender.get("id") or "") + sender_name = await self._sender_handle( + sender_id, str(sender.get("name") or "") + ) + + notices: list[str] = [] + held = _PRESS_NOTICE.set(notices) + try: + await self._on_interaction( + InboundInteraction( + channel_id=channel_id, + sender_id=sender_id, + sender_name=sender_name, + action_id=position_action(position), + value=token, + message_ref=card, + ) + ) + except Exception: + logger.exception("Failed to handle a press on a Teams card") + return _invoke_error( + 500, "InternalServerError", "Something went wrong taking that answer." + ) + finally: + _PRESS_NOTICE.reset(held) + if notices: + return {"statusCode": 200, "type": _MESSAGE_RESPONSE, "value": notices[0]} + return None + + def _pressed_card(self, activity: dict[str, Any]) -> str | None: + """The publication reference of the card a press was on. + + Built from the invoke the same way `post_rich` built the one it handed + back: the region, the conversation, and the activity the press names. + Same three parts, so the string is the one the request was stored + under and the cross-check downstream is an equality rather than a + reconstruction that has to be forgiven its differences. + """ + service_url = str(activity.get("serviceUrl", "")).strip() + conversation_id = str((activity.get("conversation") or {}).get("id", "")) + activity_id = str(activity.get("replyToId") or "") + if not (service_url and conversation_id and activity_id): + return None + return _publication_ref(service_url, conversation_id, activity_id) + + async def tell_actor( + self, + channel_id: str, + actor_ref: str, + actor_name: str, + thread_ref: str | None, + text: str, + ) -> None: + """Tell one person their answer did not land, where they can see it. + + A press is told in the answer to the press itself, which Teams shows to + whoever pressed and to nobody else β€” it costs the conversation nothing + and reaches them whether or not the bot has ever been able to message + them. It is left here for the press to carry rather than sent from here, + because the invoke it answers is not something this method can see. + + A typed answer has no press to answer, so it falls back to the base: + said in the card's own post, where everyone reading it sees a notice + addressed to someone else. That is the platform's limit rather than a + choice β€” a Teams bot cannot say something to one member of a channel + unprompted. + """ + notices = _PRESS_NOTICE.get() + if notices is not None: + notices.append(text) + return + await super().tell_actor(channel_id, actor_ref, actor_name, thread_ref, text) + def _command_in(self, probe: str, *, is_targeted: bool) -> str | None: """The command this message runs, or None if it is ordinary text. diff --git a/core/switch_core/bridges/collaboration/teams/cards.py b/core/switch_core/bridges/collaboration/teams/cards.py index 2dd7a37f5..d2fdaa9bb 100644 --- a/core/switch_core/bridges/collaboration/teams/cards.py +++ b/core/switch_core/bridges/collaboration/teams/cards.py @@ -4,9 +4,33 @@ from typing import Any from switch_core.bridges.collaboration.adapter import AgentRendering +from switch_core.bridges.collaboration.session.renderers import Control ADAPTIVE_CARD_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive" +# What a press on a Switch control calls itself. Teams hands the verb back +# untouched, so it is the first thing a press is checked against: an action +# from some other app's card is not one this bridge has any business reading. +ANSWER_VERB = "switch/answerRequest" + +# Where the press carries what it is answering. Nested under one key because +# Teams merges a card's input values into the same object, and a flat name is a +# collision waiting for the first card here to grow an input. +_ANSWER_DATA = "switchAnswer" + +# The schema version that introduced Action.Execute, which is the only card +# action that reaches a bot with a reply the presser alone sees. A card with +# nothing to press stays at 1.4: a client too old for the newer schema falls +# back to `fallbackText` for the whole card, which is a cost worth paying only +# where there is something to gain. +_ACTION_VERSION = "1.5" +_BASE_VERSION = "1.4" + +# How much of an option's label a button shows. Not a documented Teams limit β€” +# it is where a row of buttons stops being readable. Safe to impose because the +# body above lists every option in full, so the button only has to say which. +_MAX_ACTION_TITLE = 60 + # A line markdown would set as a list item: a bullet or a number, indented by # less than the four spaces that would make it a code block instead. _LIST_ITEM = re.compile(r"^ {0,3}(?:[-*+]|\d+[.)]) ") @@ -83,10 +107,86 @@ def body_blocks(body: str) -> list[dict[str, Any]]: ] +def answer_actions(token: str, controls: list[Control]) -> list[dict[str, Any]]: + """A card's options as `Action.Execute` buttons, in the order they are drawn. + + What travels in the press is the card's opaque token and the number beside + the option in the body β€” never the option's own id, its label, or anything + about who may press it. The number is what a typed answer names too, so the + two ways of answering mean the same thing by the same word, and both are + resolved against the record rather than trusted. + + Wrapped in an `ActionSet` because Teams clients that predate the universal + action model only honour an action's fallback inside one. `drop` is that + fallback: the body lists every option and says how to type an answer, so a + client with no button still has a card it can act on. + """ + return [ + { + "type": "ActionSet", + "spacing": "Medium", + "actions": [ + { + "type": "Action.Execute", + "title": _action_title(control), + "verb": ANSWER_VERB, + "data": { + _ANSWER_DATA: { + "token": token, + "position": control.position, + } + }, + "fallback": "drop", + } + for control in controls + ], + } + ] + + +def _action_title(control: Control) -> str: + """What the button says: the option's number, and as much of it as fits. + + Numbered because the body numbers it, and a reader looking at "2." in the + text and "Decline" on a button should not have to work out that they are + the same choice. + """ + label = control.label.strip() or f"Option {control.position}" + room = _MAX_ACTION_TITLE - len(f"{control.position}. ") + if len(label) > room: + label = label[: room - 1].rstrip() + "…" + return f"{control.position}. {label}" + + +def read_answer_action(value: dict[str, Any]) -> tuple[str, int] | None: + """The card and the option a press names, or None if it is not ours. + + Read as strictly as it is written. Teams hands back whatever was put in the + button, plus whatever the client chose to add, so neither half is trusted + past its shape: the token is resolved against the stored card and the + position against the form that card was posted with. + """ + action = value.get("action") + if not isinstance(action, dict) or action.get("verb") != ANSWER_VERB: + return None + data = action.get("data") + carried = data.get(_ANSWER_DATA) if isinstance(data, dict) else None + if not isinstance(carried, dict): + return None + token = carried.get("token") + position = carried.get("position") + if not isinstance(token, str) or not token: + return None + if isinstance(position, bool) or not isinstance(position, int) or position < 1: + return None + return token, position + + def agent_message_card( agent: AgentRendering, body: str, mentions: list[dict[str, Any]], + actions: list[dict[str, Any]], ) -> dict[str, Any]: """An Adaptive Card that labels a message with the sending agent's identity. @@ -109,11 +209,15 @@ def agent_message_card( ``mentions`` are Bot Framework mention entities matching ```` markup in ``body``. A card carries them under ``msteams`` rather than on the activity, and without them the markup renders as inert text and the person is never - notified.""" + notified. + + ``actions`` are card elements appended under the body β€” an empty list for + everything but an open request card. They set the schema version, because + the action model they use is the reason to ask for the newer one.""" card: dict[str, Any] = { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", - "version": "1.4", + "version": _ACTION_VERSION if actions else _BASE_VERSION, # Plain-text representation for surfaces that can't render the card # inline (mobile, notification toasts, copy-link/search previews); its # absence is what makes Teams show the "cards.unsupported" placeholder. @@ -152,6 +256,7 @@ def agent_message_card( ], }, *body_blocks(body), + *actions, ], } if mentions: diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py b/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py index feafc7594..b0fd05378 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_adapter.py @@ -838,7 +838,7 @@ def _rendering(label: str, icon_url: str) -> AgentRendering: def test_agent_card_carries_name_and_body() -> None: card = agent_message_card( - _rendering("worker", "https://example.com/i.png"), "the message body", [] + _rendering("worker", "https://example.com/i.png"), "the message body", [], [] ) # Name appears in the header column; body appears as its own TextBlock. header = card["body"][0]["columns"][1]["items"][0] @@ -848,7 +848,7 @@ def test_agent_card_carries_name_and_body() -> None: def test_agent_card_renders_the_supplied_icon() -> None: card = agent_message_card( - _rendering("worker", "https://example.com/custom.png"), "body", [] + _rendering("worker", "https://example.com/custom.png"), "body", [], [] ) image = card["body"][0]["columns"][0]["items"][0] assert image["url"] == "https://example.com/custom.png" @@ -859,7 +859,7 @@ async def test_message_activity_carries_notification_summary_and_fallback() -> N # Teams renders a "cards.unsupported" placeholder in notifications, mobile, and # link/search previews. adapter = _adapter() - activity = await adapter._message_activity("worker", "hello world") + activity = await adapter._message_activity("worker", "hello world", []) assert activity["summary"] == "worker: hello world" assert ( activity["attachments"][0]["content"]["fallbackText"] == "worker: hello world" @@ -874,7 +874,7 @@ async def _resolver(agent_name: str) -> AgentPresentation | None: return AgentPresentation(display_name=None, icon_url=icon) adapter.set_agent_presentation_resolver(_resolver) - activity = await adapter._message_activity("worker", "hello") + activity = await adapter._message_activity("worker", "hello", []) image = activity["attachments"][0]["content"]["body"][0]["columns"][0]["items"][0] assert image["url"] == "https://example.com/worker.png" @@ -887,7 +887,7 @@ async def _resolver(agent_name: str) -> AgentPresentation | None: return AgentPresentation(display_name=None, icon_url=None) adapter.set_agent_presentation_resolver(_resolver) - activity = await adapter._message_activity("worker", "hello") + activity = await adapter._message_activity("worker", "hello", []) image = activity["attachments"][0]["content"]["body"][0]["columns"][0]["items"][0] assert image["url"] == default_icon_url("worker") diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_card_buttons.py b/core/tests/switch_core/bridges/collaboration/test_teams_card_buttons.py new file mode 100644 index 000000000..ded7c34fa --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_teams_card_buttons.py @@ -0,0 +1,495 @@ +"""Answering a Teams permission card by pressing it. + +The buttons are `Action.Execute`, which is the only card action that reaches +the bot with a reply the presser alone is shown β€” an `Action.Submit` posts a +message into the conversation, so a refusal would be read by everybody who was +not answering. That is the whole reason the card asks for schema 1.5. + +What travels in a button is the card's opaque token and the number beside the +option, and nothing else: not who may press it, not the option's own id. Who +pressed comes from the activity, and both halves are resolved against the +stored record rather than trusted. + +The body still lists every option in full, unlike Telegram's. A client too old +for the universal action model drops the buttons, and a card whose options only +lived on them would drop the question too. +""" + +from __future__ import annotations + +import logging +from dataclasses import replace +from typing import Any + +import pytest + +from switch_core.bridges.collaboration.models import InboundInteraction +from switch_core.bridges.collaboration.session.form import ( + posted_form, + resolve_pressed_position, +) +from switch_core.bridges.collaboration.session.renderers import parse_answer_position +from switch_core.bridges.collaboration.teams.adapter import ( + TeamsAdapter, + _publication_ref, +) +from switch_core.bridges.collaboration.teams.cards import ANSWER_VERB + +from .test_teams_adapter import _card_text, _FakeHttpRequest +from .test_teams_sdk_only import ( + AGENT, + CHANNEL, + ROOT, + SERVICE_URL, + _activity, + _card, + _Connector, + _restart, + _teams, +) + +CONVERSATION = f"{CHANNEL};messageid={ROOT}" +CARD_ID = "MSG1" +PRESSER = "aad-presser" + + +def _handled() -> tuple[TeamsAdapter, _Connector, list[InboundInteraction]]: + """An adapter that takes presses, and the list of the ones it took.""" + adapter, connector = _teams() + seen: list[InboundInteraction] = [] + + async def record(interaction: InboundInteraction) -> None: + seen.append(interaction) + + adapter.set_interaction_handler(record) + return adapter, connector, seen + + +def _buttons(activity: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """Every button on a card, as its title and the data it carries.""" + card = activity["attachments"][0]["content"] + return [ + (str(action["title"]), dict(action["data"])) + for block in card["body"] + if block.get("type") == "ActionSet" + for action in block["actions"] + ] + + +def _posted(connector: _Connector) -> dict[str, Any]: + return dict(connector.sends[0]["activity"]) + + +def _edited(connector: _Connector) -> dict[str, Any]: + return dict(connector.updates[0]["activity"]) + + +def _press(position: int, token: str = "tok-1", **overrides: Any) -> dict[str, Any]: + """The invoke Teams delivers when someone presses a button on the card.""" + activity: dict[str, Any] = { + "type": "invoke", + "name": "adaptiveCard/action", + "serviceUrl": SERVICE_URL, + "conversation": {"id": CONVERSATION, "conversationType": "channel"}, + "channelData": {"channel": {"id": CHANNEL}}, + "replyToId": CARD_ID, + "from": {"id": "29:presser", "aadObjectId": PRESSER, "name": "kim"}, + "value": { + "action": { + "type": "Action.Execute", + "verb": ANSWER_VERB, + "data": {"switchAnswer": {"token": token, "position": position}}, + }, + "trigger": "manual", + }, + } + activity.update(overrides) + return activity + + +# ── What the card offers ───────────────────────────────────────────────────── + + +async def test_an_open_card_offers_a_button_for_every_option_it_lists() -> None: + """Numbered the way the body numbers them and the way a typed answer names + them, so pressing and typing mean the same thing by the same word.""" + adapter, connector, _ = _handled() + + await adapter.post_rich(CHANNEL, AGENT, await _card(), ROOT) + + assert _buttons(_posted(connector)) == [ + ("1. Allow once", {"switchAnswer": {"token": "tok-1", "position": 1}}), + ("2. Deny", {"switchAnswer": {"token": "tok-1", "position": 2}}), + ] + + +async def test_a_press_carries_the_card_and_the_place_and_nothing_else() -> None: + """No option id, no actor, no label. Everything a client could have + rewritten is resolved against the record, so the less it carries the less + there is to resolve.""" + adapter, connector, _ = _handled() + + await adapter.post_rich(CHANNEL, AGENT, await _card(), ROOT) + + for _title, data in _buttons(_posted(connector)): + assert set(data) == {"switchAnswer"} + assert set(data["switchAnswer"]) == {"token", "position"} + assert "allow-once" not in str(_buttons(_posted(connector))) + + +async def test_the_buttons_are_executes_that_an_old_client_drops() -> None: + """A press has to reach the bot to be answered privately, which only + `Action.Execute` does. Wrapped in an `ActionSet` because that is where a + client that cannot run one honours the fallback.""" + adapter, connector, _ = _handled() + + await adapter.post_rich(CHANNEL, AGENT, await _card(), ROOT) + + card = _posted(connector)["attachments"][0]["content"] + block = card["body"][-1] + assert block["type"] == "ActionSet" + assert {action["type"] for action in block["actions"]} == {"Action.Execute"} + assert {action["fallback"] for action in block["actions"]} == {"drop"} + assert {action["verb"] for action in block["actions"]} == {ANSWER_VERB} + + +async def test_only_a_card_with_buttons_asks_for_the_newer_schema() -> None: + """A client too old for 1.5 renders the whole card as its fallback text, so + the version is raised where there is something to gain by it and nowhere + else.""" + adapter, connector, _ = _handled() + + await adapter.post_rich(CHANNEL, AGENT, await _card(), ROOT) + await adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT) + + assert _posted(connector)["attachments"][0]["content"]["version"] == "1.5" + card = connector.sends[1]["activity"]["attachments"][0]["content"] + assert card["version"] == "1.4" + + +async def test_the_body_still_lists_every_option_the_buttons_offer() -> None: + """The buttons are dropped on a client that cannot run them, and a body + that had left the options to them would leave that reader a question with + no choices and no way to answer it.""" + adapter, connector, _ = _handled() + + await adapter.post_rich(CHANNEL, AGENT, await _card(), ROOT) + + text = _card_text(_posted(connector)) + assert "1. Allow once" in text + assert "2. Deny" in text + assert "R7" in text + + +async def test_a_long_option_is_cut_on_the_button_and_whole_in_the_body() -> None: + """A row of buttons stops being readable long before Teams refuses one. + Cutting is safe only because the full text is above it.""" + adapter, connector, _ = _handled() + card = await _card() + wordy = card.request.model_copy( + update={ + "content": card.request.content.model_copy( + update={ + "options": [ + option.model_copy(update={"label": "Allow " + "very " * 40}) + for option in card.request.content.options + ] + } + ) + } + ) + + await adapter.post_rich(CHANNEL, AGENT, replace(card, request=wordy), ROOT) + + titles = [title for title, _ in _buttons(_posted(connector))] + assert all(len(title) <= 60 for title in titles) + assert titles[0].endswith("…") + assert "very very very" in _card_text(_posted(connector)) + + +async def test_a_settled_card_is_redrawn_without_its_buttons() -> None: + """A redraw rebuilds the whole card, so a settled request loses its + controls without anything having to remember it had them.""" + adapter, connector, _ = _handled() + card = await _card() + ref = await adapter.post_rich(CHANNEL, AGENT, card, ROOT) + + settled = replace( + card, request=card.request.model_copy(update={"state": "resolved"}) + ) + await adapter.update_rich(CHANNEL, AGENT, ref, settled, ROOT) + + assert _buttons(_posted(connector)) != [] + assert _buttons(_edited(connector)) == [] + + +async def test_a_card_that_cannot_be_answered_here_offers_nothing_to_press() -> None: + """It says why in its own words, and a live button under that sentence is + an invitation to the refusal the sentence just explained.""" + adapter, connector, _ = _handled() + + await adapter.post_rich( + CHANNEL, + AGENT, + await _card(unavailable_reason="Answer this one in the Console."), + ROOT, + ) + + assert _buttons(_posted(connector)) == [] + + +async def test_a_card_that_could_not_show_its_decision_offers_nothing_to_press() -> ( + None +): + """The body says it is too long to answer here β€” and a button beside that + sentence answers it anyway. The press would resolve against the saved form + and settle the request on text the reader never saw.""" + adapter, connector, _ = _handled() + card = await _card() + clipped = card.request.model_copy( + update={ + "content": card.request.content.model_copy( + update={"detail": "Deletes the production volume. " * 2000} + ) + } + ) + + await adapter.post_rich(CHANNEL, AGENT, replace(card, request=clipped), ROOT) + + assert "cannot be answered from this message" in _card_text(_posted(connector)) + assert _buttons(_posted(connector)) == [] + + +async def test_a_status_has_nothing_to_press() -> None: + adapter, connector, _ = _handled() + + await adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT) + + assert _buttons(_posted(connector)) == [] + + +async def test_a_bridge_that_takes_no_presses_draws_no_buttons() -> None: + """Every press here arrives as an invoke that has to be answered. One + answered with nothing to route it to is a control that spins and then + reports a failure of its own.""" + adapter, connector = _teams() + + await adapter.post_rich(CHANNEL, AGENT, await _card(), ROOT) + + assert _buttons(_posted(connector)) == [] + + +# ── The press that comes back ──────────────────────────────────────────────── + + +async def test_the_card_and_the_press_agree_on_the_option() -> None: + """The loop: the adapter draws the control, Teams hands the data back, and + the record turns it into the option the reader pressed.""" + adapter, _connector, seen = _handled() + card = await _card() + reference = await adapter.post_rich(CHANNEL, AGENT, card, ROOT) + + assert await adapter._dispatch_activity(_press(2)) is None + + interaction = seen[0] + assert interaction.value == card.reference.token + assert interaction.message_ref == reference + answer = resolve_pressed_position( + posted_form(card.request), + parse_answer_position(interaction.action_id) or 0, + ) + assert answer is not None + + +async def test_a_press_on_a_card_that_outlived_the_process_is_still_addressed() -> None: + """The conversation, the region and the message all come off the activity, + so a press on a card posted before the last restart resolves to the same + reference as one posted a moment ago. Nothing is read from memory.""" + adapter, _connector, seen = _handled() + _restart(adapter) + + await adapter._dispatch_activity(_press(1)) + + assert seen[0].channel_id == CHANNEL + assert seen[0].message_ref == _publication_ref(SERVICE_URL, CONVERSATION, CARD_ID) + + +async def test_a_press_is_attributed_to_the_account_that_sent_it() -> None: + """Teams fills in who pressed; the data says only which card and which + control. An id in the data would be a claim rather than a sender.""" + adapter, _connector, seen = _handled() + + await adapter._dispatch_activity(_press(1)) + + assert seen[0].sender_id == PRESSER + assert seen[0].sender_name == "kim" + + +async def test_a_press_taken_without_a_refusal_shows_the_presser_nothing() -> None: + """The card's own redraw is what says an answer was taken. Saying so here + would be saying it before the redraw that proves it.""" + adapter, _connector, seen = _handled() + + assert await adapter._dispatch_activity(_press(1)) is None + assert len(seen) == 1 + + +async def test_a_refused_press_is_told_to_the_presser_and_to_nobody_else() -> None: + """The invoke's own answer, which Teams shows to whoever pressed. The + conversation is not told that somebody's answer did not land.""" + adapter, connector = _teams() + + async def refuse(interaction: InboundInteraction) -> None: + await adapter.tell_actor( + interaction.channel_id, + interaction.sender_id, + interaction.sender_name, + None, + "Your answer to R7 did not land, because that card is no longer open.", + ) + + adapter.set_interaction_handler(refuse) + + answer = await adapter._dispatch_activity(_press(1)) + + assert answer is not None + assert answer["type"] == "application/vnd.microsoft.activity.message" + assert "no longer open" in str(answer["value"]) + assert connector.sends == [] + + +async def test_a_typed_answers_refusal_is_still_said_in_the_cards_post() -> None: + """There is no press to answer, so the notice goes where the base puts it: + the card's own post, which is the only private-ish thing left.""" + adapter, connector = _teams() + + await adapter.tell_actor( + CHANNEL, + PRESSER, + "kim", + _publication_ref(SERVICE_URL, CONVERSATION, CARD_ID), + "Not this one.", + ) + + assert "Not this one." in _posted(connector)["text"] + assert _posted(connector)["text"].startswith("kim") + + +async def test_a_press_on_a_card_that_is_not_ours_is_closed_and_ignored() -> None: + """Another app's card action, or one with data this would not read. The + press is still answered: an unanswered one spins on the presser's client + until it decides for itself that something broke.""" + adapter, _connector, seen = _handled() + unreadable: list[dict[str, Any]] = [ + {}, + {"action": {"verb": "other-app/go", "data": {}}}, + {"action": {"verb": ANSWER_VERB, "data": {}}}, + {"action": {"verb": ANSWER_VERB, "data": {"switchAnswer": {"token": "tok-1"}}}}, + { + "action": { + "verb": ANSWER_VERB, + "data": {"switchAnswer": {"token": "", "position": 1}}, + } + }, + { + "action": { + "verb": ANSWER_VERB, + "data": {"switchAnswer": {"token": "tok-1", "position": 0}}, + } + }, + { + "action": { + "verb": ANSWER_VERB, + "data": {"switchAnswer": {"token": "tok-1", "position": True}}, + } + }, + ] + + for value in unreadable: + answer = await adapter._dispatch_activity(_press(1, value=value)) + assert answer is not None + assert answer["type"] == "application/vnd.microsoft.error" + + assert seen == [] + + +async def test_a_press_that_names_no_card_is_refused_rather_than_guessed() -> None: + """Without the message there is nothing to match the token against, and a + guess at the card is how a token lifted from one gets pressed against + another.""" + adapter, _connector, seen = _handled() + + answer = await adapter._dispatch_activity(_press(1, replyToId="")) + + assert answer is not None + assert answer["value"]["code"] == "BadRequest" + assert seen == [] + + +async def test_a_press_with_nowhere_to_go_says_so_rather_than_succeeding() -> None: + """The card should never have had buttons. Answering with an empty success + would take the button out of its loading state as though the answer had + landed.""" + adapter, _connector = _teams() + + answer = await adapter._dispatch_activity(_press(1)) + + assert answer is not None + assert answer["type"] == "application/vnd.microsoft.error" + + +async def test_a_press_whose_handling_fails_still_closes_the_press( + caplog: pytest.LogCaptureFixture, +) -> None: + """The failure belongs in the log, not on a button that never stops + loading and not in an empty success that claims the answer landed.""" + adapter, _connector = _teams() + + async def explode(_interaction: InboundInteraction) -> None: + raise RuntimeError("the room went away") + + adapter.set_interaction_handler(explode) + + with caplog.at_level(logging.ERROR): + answer = await adapter._dispatch_activity(_press(1)) + + assert answer is not None + assert answer["statusCode"] == 500 + assert "the room went away" in caplog.text + + +async def test_the_answer_to_a_press_reaches_teams_as_the_invoke_response() -> None: + """A bare 200 with no body is what every other activity gets, and it says + nothing to the presser. The response is the only channel a refusal has.""" + adapter, _connector = _teams() + adapter._validator = None + + async def refuse(interaction: InboundInteraction) -> None: + await adapter.tell_actor( + interaction.channel_id, interaction.sender_id, "kim", None, "Not yours." + ) + + adapter.set_interaction_handler(refuse) + + response = await adapter._handle_http_messages( + _FakeHttpRequest(body=_press(1)) # type: ignore[arg-type] + ) + + assert response.status == 200 + assert b"Not yours." in response.body + + +async def test_an_ordinary_message_still_answers_with_a_bare_acknowledgement() -> None: + """Only an invoke has a body to return, and a message carrying one would be + a change in what every Teams activity has always been answered with.""" + adapter, _connector = _teams() + adapter._validator = None + + response = await adapter._handle_http_messages( + _FakeHttpRequest( # type: ignore[arg-type] + body={"type": "message", "text": "hello", "conversation": {"id": CHANNEL}} + ) + ) + + assert response.status == 200 + assert response.body in (b"", None) diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py b/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py index f47e8b1e2..c17a05ba1 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py @@ -146,14 +146,14 @@ def test_the_card_carries_mentions_where_teams_looks_for_them() -> None: adapter = _adapter(alice="aad-alice") body = adapter.translate_outbound("hi @alice") - activity = _run(adapter._message_activity("james", body)) + activity = _run(adapter._message_activity("james", body, [])) card = activity["attachments"][0]["content"] assert card["msteams"]["entities"][0]["mentioned"]["id"] == "aad-alice" def test_a_card_with_no_mentions_carries_no_msteams_block() -> None: - assert "msteams" not in agent_message_card(_RENDERING, "hello", []) + assert "msteams" not in agent_message_card(_RENDERING, "hello", [], []) # ── the app's own handle ───────────────────────────────────────────────────── @@ -176,7 +176,7 @@ def test_the_app_is_named_in_words_not_in_slack_syntax() -> None: def _lines(body: str) -> list[tuple[str, str]]: """Every body block of the card, as (spacing, text).""" - card = agent_message_card(_RENDERING, body, []) + card = agent_message_card(_RENDERING, body, [], []) return [(str(block["spacing"]), str(block["text"])) for block in card["body"][1:]] @@ -221,7 +221,7 @@ def test_a_body_of_many_short_lines_is_not_refused_for_its_punctuation() -> None still here and still on a line of its own; the gaps between them widen. """ body = "\n".join(f"line {n}" for n in range(500)) - card = agent_message_card(_RENDERING, body, []) + card = agent_message_card(_RENDERING, body, [], []) assert len(card["body"][1:]) == 1 text = str(card["body"][1]["text"]) @@ -245,7 +245,7 @@ def test_the_text_itself_is_left_alone() -> None: body = "**Heading:**\nbody" assert adapter.translate_outbound(body) == body - assert agent_message_card(_RENDERING, body, [])["fallbackText"].endswith(body) + assert agent_message_card(_RENDERING, body, [], [])["fallbackText"].endswith(body) def test_the_plain_text_seam_still_doubles_because_it_has_no_blocks() -> None: @@ -268,7 +268,7 @@ def test_send_message_does_not_translate_again() -> None: adapter = _adapter(alice="aad-alice") already = adapter.translate_outbound("hi @alice\nthere") - activity = _run(adapter._message_activity("james", already)) + activity = _run(adapter._message_activity("james", already, [])) blocks = activity["attachments"][0]["content"]["body"][1:] assert [block["text"] for block in blocks] == ["hi alice", "there"] From 5809497a8b96d23b8d7b2ccdededa77307bdf7f1 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 13:28:59 +0100 Subject: [PATCH 074/120] Discord: answer a permission card by pressing a button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request card posted to Discord now carries one button per option, and a press answers it the same way typing the number does β€” resolved against the stored card rather than against anything held in memory, so a press works after a restart. Two ownership facts shape this. Discord only lets an application-owned webhook carry interactive components, and the publication webhook is adopted by name, so it may be someone else's. The creator on the resolved webhook is the probe; a webhook the bridge minted is its own by construction. Where it is not ours the card is posted without buttons and a warning names the remedy, rather than switching to a different webhook: a webhook may only edit and delete what it sent, so swapping would strand every card already posted through it. A DM card is the bot's own message and has no webhook to own. Presses arrive as gateway interactions registered alongside the command tree, since the tree is handed application commands only. The press is acknowledged with a deferred message update before any Switch work β€” invisible, because the card's own redraw is what says an answer was taken β€” and a refusal reaches the presser alone through a follow-up, carried out of the shared inbound path by a context variable that `tell_actor` writes to. A typed answer has no press to follow up and still lands in the card's thread. The buttons are rebuilt on every redraw and passed on every edit, `None` included, so a settled or unanswerable card stops offering a press without anything having to remember it once had one. Unlike Teams, an option a button says in full is dropped from the body: every Discord client renders components, so there is no older client to keep the list for. An option the button had to cut stays whole in the text. More options than Discord's five-by-five grid holds means no buttons at all. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 456 +++++++++- .../test_discord_card_buttons.py | 782 ++++++++++++++++++ .../collaboration/test_discord_sdk_only.py | 7 +- 3 files changed, 1228 insertions(+), 17 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index d272bc3e5..53a368a30 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -7,6 +7,7 @@ import re from collections import OrderedDict from collections.abc import Awaitable, Callable, Coroutine +from contextvars import ContextVar from dataclasses import replace from datetime import UTC, datetime, timedelta from typing import Any, ClassVar @@ -47,11 +48,18 @@ InboundAgentJoin, InboundAppJoin, InboundCommand, + InboundInteraction, InboundMessage, InboundUserJoin, ) +from switch_core.bridges.collaboration.session.renderers import ( + Control, + Drawn, + offered_controls, + position_action, +) from switch_core.bridges.collaboration.session.renderers.neutral import ( - request_summary, + render_request, turn_status, ) @@ -98,6 +106,69 @@ # Switch writing the reservation and Discord stamping the message it is looking # for; the limit stops a busy channel turning one lookup into a history crawl. _RECOVERY_SKEW = timedelta(seconds=30) + +# What a press hands back, and what it may cost. Discord allows 100 characters +# in a component's id and 80 on its label; the label's budget is what is left +# once the option's number and its separator are in front of it. +_CUSTOM_ID_PREFIX = "sw" +_MAX_CUSTOM_ID = 100 +_MAX_BUTTON_LABEL = 76 + +# Five buttons to a row and five rows to a message. A card with more options +# than that gets none of them rather than some. +_MAX_BUTTONS = 25 + +# The notice a press is owed, collected while the press is being handled. +# +# A refusal is raised deep inside the shared inbound path, which knows the +# person and the reason and nothing about Discord; the only private way to tell +# them is a follow-up on the press itself, addressed by a token that belongs to +# the press rather than to the person. A context variable is what joins the +# two: `tell_actor` leaves the notice here and the press carries it, so nothing +# has to be looked up by actor β€” two people pressing at once are two tasks with +# a context each, and the same person pressing twice is two presses rather than +# one notice overwriting another. +_PRESS_NOTICE: ContextVar[list[str] | None] = ContextVar( + "switch_discord_press_notice", default=None +) + + +def _custom_id(token: str, position: int) -> str: + return f"{_CUSTOM_ID_PREFIX}:{token}:{position}" + + +def _parse_custom_id(custom_id: str) -> tuple[str, int] | None: + """The card and the option a press names, or None if it is not ours. + + Read as strictly as it is written. Discord hands back whatever was put in + the button and nothing else, so neither half is trusted past its shape: the + token is resolved against the stored card and the position against the form + that card was drawn from. + """ + parts = custom_id.split(":") + if len(parts) != 3 or parts[0] != _CUSTOM_ID_PREFIX: + return None + token, digits = parts[1], parts[2] + if not token or not digits.isascii() or not digits.isdecimal(): + return None + position = int(digits) + return (token, position) if position > 0 else None + + +def _button_label(control: Control) -> str: + """What the button says: the option's number, and as much of it as fits. + + Numbered because the body numbers it. A card is answerable by typing + whether or not it has buttons, and a reader looking at "2" in the text and + "Decline" on a button should not have to work out that they are the same + thing. + """ + label = control.label.strip() or f"Option {control.position}" + if len(label) > _MAX_BUTTON_LABEL: + label = label[: _MAX_BUTTON_LABEL - 1].rstrip() + "…" + return f"{control.position}. {label}" + + _RECOVERY_LIMIT = 100 # Waited when Discord says it is rate limiting but does not say for how long. @@ -329,6 +400,9 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: self._webhooks: dict[tuple[int, str], discord.Webhook] = {} # Ids of webhooks the bridge has minted/adopted, for echo dropping. self._webhook_ids: set[int] = set() + # Of those, the ones this application created β€” the only ones Discord + # will let carry buttons. See `_application_owns`. + self._owned_webhooks: set[int] = set() self._seen_ids: OrderedDict[int, None] = OrderedDict() self._seen_ids_max = 1000 # Discord user id ↔ username caches, for mention translation both ways. @@ -378,6 +452,11 @@ async def start( client = discord.Client(intents=intents) client.event(self._make_on_message()) + # Presses on a card's buttons. Registered alongside the command tree + # rather than through it: the tree is handed application-command + # interactions only, and a component interaction is dispatched as the + # plain `interaction` event whether or not anything is listening. + client.event(self._make_on_interaction()) self._tree = app_commands.CommandTree(client) guild = discord.Object(id=self._guild_id) for app_command in build_app_commands(self._handle_slash_command): @@ -463,6 +542,17 @@ async def on_message(message: discord.Message) -> None: return on_message + def _make_on_interaction( + self, + ) -> Callable[[discord.Interaction], Coroutine[Any, Any, None]]: + async def on_interaction(interaction: discord.Interaction) -> None: + try: + await self._handle_interaction(interaction) + except Exception: + logger.exception("Failed to handle a press on a Discord card") + + return on_interaction + async def stop(self) -> None: if self._client: try: @@ -480,6 +570,7 @@ async def stop(self) -> None: self._client = None self._tree = None self._webhooks.clear() + self._owned_webhooks.clear() logger.info("Discord adapter stopped") def _require_client(self) -> discord.Client: @@ -849,7 +940,9 @@ def rich_fallback_text(self, content: RichContent) -> str: `RichContentFailed`, where the lookups would be decorating a message nobody is going to see. """ - return self._draw(content, mention=None, responder=None, prefix="") + return self._draw( + content, mention=None, responder=None, prefix="", controls=False + ).text def _draw( self, @@ -858,7 +951,8 @@ def _draw( mention: str | None, responder: str | None, prefix: str, - ) -> str: + controls: bool, + ) -> Drawn: escape = self._rich_escape limit = max(1, self.rich_fallback_limit() - len(prefix)) markup = self.rich_markup() @@ -882,13 +976,13 @@ def _draw( ) + tail ) - return f"{prefix}{body}" + return Drawn(text=f"{prefix}{body}", answerable=False) # The mention goes on its own line rather than in front of the heading: # a card is a block, and a handle wedged before "**Permission needed**" # reads as part of the heading. lead = f"{mention}\n" if mention else "" tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" - body = request_summary( + drawn = render_request( content.request, content.reference, escape=escape, @@ -896,29 +990,116 @@ def _draw( markup=markup, responder=responder, unavailable_reason=content.unavailable_reason, + control_label_limit=_MAX_BUTTON_LABEL if controls else None, ) - return f"{prefix}{lead}{body}{tail}" + return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") - def _render_rich(self, content: RichContent, *, prefix: str) -> str: - """Draw `content` for one place on Discord. + def _render_rich( + self, content: RichContent, *, prefix: str, controls: bool + ) -> tuple[str, discord.ui.View | None]: + """Draw `content` for one place on Discord, with the buttons it earns. `prefix` is the inlined agent name a DM needs and a guild channel does not: a webhook message carries its sender's name and face, and a bot post in a DM carries the bot's, so there the name goes in the body the way `send_message` puts it there, charged to the same 2,000 characters as everything else. + + `controls` is whether buttons are possible where this is going at all β€” + false for a channel whose publication webhook this application does not + own, since Discord drops components from one it does not. Whether this + particular drawing gets any is decided below. """ responder = ( self._mention(content.responder_external_id) if isinstance(content, RequestCard) else None ) - return self._draw( + offered = self._offered(content) if controls else [] + drawn = self._draw( content, mention=self._mention(content.notify_external_id), responder=responder, prefix=prefix, + controls=bool(offered), ) + return drawn.text, self._controls(content, drawn, offered) + + def _offered(self, content: RichContent) -> list[Control]: + """The options this card would put on buttons, before it is drawn. + + Asked first because the answer changes the body: an option a button + says in full is one the body stops repeating, and a card with no + buttons has to print them all. A card with more options than Discord's + five-by-five grid holds gets none of them rather than the first + twenty-five, since a reader offered some of the choices would take the + absence of the rest for the whole list. + """ + if not isinstance(content, RequestCard) or self._on_interaction is None: + return [] + offered = offered_controls(content.request) + if len(offered) > _MAX_BUTTONS: + logger.warning( + "Request %s offers %d options, more than the %d Discord will " + "show as buttons, so its card is answerable by typing only.", + content.request.request_id, + len(offered), + _MAX_BUTTONS, + ) + return [] + return offered + + def _controls( + self, content: RichContent, drawn: Drawn, offered: list[Control] + ) -> discord.ui.View | None: + """The card's options as buttons, or nothing where a press cannot land. + + Nothing at all is the ordinary answer: a status has no options, a + settled card has none left, and a card that cannot be answered where it + is showing says so β€” a live control under that sentence is an + invitation to the refusal it just explained. Since every redraw builds + this again, the buttons come off a card at the moment it stops being + pressable, without anything having to remember that it once had them. + + Whether the drawing earned them comes from `drawn`, not from reading + the request a second time. A long detail or a clipped option leaves a + body the reader cannot decide from, and only the renderer that cut it + knows that. A press would still resolve against the saved form and + settle the request β€” so the whole of the protection is not offering the + button. + + A truncated *label* is not that case. `_MAX_BUTTON_LABEL` is what the + renderer was given too, so an option the button says in full is one the + body left to it and an option the button had to cut is one the body + kept whole. + + The view is stopped before it is returned. Nothing here waits on + discord.py's own dispatch β€” a press arrives as a gateway interaction + and is resolved against the stored card, which is what makes it survive + a restart β€” and an unstopped view is filed in the client's view store + for the life of the process, one per card ever posted. + """ + if not isinstance(content, RequestCard) or not offered or not drawn.answerable: + return None + view = discord.ui.View(timeout=None) + for control in offered: + custom_id = _custom_id(content.reference.token, control.position) + if len(custom_id) > _MAX_CUSTOM_ID: + raise RichContentFailed( + f"Cannot put a button on request {content.request.request_id} " + f"in Discord: its press would carry {len(custom_id)} " + f"characters and Discord allows {_MAX_CUSTOM_ID}.", + text=drawn.text, + ) + view.add_item( + discord.ui.Button( + label=_button_label(control), + custom_id=custom_id, + style=discord.ButtonStyle.secondary, + ) + ) + view.stop() + return view def _mention(self, external_user_id: str | None) -> str | None: """`<@id>` for a Discord user id, or None where there is nothing to name. @@ -983,13 +1164,17 @@ async def post_rich( lobby = self._channel_type_of(target) == "lobby" prefix = f"**{await self.agent_label_for_body(agent_name)}**: " if lobby else "" - text = self._render_rich(content, prefix=prefix) if lobby: + # A DM card is the bot's own message, and a bot may always put + # components on one β€” there is no webhook here to own or not own. + text, view = self._render_rich(content, prefix=prefix, controls=True) + kwargs: dict[str, Any] = {} if view is None else {"view": view} try: sent = await target.send( text, suppress_embeds=True, allowed_mentions=_NO_MASS_MENTIONS, + **kwargs, ) except Exception as error: raise self._rich_failure( @@ -997,6 +1182,19 @@ async def post_rich( ) from error return f"{sent.channel.id}:{sent.id}" + try: + webhook = await self._publication_webhook(int(channel_id)) + except Exception as error: + raise self._rich_failure( + error, + f"Discord could not resolve the publication webhook for channel " + f"{channel_id}", + fallback, + ) from error + text, view = self._render_rich( + content, prefix=prefix, controls=self._offers_buttons(int(channel_id)) + ) + thread: Any = None if thread_root_id: thread = await self._publication_thread( @@ -1004,7 +1202,6 @@ async def post_rich( ) try: - webhook = await self._publication_webhook(int(channel_id)) agent = await self.agent_rendering(agent_name) payload: dict[str, Any] = { "content": text, @@ -1015,6 +1212,8 @@ async def post_rich( } if thread is not None: payload["thread"] = thread + if view is not None: + payload["view"] = view sent = await _WebhookIdentity(agent.field_label, agent_name).send( webhook, payload ) @@ -1142,23 +1341,51 @@ async def update_rich( lobby = self._channel_type_of(target) == "lobby" prefix = f"**{await self.agent_label_for_body(agent_name)}**: " if lobby else "" + if lobby: + controls = True + else: + try: + await self._publication_webhook(int(channel_id)) + except Exception as error: + raise self._rich_failure( + error, + f"Discord could not resolve the publication webhook for " + f"channel {channel_id}", + self.rich_fallback_text(content), + ) from error + controls = self._offers_buttons(int(channel_id)) # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the channel that never reaches anybody # it has not already reached. - text = self._render_rich( - replace(content, notify_external_id=None), prefix=prefix + text, view = self._render_rich( + replace(content, notify_external_id=None), prefix=prefix, controls=controls ) - await self._edit_rich(channel_id, message_ref, text, lobby=lobby) + await self._edit_rich(channel_id, message_ref, text, view, lobby=lobby) async def _edit_rich( - self, channel_id: str, message_ref: str, text: str, *, lobby: bool + self, + channel_id: str, + message_ref: str, + text: str, + view: discord.ui.View | None, + *, + lobby: bool, ) -> None: + """Redraw a publication, including the buttons it does or does not keep. + + `view` is passed on every edit rather than only when there is one, + because leaving it out leaves the components alone: a settled card + would keep the buttons it was posted with and go on inviting a press + that can no longer land. `None` is what takes them off. + """ location_id, message_id = self._parse_message_ref(message_ref) try: if lobby: target = await self._get_channel(int(location_id or channel_id)) message = await target.fetch_message(int(message_id)) - await message.edit(content=text, allowed_mentions=_NO_MASS_MENTIONS) + await message.edit( + content=text, view=view, allowed_mentions=_NO_MASS_MENTIONS + ) return kwargs: dict[str, Any] = {} if location_id and location_id != channel_id: @@ -1167,6 +1394,7 @@ async def _edit_rich( await webhook.edit_message( int(message_id), content=text, + view=view, allowed_mentions=_NO_MASS_MENTIONS, **kwargs, ) @@ -2172,6 +2400,153 @@ async def _handle_message(self, message: Any) -> None: ) ) + # ── Card presses ───────────────────────────────────────────────────────── + + async def _handle_interaction(self, interaction: discord.Interaction) -> None: + """Someone pressed a button on a card this bridge posted. + + Who pressed comes from the interaction's own `user`, which Discord + fills in and the payload cannot: the id in the button says which + request and which option, never who. So a press replayed from someone + else's client is still attributed to whoever actually sent it, and the + identity check downstream is against a real account rather than a + claim. + + Which card comes from the message the press arrived on, addressed the + same way `post_rich` addressed it when it wrote the reference down β€” + thread or channel, then message. That is what makes a press work after + a restart: nothing is remembered between the two, and the button is + read against the stored card rather than against a view still in + memory. + + The press is acknowledged before any Switch work, because Discord + allows three seconds and the authority check is not bounded by them. + The acknowledgement changes nothing on the screen: the card's own + redraw is what says an answer was taken, and claiming it here would be + claiming it before the redraw that proves it. A refusal reaches the + presser through `tell_actor`, which leaves it in `_PRESS_NOTICE` for + the follow-up below β€” private to them, so a channel does not watch + somebody be told no. + + Nothing here dedupes. The same press twice is the same option, by the + same person, against the same revision β€” which the shared layer derives + one command id from, so the second is the first rather than a second + answer. + """ + if interaction.type is not discord.InteractionType.component: + return + if interaction.guild_id is not None and interaction.guild_id != self._guild_id: + return + data: dict[str, Any] = dict(interaction.data or {}) + press = _parse_custom_id(str(data.get("custom_id") or "")) + if press is None: + return + channel = interaction.channel + message = interaction.message + if channel is None or message is None: + logger.warning( + "A press on a Switch card carried no message to answer against, " + "so there is nothing to resolve it to." + ) + return + if self._on_interaction is None: + logger.warning( + "A press on a Switch card in Discord channel %s has nowhere to " + "go: this bridge handles no interactions, so the card should " + "not have been drawn with buttons.", + channel.id, + ) + return + + try: + await interaction.response.defer() + except discord.HTTPException: + logger.exception( + "Discord would not accept the acknowledgement of a press in " + "channel %s, so the answer is not attempted: a press that is " + "not acknowledged in time is one the presser is told failed.", + channel.id, + ) + return + + token, position = press + user = interaction.user + name = str(user.name) + # A press is a sighting of that account in this channel, and the same + # thing a message teaches: the name a mention needs, and the id a + # handle resolves to. + self._user_names[user.id] = name + self._username_to_id[name] = user.id + + # A card in a thread belongs to the parent channel's room, exactly as + # a message in that thread does β€” and that is the channel the card was + # recorded against. + parent_id = getattr(channel, "parent_id", None) + channel_id = str(parent_id if parent_id is not None else channel.id) + + notices: list[str] = [] + held = _PRESS_NOTICE.set(notices) + try: + await self._on_interaction( + InboundInteraction( + channel_id=channel_id, + sender_id=str(user.id), + sender_name=name, + action_id=position_action(position), + value=token, + message_ref=f"{channel.id}:{message.id}", + ) + ) + finally: + _PRESS_NOTICE.reset(held) + if notices: + await self._tell_presser(interaction, notices[0]) + + async def _tell_presser( + self, interaction: discord.Interaction, notice: str + ) -> None: + """Say why an answer did not land, to the person who pressed and no one else. + + A refusal from Discord is logged and left. Nothing downstream waits on + this, and the answer it would have explained has already been decided + either way. + """ + try: + await interaction.followup.send(notice, ephemeral=True) + except discord.HTTPException as error: + logger.warning( + "Discord would not carry the reply to a press (%s). The notice " + "went unsaid: %s", + error, + notice, + ) + + async def tell_actor( + self, + channel_id: str, + actor_ref: str, + actor_name: str, + thread_ref: str | None, + text: str, + ) -> None: + """Tell one person their answer did not land, where they can see it. + + A press is told in a follow-up to the press itself: visible to them + alone, which costs the channel nothing and reaches them without the bot + having to be able to open a DM with them. + + A typed answer has no press to follow up, so it falls back to the base: + said in the card's own thread, where everyone reading it sees a notice + addressed to someone else. That is the platform's limit rather than a + choice β€” nothing but an interaction gives a bot a private reply in a + channel. + """ + notices = _PRESS_NOTICE.get() + if notices is not None: + notices.append(text) + return + await super().tell_actor(channel_id, actor_ref, actor_name, thread_ref, text) + # ── Slash commands ─────────────────────────────────────────────────────── async def _handle_slash_command( @@ -2422,12 +2797,61 @@ async def _named_webhook(self, channel_id: int, name: str) -> discord.Webhook: webhook = existing break if webhook is None: + # Minted here, so it is this application's by construction. webhook = await channel.create_webhook(name=name) + owned = True + else: + owned = self._application_owns(webhook) self._webhooks[(channel_id, name)] = webhook self._webhook_ids.add(webhook.id) + if owned: + self._owned_webhooks.add(webhook.id) + elif name == _PUBLICATION_WEBHOOK_NAME: + logger.warning( + "The %r webhook in Discord channel %s was made by somebody " + "other than this application, so Discord will not let it carry " + "buttons: a request card posted there can only be answered by " + "typing. It is used anyway, because a webhook may only edit and " + "delete the messages it sent itself and swapping it would strand " + "every card already posted through it. Deleting it in the " + "channel's settings lets the bridge mint its own.", + name, + channel_id, + ) return webhook + def _application_owns(self, webhook: discord.Webhook) -> bool: + """Whether Discord will let this webhook carry interactive components. + + Only a webhook an application owns may send them; one a person made in + the channel's settings has its components dropped on the way out, so a + card posted through it would arrive with the question and no buttons. + Finding a webhook by name is no evidence either way β€” the name is + whatever it was called. + + Discord names the owner twice over: as `application_id`, which + discord.py does not carry onto the object, and as the account that + created it, which it does. For a webhook a bot created those are the + same application, so the creator is the probe. It is only filled in on + a webhook read through the channel, which is how this bridge reads + them; one fetched by its token says nothing about who made it. + """ + creator = getattr(webhook, "user", None) + return creator is not None and bool( + self._bot_user_id and creator.id == self._bot_user_id + ) + + def _offers_buttons(self, channel_id: int) -> bool: + """Whether a card published in this guild channel may have buttons. + + Answered from what `_publication_webhook` already resolved, so this + stays synchronous and costs nothing: the caller has resolved the + webhook by the time it draws. + """ + webhook = self._webhooks.get((channel_id, _PUBLICATION_WEBHOOK_NAME)) + return webhook is not None and webhook.id in self._owned_webhooks + async def _ensure_thread(self, channel_id: int, thread_root_ref: str) -> Any: """Resolve (creating if needed) the Discord thread rooted at the given external message ref, for posting a threaded reply. diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py b/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py new file mode 100644 index 000000000..f87e3cf1a --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py @@ -0,0 +1,782 @@ +"""Answering a Discord permission card by pressing it. + +Two things have to be true before a button is drawn at all. Discord only lets +an application-owned webhook carry interactive components, and the publication +webhook is found by name β€” so the one in a channel may be somebody else's, and +a card posted through it would arrive with the question and no way to press it. +And there has to be something for a press to reach; a button on a bridge that +handles no interactions is a button that does nothing. + +After that it is the shape the other platforms use: the press carries the +card's opaque token and the number beside the option, both resolved against the +record rather than trusted, and the answer is attributed to the account Discord +says sent it. Nothing is remembered between the post and the press, which is +what makes a card outlive a restart. + +A refusal is private. It reaches the presser as a follow-up on the press +itself, so a channel does not watch somebody be told no. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import replace +from typing import Any + +import discord +import pytest + +from switch_core.bridges.collaboration.adapter import RequestCard +from switch_core.bridges.collaboration.discord.adapter import ( + _MAX_BUTTONS, + _PUBLICATION_WEBHOOK_NAME, + _WEBHOOK_NAME, + DiscordAdapter, +) +from switch_core.bridges.collaboration.models import InboundInteraction +from switch_core.bridges.collaboration.session.form import ( + posted_form, + resolve_pressed_position, +) +from switch_core.bridges.collaboration.session.renderers import ( + Control, + offered_controls, + parse_answer_position, +) + +from .test_discord_sdk_only import ( + BOT_USER_ID, + CHANNEL_ID, + DM_CHANNEL_ID, + GUILD_ID, + ROOT_MESSAGE_ID, + _activity, + _adapter, + _card, + _Channel, + _DMChannel, + _guild_setup, + _Thread, + _Webhook, +) + +PRESSER_ID = 4242 +PRESSER_NAME = "kim" +CARD_MESSAGE_ID = 901 + + +# ── Fakes ──────────────────────────────────────────────────────────────────── + + +class _Presser: + def __init__(self) -> None: + self.id = PRESSER_ID + self.name = PRESSER_NAME + + +class _Response: + def __init__(self) -> None: + self.deferred = 0 + self.error: Exception | None = None + + async def defer(self) -> None: + if self.error is not None: + raise self.error + self.deferred += 1 + + +class _Followup: + def __init__(self) -> None: + self.sent: list[dict[str, Any]] = [] + self.error: Exception | None = None + + async def send(self, content: str, **kwargs: Any) -> None: + if self.error is not None: + raise self.error + self.sent.append({"content": content, **kwargs}) + + +class _Interaction: + """What the gateway hands a listener when a component is operated.""" + + def __init__( + self, + custom_id: str, + *, + channel: Any, + message: Any, + guild_id: int | None = GUILD_ID, + kind: discord.InteractionType = discord.InteractionType.component, + ) -> None: + self.type = kind + self.guild_id = guild_id + self.data: dict[str, Any] = {"custom_id": custom_id, "component_type": 2} + self.channel = channel + self.message = message + self.user = _Presser() + self.response = _Response() + self.followup = _Followup() + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _handled( + adapter: DiscordAdapter, +) -> list[InboundInteraction]: + """Give `adapter` somewhere for a press to go, and return what arrived.""" + seen: list[InboundInteraction] = [] + + async def took(interaction: InboundInteraction) -> None: + seen.append(interaction) + + adapter.set_interaction_handler(took) + return seen + + +def _answering() -> tuple[DiscordAdapter, _Channel, _Thread, _Webhook, list[Any]]: + """A guild channel whose publication webhook this application owns.""" + adapter, channel, thread, webhook = _guild_setup() + return adapter, channel, thread, webhook, _handled(adapter) + + +def _buttons(payload: dict[str, Any]) -> list[tuple[str, str]]: + """Every button in a send or edit, as (label, id), in the order drawn.""" + view = payload.get("view") + if view is None: + return [] + return [(item.label, item.custom_id) for item in view.children] + + +def _message(channel: Any, message_id: int = CARD_MESSAGE_ID) -> Any: + class _Posted: + id = message_id + + return _Posted() + + +async def _post_card( + adapter: DiscordAdapter, *, thread: bool = True, **kwargs: Any +) -> RequestCard: + card = await _card(**kwargs) + root = f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" if thread else None + await adapter.post_rich(str(CHANNEL_ID), "my-agent", card, root) + return card + + +def _settled(card: RequestCard) -> RequestCard: + return replace(card, request=card.request.model_copy(update={"state": "resolved"})) + + +def _with_options(card: RequestCard, label: Callable[[Any], str]) -> Any: + """The card's request with each option's label rewritten by `label`.""" + content = card.request.content + return card.request.model_copy( + update={ + "content": content.model_copy( + update={ + "options": [ + option.model_copy(update={"label": label(option)}) + for option in content.options + ] + } + ) + } + ) + + +# ── What a card offers ─────────────────────────────────────────────────────── + + +async def test_an_open_card_offers_every_option_as_a_numbered_button() -> None: + """The number is the one the body prints and the one a typed answer names, + so the two ways of answering mean the same thing by the same word.""" + adapter, _channel, _thread, webhook, _seen = _answering() + card = await _post_card(adapter) + + offered = offered_controls(card.request) + assert offered + assert _buttons(webhook.sent[0]) == [ + (f"{control.position}. {control.label}", f"sw:tok-1:{control.position}") + for control in offered + ] + + +async def test_a_press_carries_the_card_and_the_option_and_nothing_else() -> None: + """No actor, no option id, no label. What the button hands back is a token + to resolve and a number to count to, both checked against the record.""" + adapter, _channel, _thread, webhook, _seen = _answering() + await _post_card(adapter) + + for _label, custom_id in _buttons(webhook.sent[0]): + prefix, token, position = custom_id.split(":") + assert prefix == "sw" + assert token == "tok-1" + assert position.isdecimal() + + +async def test_an_option_the_button_says_in_full_is_not_repeated_in_the_body() -> None: + """Unlike the buttonless card, which has to print them: the body is the + only thing carrying the options there.""" + adapter, _channel, _thread, webhook, _seen = _answering() + card = await _post_card(adapter) + with_buttons = webhook.sent[0]["content"] + + plain, _channel2, _thread2, plain_webhook = _guild_setup() + await plain.post_rich( + str(CHANNEL_ID), "my-agent", card, f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + label = offered_controls(card.request)[0].label + assert label in plain_webhook.sent[0]["content"] + assert label not in with_buttons + + +async def test_an_option_too_long_for_its_button_is_kept_whole_in_the_body() -> None: + """A cut label is the shortest way to say which option, not the option. The + reader has to be able to see what they are agreeing to somewhere.""" + adapter, _channel, _thread, webhook, _seen = _answering() + card = await _card() + wordy = replace( + card, request=_with_options(card, lambda option: "Allow " + "very " * 40) + ) + + await adapter.post_rich(str(CHANNEL_ID), "my-agent", wordy, None) + + label, _custom_id = _buttons(webhook.sent[0])[0] + assert len(label) <= 80 + assert label.endswith("…") + assert "very very very" in webhook.sent[0]["content"] + + +async def test_a_settled_card_is_redrawn_with_its_buttons_taken_off() -> None: + """`view=None` rather than nothing at all: an edit that leaves the + components alone leaves a settled card inviting a press.""" + adapter, _channel, _thread, webhook, _seen = _answering() + settled = _settled(await _card()) + + await adapter.update_rich( + str(CHANNEL_ID), + "my-agent", + f"{ROOT_MESSAGE_ID}:{CARD_MESSAGE_ID}", + settled, + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + ) + + assert "view" in webhook.edits[0] + assert webhook.edits[0]["view"] is None + + +async def test_a_card_that_cannot_be_answered_here_offers_no_press() -> None: + """A live control under the sentence explaining why an answer cannot land + is an invitation to the refusal it just explained.""" + adapter, _channel, _thread, webhook, _seen = _answering() + + await _post_card(adapter, unavailable_reason="this channel is read-only") + + assert _buttons(webhook.sent[0]) == [] + + +async def test_a_turn_status_offers_no_press() -> None: + adapter, _channel, _thread, webhook, _seen = _answering() + + await adapter.post_rich(str(CHANNEL_ID), "my-agent", _activity(), None) + + assert _buttons(webhook.sent[0]) == [] + + +async def test_a_bridge_that_takes_no_presses_draws_no_buttons() -> None: + """The handler is what a press reaches. Without one the card is answerable + by typing and says so, rather than offering a control that goes nowhere.""" + adapter, _channel, _thread, webhook = _guild_setup() + + await adapter.post_rich(str(CHANNEL_ID), "my-agent", await _card(), None) + + assert _buttons(webhook.sent[0]) == [] + + +async def test_more_options_than_discord_shows_gets_none_of_them( + caplog: pytest.LogCaptureFixture, +) -> None: + """Some of the choices reads as all of them. A card that cannot show the + whole list shows none and is answered by typing.""" + adapter, _channel, _thread, webhook, _seen = _answering() + card = await _card() + content = card.request.content + crowded = replace( + card, + request=card.request.model_copy( + update={ + "content": content.model_copy( + update={ + "options": [ + content.options[0].model_copy( + update={"option_id": f"o{n}", "label": f"Option {n}"} + ) + for n in range(_MAX_BUTTONS + 1) + ] + } + ) + } + ), + ) + + with caplog.at_level(logging.WARNING): + await adapter.post_rich(str(CHANNEL_ID), "my-agent", crowded, None) + + assert _buttons(webhook.sent[0]) == [] + assert "typing only" in caplog.text + assert "Option 3" in webhook.sent[0]["content"] + + +async def test_the_view_is_never_left_in_the_clients_own_store() -> None: + """Nothing here waits on discord.py's dispatch β€” a press arrives as a + gateway interaction. An unstopped view would be filed against the message + for the life of the process, one per card ever posted.""" + adapter, _channel, _thread, webhook, _seen = _answering() + + await _post_card(adapter) + + assert webhook.sent[0]["view"].is_finished() is True + + +# ── Which webhook may carry a button ───────────────────────────────────────── + + +async def test_a_webhook_this_application_made_may_carry_buttons() -> None: + adapter, _channel, _thread, webhook, _seen = _answering() + + await _post_card(adapter) + + assert _buttons(webhook.sent[0]) + + +async def test_a_webhook_somebody_else_made_carries_none_and_says_so( + caplog: pytest.LogCaptureFixture, +) -> None: + """Discord drops components from a webhook it does not consider an + application's. Finding one by name proves nothing about who made it.""" + adapter, channel, _thread, _webhook = _guild_setup() + _handled(adapter) + channel.existing_webhooks = [ + _Webhook(_WEBHOOK_NAME), + _Webhook(_PUBLICATION_WEBHOOK_NAME, creator=BOT_USER_ID + 1), + ] + theirs = channel.existing_webhooks[1] + + with caplog.at_level(logging.WARNING): + await adapter.post_rich(str(CHANNEL_ID), "my-agent", await _card(), None) + + assert _buttons(theirs.sent[0]) == [] + assert "not let it carry buttons" in caplog.text + assert "answered by typing" in caplog.text + + +async def test_a_webhook_of_unknown_making_is_not_taken_for_ours() -> None: + """Discord leaves the creator out when a webhook is read by its token. An + absent answer is not a yes.""" + adapter, channel, _thread, _webhook = _guild_setup() + _handled(adapter) + channel.existing_webhooks = [ + _Webhook(_WEBHOOK_NAME), + _Webhook(_PUBLICATION_WEBHOOK_NAME, creator=None), + ] + + await adapter.post_rich(str(CHANNEL_ID), "my-agent", await _card(), None) + + assert _buttons(channel.existing_webhooks[1].sent[0]) == [] + + +async def test_a_webhook_the_bridge_had_to_mint_is_ours_by_construction() -> None: + """Nothing else made it, so nothing has to be read back to know that.""" + channel = _Channel() + adapter = _adapter({CHANNEL_ID: channel}) + _handled(adapter) + + await adapter.post_rich(str(CHANNEL_ID), "my-agent", await _card(), None) + + minted = channel.existing_webhooks[-1] + assert minted.name == _PUBLICATION_WEBHOOK_NAME + assert _buttons(minted.sent[0]) + + +async def test_somebody_elses_webhook_is_used_rather_than_replaced() -> None: + """A webhook may only edit and delete the messages it sent. Swapping it + would strand every card already posted through it, so the cards stay where + they are and lose their buttons instead.""" + adapter, channel, _thread, _webhook = _guild_setup() + _handled(adapter) + channel.existing_webhooks = [ + _Webhook(_WEBHOOK_NAME), + _Webhook(_PUBLICATION_WEBHOOK_NAME, creator=BOT_USER_ID + 1), + ] + + await adapter.post_rich(str(CHANNEL_ID), "my-agent", await _card(), None) + + assert len(channel.existing_webhooks) == 2 + assert channel.existing_webhooks[1].sent + + +# ── A card in a DM ─────────────────────────────────────────────────────────── + + +async def test_a_card_in_a_dm_gets_buttons_on_the_bots_own_message() -> None: + """There is no webhook in a DM to own or not own, and a bot may always put + components on a message it wrote itself.""" + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + _handled(adapter) + + await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", await _card(), None) + + assert _buttons(dm.sent[0]) + + +async def test_a_settled_card_in_a_dm_loses_its_buttons_too() -> None: + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + _handled(adapter) + await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", await _card(), None) + posted = dm.messages[501] + settled = _settled(await _card()) + + await adapter.update_rich( + str(DM_CHANNEL_ID), "my-agent", f"{DM_CHANNEL_ID}:501", settled, None + ) + + assert posted.edits[0]["view"] is None + + +# ── A press ────────────────────────────────────────────────────────────────── + + +async def test_a_press_names_the_option_the_reader_was_looking_at() -> None: + """End to end through the shared seam: the number on the button resolves + against the form the card was posted with.""" + adapter, _channel, thread, webhook, seen = _answering() + card = await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[1] + + await adapter._handle_interaction( + _Interaction(custom_id, channel=thread, message=_message(thread)) + ) + + assert len(seen) == 1 + assert seen[0].value == card.reference.token + answer = resolve_pressed_position( + posted_form(card.request), + parse_answer_position(seen[0].action_id) or 0, + ) + assert answer is not None + + +async def test_a_press_in_a_thread_is_addressed_the_way_the_card_was() -> None: + """The card was recorded against the parent channel and the thread's own + message. A press has to resolve to the same two, or the shared layer sees + an answer about some other card and ignores it.""" + adapter, _channel, thread, webhook, seen = _answering() + reference = await adapter.post_rich( + str(CHANNEL_ID), "my-agent", await _card(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + _label, custom_id = _buttons(webhook.sent[0])[0] + + await adapter._handle_interaction( + _Interaction(custom_id, channel=thread, message=_message(thread)) + ) + + assert seen[0].channel_id == str(CHANNEL_ID) + assert seen[0].message_ref == reference + + +async def test_a_press_in_a_dm_is_addressed_to_the_dm() -> None: + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + seen = _handled(adapter) + reference = await adapter.post_rich( + str(DM_CHANNEL_ID), "my-agent", await _card(), None + ) + _label, custom_id = _buttons(dm.sent[0])[0] + + await adapter._handle_interaction( + _Interaction(custom_id, channel=dm, message=_message(dm, 501), guild_id=None) + ) + + assert seen[0].channel_id == str(DM_CHANNEL_ID) + assert seen[0].message_ref == reference + + +async def test_a_press_on_a_card_that_outlived_the_process_still_resolves() -> None: + """Nothing is remembered between the post and the press: the card comes off + the message the press arrived on, so an adapter that has never seen it + addresses it the same way.""" + _poster, channel, thread, webhook, _seen = _answering() + reference = await _poster.post_rich( + str(CHANNEL_ID), "my-agent", await _card(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + _label, custom_id = _buttons(webhook.sent[0])[0] + + restarted = _adapter({CHANNEL_ID: channel, ROOT_MESSAGE_ID: thread}) + seen = _handled(restarted) + await restarted._handle_interaction( + _Interaction(custom_id, channel=thread, message=_message(thread)) + ) + + assert seen[0].message_ref == reference + + +async def test_a_press_is_attributed_to_the_account_discord_says_sent_it() -> None: + """The id in the button says which card and which option, never who. An + actor carried in the payload would be a claim rather than a sender.""" + adapter, _channel, thread, webhook, seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + + await adapter._handle_interaction( + _Interaction(custom_id, channel=thread, message=_message(thread)) + ) + + assert seen[0].sender_id == str(PRESSER_ID) + assert seen[0].sender_name == PRESSER_NAME + + +async def test_a_press_is_acknowledged_before_switch_is_asked_anything() -> None: + """Discord allows three seconds and the authority check is not bounded by + them. An unacknowledged press is one the presser is told failed.""" + adapter, _channel, thread, webhook, _seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + order: list[str] = [] + + async def took(interaction: InboundInteraction) -> None: + order.append("answered") + + adapter.set_interaction_handler(took) + press = _Interaction(custom_id, channel=thread, message=_message(thread)) + + original = press.response.defer + + async def defer() -> None: + order.append("acknowledged") + await original() + + press.response.defer = defer # type: ignore[method-assign] + await adapter._handle_interaction(press) + + assert order == ["acknowledged", "answered"] + + +async def test_a_press_discord_would_not_acknowledge_is_not_answered( + caplog: pytest.LogCaptureFixture, +) -> None: + """Past the window the presser is shown a failure whatever happens next, so + an answer taken after it would settle a request nobody was told about.""" + adapter, _channel, thread, webhook, seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + press = _Interaction(custom_id, channel=thread, message=_message(thread)) + press.response.error = discord.HTTPException(_HttpResponse(), "too late") # type: ignore[arg-type] + + with caplog.at_level(logging.ERROR): + await adapter._handle_interaction(press) + + assert seen == [] + assert "not attempted" in caplog.text + + +async def test_a_clean_press_says_nothing_to_anybody() -> None: + """The card's own redraw is what says an answer was taken. Saying so here + would be claiming it before the redraw that proves it.""" + adapter, channel, thread, webhook, _seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + press = _Interaction(custom_id, channel=thread, message=_message(thread)) + + await adapter._handle_interaction(press) + + assert press.followup.sent == [] + assert channel.sent == [] + assert thread.sent == [] + + +async def test_a_refused_press_is_explained_to_the_presser_alone() -> None: + """A refusal names the person and the reason, and a channel does not need + to watch somebody be told no.""" + adapter, channel, thread, webhook, _seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + + async def refuse(interaction: InboundInteraction) -> None: + await adapter.tell_actor( + str(CHANNEL_ID), + str(PRESSER_ID), + PRESSER_NAME, + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + "That request was answered already.", + ) + + adapter.set_interaction_handler(refuse) + press = _Interaction(custom_id, channel=thread, message=_message(thread)) + await adapter._handle_interaction(press) + + assert press.followup.sent == [ + {"content": "That request was answered already.", "ephemeral": True} + ] + assert channel.sent == [] + assert thread.sent == [] + + +async def test_a_refusal_discord_will_not_carry_is_logged_and_left( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing downstream waits on it, and the answer it explains was decided + either way.""" + adapter, _channel, thread, webhook, _seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + + async def refuse(interaction: InboundInteraction) -> None: + await adapter.tell_actor( + str(CHANNEL_ID), str(PRESSER_ID), PRESSER_NAME, None, "Not yours to answer." + ) + + adapter.set_interaction_handler(refuse) + press = _Interaction(custom_id, channel=thread, message=_message(thread)) + press.followup.error = discord.HTTPException(_HttpResponse(), "gone") # type: ignore[arg-type] + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) + + assert "went unsaid" in caplog.text + assert "Not yours to answer." in caplog.text + + +async def test_a_typed_answers_refusal_is_still_said_in_the_cards_thread() -> None: + """There is no press to follow up, so it falls back to the base: said where + the card is, which is the only reply Discord gives a bot unprompted.""" + adapter, _channel, thread, _webhook, _seen = _answering() + + await adapter.tell_actor( + str(CHANNEL_ID), + str(PRESSER_ID), + PRESSER_NAME, + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + "Not this one.", + ) + + assert thread.sent + assert "Not this one." in thread.sent[0]["content"] + + +# ── A press that is not ours ───────────────────────────────────────────────── + + +async def test_a_press_this_bridge_did_not_write_is_left_alone() -> None: + """Every shape that is not a Switch answer: another app's control, a + truncated one, a position that is not a count, and one that is not a + number at all.""" + adapter, _channel, thread, _webhook, seen = _answering() + + for custom_id in ( + "", + "other:tok-1:1", + "sw:tok-1", + "sw::1", + "sw:tok-1:", + "sw:tok-1:0", + "sw:tok-1:-1", + "sw:tok-1:Ω’", + "sw:tok-1:1:2", + "sw:tok-1:two", + ): + press = _Interaction(custom_id, channel=thread, message=_message(thread)) + await adapter._handle_interaction(press) + assert press.response.deferred == 0, custom_id + + assert seen == [] + + +async def test_an_interaction_that_is_not_a_press_is_left_to_the_command_tree() -> None: + """A slash command arrives on the same event, and the tree handles it.""" + adapter, _channel, thread, webhook, seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + + await adapter._handle_interaction( + _Interaction( + custom_id, + channel=thread, + message=_message(thread), + kind=discord.InteractionType.application_command, + ) + ) + + assert seen == [] + + +async def test_a_press_from_another_guild_is_not_this_bridges_business() -> None: + adapter, _channel, thread, webhook, seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + + await adapter._handle_interaction( + _Interaction( + custom_id, channel=thread, message=_message(thread), guild_id=GUILD_ID + 1 + ) + ) + + assert seen == [] + + +async def test_a_press_with_nowhere_to_go_is_said_out_loud( + caplog: pytest.LogCaptureFixture, +) -> None: + """The card should never have been drawn with buttons, so this is a bug + report rather than a refusal.""" + adapter, _channel, thread, _webhook = _guild_setup() + press = _Interaction("sw:tok-1:1", channel=thread, message=_message(thread)) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) + + assert "nowhere to go" in caplog.text + assert press.response.deferred == 0 + + +async def test_a_handler_that_raises_is_logged_rather_than_thrown_at_discord( + caplog: pytest.LogCaptureFixture, +) -> None: + """The gateway listener is an event loop: one bad press must not take the + connection down with it.""" + adapter, _channel, thread, webhook, _seen = _answering() + await _post_card(adapter) + _label, custom_id = _buttons(webhook.sent[0])[0] + + async def explode(interaction: InboundInteraction) -> None: + raise RuntimeError("no") + + adapter.set_interaction_handler(explode) + listener = adapter._make_on_interaction() + + with caplog.at_level(logging.ERROR): + await listener( + _Interaction(custom_id, channel=thread, message=_message(thread)) # type: ignore[arg-type] + ) + + assert "Failed to handle a press on a Discord card" in caplog.text + + +# ── The pieces the rest of it rests on ─────────────────────────────────────── + + +def test_a_buttons_label_is_numbered_the_way_the_body_numbers_it() -> None: + from switch_core.bridges.collaboration.discord.adapter import _button_label + + assert _button_label(Control(position=2, label="Deny")) == "2. Deny" + assert _button_label(Control(position=1, label=" ")) == "1. Option 1" + + +class _HttpResponse: + status = 400 + reason = "Bad Request" + headers: dict[str, str] = {} diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index e873698a3..7e2933851 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -102,10 +102,12 @@ def __init__(self, channel: Any, message_id: int, content: str = "") -> None: self.author = _Author(0) self.webhook_id: int | None = None self.edited: str | None = None + self.edits: list[dict[str, Any]] = [] self.deleted = False async def edit(self, *, content: str, **kwargs: Any) -> None: self.edited = content + self.edits.append({"content": content, **kwargs}) async def delete(self) -> None: self.deleted = True @@ -251,10 +253,13 @@ def __init__(self, parent: _Channel, thread_id: int = ROOT_MESSAGE_ID) -> None: class _Webhook: - def __init__(self, name: str) -> None: + def __init__(self, name: str, *, creator: int | None = BOT_USER_ID) -> None: self.id = _WEBHOOK_IDS[name] self.name = name self.token = "tok" + # Who Discord says made it. The bridge reads this to decide whether + # its cards may carry buttons β€” see `_application_owns`. + self.user = None if creator is None else _Author(creator) self.sent: list[dict[str, Any]] = [] self.edits: list[dict[str, Any]] = [] self.deletes: list[dict[str, Any]] = [] From 86a425b44a531f2a12c5229de731797427b37617 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 13:46:45 +0100 Subject: [PATCH 075/120] Mattermost: sign the hidden data a button carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mattermost keeps an action's context confidential and hands it back to the integration untouched, and documents it as the place to put something that proves a callback came from the server rather than from whoever found the URL. This is that proof, and the reading of it. What travels is a signature, not the key. A context that leaks β€” through a bug, a backup, a database dump β€” then hands over one card's button rather than the means to forge every card's. The card's token rides along as the subject of the press, never as its credential: it says which request is being answered and nothing about who may answer it, and the actor is read from the body Mattermost fills in. The key is derived from the server secret and the bridge's id rather than stored on the bridge. A stored secret would have to be minted when a bridge is registered, leaving every Mattermost bridge that predates this unable to carry a button until somebody edited its configuration by hand, and would add a second secret to keep and rotate. Deriving separates one bridge's signature from another's, and rotating the server secret rotates it β€” which invalidates buttons already on the channel, so that case is refused by name in the log rather than silently. The context is read as strictly as it is written: exactly the three signed fields and nothing beside them, since the signature cannot vouch for a field it does not cover. No route serves this yet. Where the callback is hosted is an open deployment question; nothing here depends on the answer. Co-Authored-By: Claude Opus 5 --- .../collaboration/mattermost/callback.py | 155 ++++++++++++++ .../collaboration/test_mattermost_callback.py | 191 ++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 core/switch_core/bridges/collaboration/mattermost/callback.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py diff --git a/core/switch_core/bridges/collaboration/mattermost/callback.py b/core/switch_core/bridges/collaboration/mattermost/callback.py new file mode 100644 index 000000000..3d7adf642 --- /dev/null +++ b/core/switch_core/bridges/collaboration/mattermost/callback.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + +# Where a press carries what it is answering. Nested under one key because +# Mattermost merges nothing of its own into the context, but a flat name is a +# collision waiting for the first card here to want a second kind of button. +CONTEXT_KEY = "switch" + +# What the signature is computed over, so a value minted for one purpose can +# never be replayed as another if a second kind of button is ever added. +_PURPOSE = "answer" + +# What the bridge's own key is derived from, kept apart from anything else the +# server secret is used for. +_KEY_PURPOSE = "mattermost-callback" + + +def callback_key(server_secret: str, bridge_id: str) -> str: + """The key this bridge signs its buttons with. + + Derived rather than stored. A secret on the bridge's saved configuration + would have to be minted when the bridge is registered, which leaves every + Mattermost bridge registered before this existed unable to carry a button + until somebody edits its configuration by hand β€” and adds a second secret + to keep, back up and rotate. Deriving it costs none of that: the key exists + the moment the bridge starts, and rotating the server secret rotates it. + + Separated by bridge, so one bridge's signature cannot be presented to + another, and by purpose, so it is not the same value as anything else + derived from the same secret. Rotating the server secret invalidates the + buttons on cards already posted; those cards stay answerable by typing, and + a press on one is refused in the log by name rather than silently. + """ + return hmac.new( + server_secret.encode(), + f"{_KEY_PURPOSE}:{bridge_id}".encode(), + hashlib.sha256, + ).hexdigest() + + +@dataclass(frozen=True) +class Press: + """A button press Mattermost vouched for, before Switch has judged it. + + Everything here is asserted by the server rather than by the button: the + ids come from the request body, which only Mattermost can produce a valid + signature for. What the button carried is the card and the option, neither + of which says who may press it. + """ + + user_id: str + post_id: str + channel_id: str + token: str + position: int + + +def action_context(secret: str, token: str, position: int) -> dict[str, Any]: + """The hidden data a button carries, signed so a forgery cannot be built. + + Mattermost keeps an action's context confidential β€” it is never serialised + to a client β€” and documents it as the place to put a value that proves a + callback came from the server. What goes here is a signature rather than + the secret itself, so that a context which does leak, through a bug or a + database dump, hands over one card's button rather than the key to every + card's. + + The card's token is the subject, not the credential: it says which request + is being answered and nothing about who may answer it. Authority is decided + afterwards, against the actor Mattermost names. + """ + return { + CONTEXT_KEY: { + "token": token, + "position": position, + "signature": _sign(secret, token, position), + } + } + + +def read_press(secret: str, body: dict[str, Any]) -> Press | None: + """What a callback is asking for, or None if it is not ours to act on. + + Read as strictly as it is written, and in two stages. A body that does not + carry a Switch context at all is somebody else's integration posting to a + shared route, and is passed over quietly. A body that carries one whose + signature does not verify is a forgery attempt, or a credential that has + been rotated out from under posts already on the channel, and says so in + the log β€” those are worth telling apart, and neither is worth acting on. + + Only the shape is established here. That the post is the one the card was + published to, and that this person may answer it at all, are decided + against the record further in. + """ + carried = body.get("context") + if not isinstance(carried, dict): + return None + switch = carried.get(CONTEXT_KEY) + if not isinstance(switch, dict): + return None + # Nothing but what was signed. The signature covers the card and the + # option, so a field beside them is one it does not vouch for β€” and a + # reader added later would be reading an unsigned value out of a context + # that looks authentic. Refusing the whole thing keeps the signature's + # promise the same size as the context. + if set(switch) != {"token", "position", "signature"}: + return None + + token = switch.get("token") + position = switch.get("position") + signature = switch.get("signature") + if not isinstance(token, str) or not token: + return None + if isinstance(position, bool) or not isinstance(position, int) or position < 1: + return None + if not isinstance(signature, str) or not signature: + return None + if not hmac.compare_digest(signature, _sign(secret, token, position)): + logger.warning( + "Rejected a Mattermost action callback for request %s: the context " + "signature does not verify. Either it was not signed with this " + "bridge's credential, or the credential has been rotated since the " + "card was posted.", + token, + ) + return None + + user_id = body.get("user_id") + post_id = body.get("post_id") + channel_id = body.get("channel_id") + if not isinstance(user_id, str) or not user_id: + return None + if not isinstance(post_id, str) or not post_id: + return None + if not isinstance(channel_id, str) or not channel_id: + return None + return Press( + user_id=user_id, + post_id=post_id, + channel_id=channel_id, + token=token, + position=position, + ) + + +def _sign(secret: str, token: str, position: int) -> str: + message = f"{_PURPOSE}:{token}:{position}".encode() + return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py new file mode 100644 index 000000000..5d088d032 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py @@ -0,0 +1,191 @@ +"""The credential on a Mattermost button, and what a press has to prove. + +Mattermost keeps an action's context confidential and hands it back to the +integration untouched, which is where the proof that a callback came from the +server has to live. These cover what goes in, what is accepted back, and β€” more +to the point β€” what is not: a context nobody signed, one signed with another +bridge's key, and one whose numbers were changed after signing. + +Nothing here decides whether the presser may answer. That is the shared +authority path's job, against the actor Mattermost names in the body. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from switch_core.bridges.collaboration.mattermost.callback import ( + CONTEXT_KEY, + action_context, + callback_key, + read_press, +) + +SERVER_SECRET = "server-secret-for-tests" +BRIDGE_ID = "bridge-1" +OTHER_BRIDGE_ID = "bridge-2" +TOKEN = "tok-1" +POSITION = 2 + +USER_ID = "user-abc" +POST_ID = "post-abc" +CHANNEL_ID = "channel-abc" + + +def _key() -> str: + return callback_key(SERVER_SECRET, BRIDGE_ID) + + +def _body(context: dict[str, Any], **overrides: Any) -> dict[str, Any]: + body: dict[str, Any] = { + "user_id": USER_ID, + "post_id": POST_ID, + "channel_id": CHANNEL_ID, + "team_id": "team-abc", + "context": context, + } + body.update(overrides) + return body + + +def test_a_signed_press_reads_back_as_what_it_was_built_from() -> None: + press = read_press(_key(), _body(action_context(_key(), TOKEN, POSITION))) + + assert press is not None + assert press.token == TOKEN + assert press.position == POSITION + assert press.user_id == USER_ID + assert press.post_id == POST_ID + assert press.channel_id == CHANNEL_ID + + +def test_the_context_carries_a_signature_and_not_the_key() -> None: + key = _key() + context = action_context(key, TOKEN, POSITION) + + carried = context[CONTEXT_KEY] + assert set(carried) == {"token", "position", "signature"} + assert key not in repr(context) + assert SERVER_SECRET not in repr(context) + + +def test_the_signature_is_the_same_every_time_it_is_built() -> None: + first = action_context(_key(), TOKEN, POSITION) + second = action_context(_key(), TOKEN, POSITION) + + assert first == second + + +def test_a_body_with_no_context_is_not_ours() -> None: + assert read_press(_key(), {"user_id": USER_ID, "post_id": POST_ID}) is None + + +def test_a_context_from_another_integration_is_passed_over() -> None: + assert read_press(_key(), _body({"action": "something-else"})) is None + + +def test_a_context_that_is_not_an_object_is_not_ours() -> None: + assert read_press(_key(), _body({CONTEXT_KEY: "answer"})) is None + + +def test_an_unsigned_context_is_refused() -> None: + context = {CONTEXT_KEY: {"token": TOKEN, "position": POSITION}} + + assert read_press(_key(), _body(context)) is None + + +def test_a_position_changed_after_signing_is_refused_and_logged( + caplog: Any, +) -> None: + context = action_context(_key(), TOKEN, POSITION) + context[CONTEXT_KEY]["position"] = POSITION + 1 + + with caplog.at_level(logging.WARNING): + assert read_press(_key(), _body(context)) is None + + assert "signature does not verify" in caplog.text + + +def test_a_token_changed_after_signing_is_refused() -> None: + context = action_context(_key(), TOKEN, POSITION) + context[CONTEXT_KEY]["token"] = "tok-2" + + assert read_press(_key(), _body(context)) is None + + +def test_another_bridges_signature_is_refused() -> None: + other = callback_key(SERVER_SECRET, OTHER_BRIDGE_ID) + context = action_context(other, TOKEN, POSITION) + + assert read_press(_key(), _body(context)) is None + + +def test_a_signature_from_a_rotated_secret_says_so(caplog: Any) -> None: + stale = callback_key("the-previous-server-secret", BRIDGE_ID) + context = action_context(stale, TOKEN, POSITION) + + with caplog.at_level(logging.WARNING): + assert read_press(_key(), _body(context)) is None + + assert "rotated" in caplog.text + assert TOKEN in caplog.text + + +def test_each_bridge_signs_with_a_key_of_its_own() -> None: + assert callback_key(SERVER_SECRET, BRIDGE_ID) != callback_key( + SERVER_SECRET, OTHER_BRIDGE_ID + ) + + +def test_the_key_is_not_the_server_secret() -> None: + key = _key() + + assert SERVER_SECRET not in key + assert key != SERVER_SECRET + + +def test_a_press_naming_nobody_is_refused() -> None: + context = action_context(_key(), TOKEN, POSITION) + + assert read_press(_key(), _body(context, user_id="")) is None + assert read_press(_key(), _body(context, post_id="")) is None + assert read_press(_key(), _body(context, channel_id="")) is None + + +def test_an_actor_smuggled_into_the_context_is_refused() -> None: + """The context says which card and which option. It never says who. + + The actor is read from the body, which Mattermost fills in, so a `user_id` + here would be ignored on its own merits. It is refused outright instead, + because the signature does not cover it: a field beside the signed ones is + a field something added later could read and trust by mistake. + """ + context = action_context(_key(), TOKEN, POSITION) + context[CONTEXT_KEY]["user_id"] = "somebody-else" + + assert read_press(_key(), _body(context)) is None + + +def test_a_position_that_is_not_a_counting_number_is_refused() -> None: + for position in (0, -1, True, "2", 2.0, None): + context = { + CONTEXT_KEY: { + "token": TOKEN, + "position": position, + "signature": "whatever", + } + } + assert read_press(_key(), _body(context)) is None + + +def test_a_token_that_is_not_a_string_is_refused() -> None: + for token in (None, 7, ["tok-1"]): + context = { + CONTEXT_KEY: { + "token": token, + "position": POSITION, + "signature": "whatever", + } + } + assert read_press(_key(), _body(context)) is None From 56cd1aed8bf5e678b888f087625e2a39aea3bd09 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 14:30:55 +0100 Subject: [PATCH 076/120] Collaboration: one shared door for the callbacks a platform delivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Almost every platform Switch bridges to is dialled out to, and nothing has to reach Switch for it to work. A Mattermost button press is the exception: the Mattermost server delivers it by HTTP. The listener is owned by the lifecycle service rather than by an adapter, because the port belongs to the process and not to a bridge. Two Mattermost bridges β€” two tenants, or a test server beside a real one β€” are ordinary, and a listener each would be a port and an ingress rule each. Each bridge is routed by type and id, so a second platform needing callbacks is a sibling rather than a collision. It gets a socket of its own rather than a route on the agent API, which also carries the MCP server and the operator dashboard: what an operator has to expose for a button should be callbacks and nothing else. Nothing binds until a bridge asks to be served, so a deployment with no callback address anywhere opens no port at all, and the bind outlives any one bridge so a restart cannot unbind the port under its neighbours. A bridge that is not running answers 404 rather than refusing the connection, which Mattermost would retry into. Each bridge is handed its place already bound to it, along with the key it authenticates its own presses with. The key moves here from the Mattermost module so that no adapter ever holds the server secret it is derived from, and gains the bridge type so two bridges sharing an id across platforms do not share a key. A verified press goes the whole way: the presser comes from the body, which the server fills in and the button cannot, and the card and option come from the signed context. A refusal raised while the answer is judged is carried back out as `ephemeral_text`, the one reply Mattermost shows to the presser alone, instead of being posted where the channel would read it. A presser the server cannot name is refused rather than attributed to their raw id β€” the id is what authority is judged on, but the handle is what a participant is created under, and a lookup failure must not become somebody's permanent name in the room. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 15 + .../bridges/collaboration/ingress.py | 216 +++++++++++ .../collaboration/lifecycle_service.py | 24 ++ .../collaboration/mattermost/adapter.py | 232 ++++++++++-- .../collaboration/mattermost/callback.py | 27 -- core/switch_core/config.py | 13 + .../test_bridge_type_registry.py | 11 +- .../test_collaboration_ingress.py | 347 ++++++++++++++++++ .../test_lifecycle_callback_endpoint.py | 133 +++++++ .../test_lifecycle_tenant_binding.py | 5 + .../collaboration/test_mattermost_callback.py | 28 +- .../collaboration/test_mattermost_press.py | 314 ++++++++++++++++ 12 files changed, 1295 insertions(+), 70 deletions(-) create mode 100644 core/switch_core/bridges/collaboration/ingress.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_mattermost_press.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 1764b93e5..a32091360 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -9,6 +9,7 @@ from switch_core.agent_display_name import defuse_label_markup from switch_core.agent_icon import default_icon_url +from switch_core.bridges.collaboration.ingress import CallbackEndpoint from switch_core.bridges.collaboration.models import ( BridgeInstallLink, ChannelCreationUnsupported, @@ -1132,6 +1133,20 @@ def set_channel_team_persister( silently kills capture in every channel outside the configured team.""" return None + def set_callback_endpoint(self, endpoint: CallbackEndpoint) -> None: + """Hand the adapter its own place on the shared callback listener. + + Default is a no-op, and the right answer for every adapter that only + dials out: nothing has to reach Switch for it to work, so it never asks + to be served and the listener never binds. Mattermost overrides it, + because a button press there is delivered to a URL. + + The endpoint arrives already bound to this bridge. An adapter is built + from its connection config alone and is never told which bridge it is, + which is what stops it addressing another bridge's callbacks even by + mistake.""" + return None + def set_channel_migration_handler( self, handler: Callable[[str, str], Awaitable[None]] ) -> None: diff --git a/core/switch_core/bridges/collaboration/ingress.py b/core/switch_core/bridges/collaboration/ingress.py new file mode 100644 index 000000000..c8d8b7599 --- /dev/null +++ b/core/switch_core/bridges/collaboration/ingress.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from aiohttp import web + +logger = logging.getLogger(__name__) + +# What a bridge's callbacks are addressed as. The type is in the path so a +# second platform needing one is a sibling rather than a collision, and the +# bridge's id is in it so one listener serves every bridge and every tenant. +_ROUTE = "/collaboration/{bridge_type}/{bridge_id}/callback" + +# What a callback key is derived from, so it is not the same value as anything +# else the server secret is used for. +_KEY_PURPOSE = "collaboration-callback" + +Handler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]] + + +class CallbackRefused(Exception): + """A callback this bridge will not act on, and the answer it is owed. + + Raised by a handler that has read the request and decided against it β€” + an unsigned press, a credential that no longer verifies, a body that is + not what the route is for. The status travels with it because refusing is + a normal outcome of an authenticated route, not a fault to be logged as + one. + """ + + def __init__(self, message: str, *, status: int) -> None: + super().__init__(message) + self.status = status + + +class CallbackIngress: + """One HTTP listener for every collaboration bridge that is called back. + + Most platforms never need this: Slack, Discord, Telegram and Mattermost's + own event stream all dial out, and nothing has to reach Switch for them to + work. A Mattermost button is the exception β€” a press is delivered by the + Mattermost server to a URL, so there has to be something listening. + + It is a port of its own rather than a route on the agent API. The agent API + carries the agent surface, the MCP server and the operator dashboard, and a + callback route on it would mean one over-broad proxy rule away from + publishing all three. Here the whole of what an operator exposes is + callbacks, so a mistake can only expose callbacks. + + It is shared rather than one per bridge, because a per-bridge listener + costs a port and an ingress rule each, and two Mattermost bridges β€” two + tenants, or a test server beside a real one β€” are ordinary. Each bridge is + named in its own path and authenticates its own callers; this class routes + and does not judge. + + Nothing binds until a bridge actually asks to be served, so a deployment + with no callbacks opens no port. Once bound it stays bound until shutdown: + a bridge restarting would otherwise unbind and rebind the port underneath + any other bridge sharing it. + """ + + def __init__(self, *, host: str, port: int, secret: str) -> None: + self._host = host + self._port = port + self._secret = secret + self._handlers: dict[tuple[str, str], Handler] = {} + self._runner: web.AppRunner | None = None + + def endpoint_for(self, bridge_type: str, bridge_id: str) -> CallbackEndpoint: + return CallbackEndpoint( + self, + bridge_type, + bridge_id, + key=self._key_for(bridge_type, bridge_id), + ) + + def path_for(self, bridge_type: str, bridge_id: str) -> str: + return _ROUTE.format(bridge_type=bridge_type, bridge_id=bridge_id) + + def _key_for(self, bridge_type: str, bridge_id: str) -> str: + """The key one bridge authenticates its own callbacks with. + + Derived rather than stored. A secret on the bridge's saved + configuration would have to be minted when the bridge is registered, + which leaves every bridge registered before callbacks existed unable to + take one until somebody edits its configuration by hand β€” and adds a + second secret to keep, back up and rotate. Deriving it costs none of + that: the key exists the moment the bridge starts, and rotating the + server secret rotates it. + + Separated by bridge, so what one bridge accepts another will not, and + by purpose, so it is not the same value as anything else derived from + the same secret. Rotating the server secret invalidates whatever the + old key signed; a platform that has already handed out signed material + is responsible for saying so rather than failing quietly. + """ + return hmac.new( + self._secret.encode(), + f"{_KEY_PURPOSE}:{bridge_type}:{bridge_id}".encode(), + hashlib.sha256, + ).hexdigest() + + async def serve(self, bridge_type: str, bridge_id: str, handle: Handler) -> None: + """Take callbacks for one bridge, binding the listener if it is the first.""" + self._handlers[(bridge_type, bridge_id)] = handle + await self._listen() + + async def withdraw(self, bridge_type: str, bridge_id: str) -> None: + """Stop taking callbacks for one bridge. + + The listener stays up. A press that arrives for a bridge that is not + running is answered as gone rather than by a refused connection, which + is the difference between a Mattermost server that reports the problem + and one that retries into a closed port. + """ + self._handlers.pop((bridge_type, bridge_id), None) + + async def stop(self) -> None: + self._handlers.clear() + if self._runner is None: + return + await self._runner.cleanup() + self._runner = None + logger.info("Collaboration callback listener stopped") + + async def _listen(self) -> None: + if self._runner is not None: + return + app = web.Application() + app.router.add_post(_ROUTE, self._dispatch) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, self._host, self._port) + await site.start() + self._runner = runner + logger.info("Collaboration callback listener on %s:%s", self._host, self._port) + + async def _dispatch(self, request: web.Request) -> web.StreamResponse: + bridge_type = request.match_info["bridge_type"] + bridge_id = request.match_info["bridge_id"] + handle = self._handlers.get((bridge_type, bridge_id)) + if handle is None: + logger.warning( + "A callback arrived for %s bridge %s, which is not running here.", + bridge_type, + bridge_id, + ) + return web.json_response( + {"error": {"message": "Unknown bridge."}}, status=404 + ) + + try: + body = await request.json() + except Exception: + return web.json_response( + {"error": {"message": "Expected a JSON body."}}, status=400 + ) + if not isinstance(body, dict): + return web.json_response( + {"error": {"message": "Expected a JSON object."}}, status=400 + ) + + try: + answer = await handle(body) + except CallbackRefused as refused: + return web.json_response( + {"error": {"message": str(refused)}}, status=refused.status + ) + except Exception: + # The listener serves every bridge sharing the port, so one + # handler's failure is not allowed to be the port's. + logger.exception( + "Failed to handle a callback for %s bridge %s", bridge_type, bridge_id + ) + return web.json_response( + {"error": {"message": "Switch could not handle that."}}, status=500 + ) + return web.json_response(answer) + + +class CallbackEndpoint: + """One bridge's place on the shared listener, and the key it vouches with. + + Handed to an adapter so it can serve its own callbacks without knowing + which bridge it is or that it shares a port with anything β€” and without + ever holding the server secret the key came from. + """ + + def __init__( + self, + ingress: CallbackIngress, + bridge_type: str, + bridge_id: str, + *, + key: str, + ) -> None: + self._ingress = ingress + self._bridge_type = bridge_type + self._bridge_id = bridge_id + self.key = key + + @property + def path(self) -> str: + """The path a caller reaches this bridge on, below whatever base URL + the platform has been told to use.""" + return self._ingress.path_for(self._bridge_type, self._bridge_id) + + async def serve(self, handle: Handler) -> None: + await self._ingress.serve(self._bridge_type, self._bridge_id, handle) + + async def withdraw(self) -> None: + await self._ingress.withdraw(self._bridge_type, self._bridge_id) diff --git a/core/switch_core/bridges/collaboration/lifecycle_service.py b/core/switch_core/bridges/collaboration/lifecycle_service.py index 76d89cc97..e6bcbbb07 100644 --- a/core/switch_core/bridges/collaboration/lifecycle_service.py +++ b/core/switch_core/bridges/collaboration/lifecycle_service.py @@ -10,6 +10,7 @@ from switch_core.bridges.collaboration.adapter import CollaborationAdapter from switch_core.bridges.collaboration.bridge_core import BridgeCore +from switch_core.bridges.collaboration.ingress import CallbackEndpoint, CallbackIngress from switch_core.bridges.collaboration.models import BridgeConnectionConfig from switch_core.clients.bridge_client import BridgeClient, BridgeClientConfig from switch_core.clients.client_factory import ClientFactory @@ -94,6 +95,18 @@ def __init__( # (see CollaborationAdapter.exclusive_resource). Lets a second # claimant be refused by name instead of failing on the resource. self._held_resources: dict[str, str] = {} + # The one listener every bridge that gets called back shares, and each + # running bridge's place on it. Owned here rather than by an adapter + # because the port is the process's, not a bridge's: two Mattermost + # bridges are ordinary, and a listener each would be a port and an + # ingress rule each. Constructed unconditionally and bound by nobody β€” + # it binds when a bridge first asks to be served. + self._callback_ingress = CallbackIngress( + host=config.collaboration_callback_host, + port=config.collaboration_callback_port, + secret=config.jwt_secret_key, + ) + self._callback_endpoints: dict[str, CallbackEndpoint] = {} # Serialises registration. The exclusivity check reads the stored # bridges and the winner is not written until several awaits later, # so two concurrent registrations would both see a free resource and @@ -483,6 +496,10 @@ async def start(self, bridge_id: str) -> None: ) adapter.set_max_attachment_bytes(self._config.agent_media_max_bytes) + callback_endpoint = self._callback_ingress.endpoint_for(bridge.type, bridge_id) + adapter.set_callback_endpoint(callback_endpoint) + self._callback_endpoints[bridge_id] = callback_endpoint + gateway_warning = gateway_url_warning( self._config.gateway_public_url, adapter_cls.renders_custom_url_schemes ) @@ -631,6 +648,12 @@ async def _run_bridge( self._held_resources.pop(bridge_id, None) async def stop(self, bridge_id: str) -> None: + # Before the adapter goes, so a press in flight is answered as gone + # rather than handled by a bridge that is halfway shut down. + endpoint = self._callback_endpoints.pop(bridge_id, None) + if endpoint is not None: + await endpoint.withdraw() + bridge_core = self._bridges.get(bridge_id) if bridge_core: await bridge_core.stop() @@ -656,6 +679,7 @@ async def stop_all(self) -> None: logger.info("Stopping all %d collaboration bridges", len(self._bridges)) for bridge_id in list(self._bridges): await self.stop(bridge_id) + await self._callback_ingress.stop() async def remove(self, bridge_id: str) -> None: """Disconnect a messaging app and take its identities with it. diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 7fc525385..eb2759a94 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -10,6 +10,7 @@ import uuid from collections import OrderedDict from collections.abc import Awaitable, Callable +from contextvars import ContextVar from dataclasses import replace from datetime import datetime from typing import Any, ClassVar @@ -38,6 +39,11 @@ RichContentThrottled, TurnActivity, ) +from switch_core.bridges.collaboration.ingress import ( + CallbackEndpoint, + CallbackRefused, +) +from switch_core.bridges.collaboration.mattermost.callback import read_press from switch_core.bridges.collaboration.models import ( Attachment, AttachmentFailure, @@ -47,10 +53,12 @@ InboundAgentJoin, InboundAppJoin, InboundCommand, + InboundInteraction, InboundMessage, InboundUserJoin, OutboundAttachment, ) +from switch_core.bridges.collaboration.session.renderers import position_action from switch_core.bridges.collaboration.session.renderers.neutral import ( request_summary, turn_status, @@ -181,6 +189,25 @@ class MattermostConnectionConfig(BridgeConnectionConfig): # credentials and bot tokens are never sent over an unverified https # connection. Set False only for a self-signed internal CA you trust. verify_tls: bool = True + # Base URL (scheme + host, no path) the *Mattermost server* reaches + # Switch's callback listener on, for the button presses Mattermost delivers + # by HTTP. Not `url` reversed and not `gateway_public_url`: this is a + # separate port from the agent API, and the route between the two servers + # is frequently nothing like the route a browser takes β€” a container alias + # on a shared network, or an ingress hostname that only exists inside the + # cluster. Unset means Switch has no address to give Mattermost, so cards + # carry no buttons and stay answerable by typing. + callback_base_url: str | None = None + + +# A refusal being collected for the person who pressed, if a press is what we +# are in the middle of. Mattermost has one chance to say something privately β€” +# the `ephemeral_text` on the response to the callback β€” so a notice raised +# while the answer is being judged has to be caught here and carried back out +# rather than posted where the channel would read it. +_PRESS_NOTICE: ContextVar[list[str] | None] = ContextVar( + "switch_mattermost_press_notice", default=None +) class MattermostAdapter(CollaborationAdapter): @@ -279,6 +306,14 @@ def __init__(self, *, config: MattermostConnectionConfig) -> None: self._name_display_read = False self._name_display_warned = False + # This bridge's place on the shared callback listener, installed by the + # lifecycle service before start. None when the adapter is running + # without one behind it, which is how most of the tests build it. + self._callback: CallbackEndpoint | None = None + + def set_callback_endpoint(self, endpoint: CallbackEndpoint) -> None: + self._callback = endpoint + # ── Lifecycle ──────────────────────────────────────────────────────────── async def start( @@ -316,6 +351,8 @@ async def start( await self._ensure_admin_bot() + await self._start_callbacks() + logger.info( "Mattermost adapter connected to %s as %s", self._config.url, @@ -327,6 +364,144 @@ async def stop(self) -> None: self._bot_drivers.clear() logger.info("Mattermost adapter stopped") + # ── Callbacks ──────────────────────────────────────────────────────────── + + @property + def callback_url(self) -> str | None: + """Where this bridge's presses should be delivered, or None if nowhere. + + None when either half is missing: an operator who has not said how the + Mattermost server reaches Switch, or an adapter running without a + listener behind it. A caller building a button asks this first β€” there + is no button to draw without an address on it. + """ + base = self._config.callback_base_url + if not base or self._callback is None: + return None + return f"{base.rstrip('/')}{self._callback.path}" + + async def _start_callbacks(self) -> None: + """Take presses for this bridge, or say once why there will be none. + + A request card is answerable by typing whether or not it carries a + button, so no callback address is a reduced service rather than a + failure to start. It is said out loud because it is otherwise + invisible: cards keep arriving and simply never have anything to press. + """ + if self._callback is None: + return + url = self.callback_url + if url is None: + logger.warning( + "Mattermost cards on %s will carry no buttons: this bridge has " + "no callback_base_url, so there is no address to give the " + "Mattermost server for a press. Requests stay answerable by " + "typing.", + self._config.url, + ) + return + await self._callback.serve(self._handle_callback) + logger.info( + "Mattermost presses for %s will be taken at %s", self._config.url, url + ) + + async def _handle_callback(self, body: dict[str, Any]) -> dict[str, Any]: + """Someone pressed a button on a card this bridge posted. + + Who pressed comes from the body, which the Mattermost server fills in + and the button cannot. What the button carried is the card and the + option, signed with this bridge's key so that a body assembled by + anything but Mattermost β€” the route is reachable by whoever can reach + the port β€” does not read as a press at all. + + Which card comes from the signed token rather than from the post the + press arrived on, and is resolved against the stored record. That is + what makes a press work after a restart: nothing about the card is held + in memory between posting it and answering it. + + Nothing here dedupes. The same press twice is the same option, by the + same person, against the same revision, which the shared layer derives + one command id from β€” so the second is the first rather than a second + answer. + + The reply is the one chance to say something to the presser alone: + Mattermost shows `ephemeral_text` to them and nobody else. A refusal + raised while the answer is being judged reaches `tell_actor`, which + leaves it here rather than posting it where the channel would read it. + """ + if self._callback is None: + raise CallbackRefused("This bridge takes no callbacks.", status=404) + press = read_press(self._callback.key, body) + if press is None: + raise CallbackRefused("Not a press this bridge will act on.", status=401) + if self._on_interaction is None: + logger.warning( + "A press on a Switch card in Mattermost channel %s has nowhere " + "to go: this bridge handles no interactions, so the card should " + "not have been drawn with buttons.", + press.channel_id, + ) + raise CallbackRefused("This bridge handles no presses.", status=404) + + name = await self._username_for(press.user_id) + if name is None: + # Refused rather than attributed to the raw id: the id is what the + # answer is judged against, but the name is what a puppet is + # created under, and inventing one from an id makes a person who + # cannot be looked up into a permanent participant named after a + # lookup failure. + logger.error( + "A press in Mattermost channel %s is from a user this bridge " + "cannot resolve to a handle, so it is not acted on.", + press.channel_id, + ) + raise CallbackRefused("Switch could not identify you.", status=500) + + notices: list[str] = [] + held = _PRESS_NOTICE.set(notices) + try: + await self._on_interaction( + InboundInteraction( + channel_id=press.channel_id, + sender_id=press.user_id, + sender_name=name, + action_id=position_action(press.position), + value=press.token, + message_ref=press.post_id, + ) + ) + finally: + _PRESS_NOTICE.reset(held) + + if notices: + return {"ephemeral_text": notices[0]} + return {} + + async def tell_actor( + self, + channel_id: str, + actor_ref: str, + actor_name: str, + thread_ref: str | None, + text: str, + ) -> None: + """Tell one person their answer did not land, where they can see it. + + A press is answered in the reply to the press itself, which Mattermost + shows to that person alone: the channel is told nothing, and it reaches + them without the bot needing to be able to open a DM. + + A typed answer has no press to reply to, so it falls back to the base: + said in the card's own thread, where everyone reading it sees a notice + addressed to someone else. That is the platform's limit rather than a + choice β€” nothing but a callback gives a bot a private reply here. + """ + notices = _PRESS_NOTICE.get() + if notices is not None: + notices.append(text) + return + await super().tell_actor(channel_id, actor_ref, actor_name, thread_ref, text) + # ── Messaging ──────────────────────────────────────────────────────────── async def send_message( @@ -944,30 +1119,41 @@ async def _mention(self, external_user_id: str | None) -> str | None: """ if not external_user_id: return None + username = await self._username_for(external_user_id) + return f"@{username}" if username else None + + async def _username_for(self, external_user_id: str) -> str | None: + """The handle behind a Mattermost user id, or None if it cannot be read. + + Cached because a handle is stable for the life of an account, so a hit + saves a round trip on every redraw carrying a mention and on every + press. + """ username = self._usernames.get(external_user_id) - if username is None: - driver = self._admin_driver - loop = self._main_loop - if driver is None or loop is None: - return None - try: - user = await loop.run_in_executor( - None, driver.users.get_user, external_user_id - ) - except Exception as e: - logger.warning( - "Could not resolve Mattermost user %s to a handle: %s", - external_user_id, - e, - ) - return None - username = str(user.get("username") or "") - if not username: - return None - self._usernames[external_user_id] = username - while len(self._usernames) > self._usernames_max: - self._usernames.popitem(last=False) - return f"@{username}" + if username is not None: + return username + driver = self._admin_driver + loop = self._main_loop + if driver is None or loop is None: + return None + try: + user = await loop.run_in_executor( + None, driver.users.get_user, external_user_id + ) + except Exception as e: + logger.warning( + "Could not resolve Mattermost user %s to a handle: %s", + external_user_id, + e, + ) + return None + username = str(user.get("username") or "") + if not username: + return None + self._usernames[external_user_id] = username + while len(self._usernames) > self._usernames_max: + self._usernames.popitem(last=False) + return username async def delete_message(self, channel_id: str, message_ref: str) -> None: if not self._admin_driver or not self._main_loop: diff --git a/core/switch_core/bridges/collaboration/mattermost/callback.py b/core/switch_core/bridges/collaboration/mattermost/callback.py index 3d7adf642..691449521 100644 --- a/core/switch_core/bridges/collaboration/mattermost/callback.py +++ b/core/switch_core/bridges/collaboration/mattermost/callback.py @@ -17,33 +17,6 @@ # never be replayed as another if a second kind of button is ever added. _PURPOSE = "answer" -# What the bridge's own key is derived from, kept apart from anything else the -# server secret is used for. -_KEY_PURPOSE = "mattermost-callback" - - -def callback_key(server_secret: str, bridge_id: str) -> str: - """The key this bridge signs its buttons with. - - Derived rather than stored. A secret on the bridge's saved configuration - would have to be minted when the bridge is registered, which leaves every - Mattermost bridge registered before this existed unable to carry a button - until somebody edits its configuration by hand β€” and adds a second secret - to keep, back up and rotate. Deriving it costs none of that: the key exists - the moment the bridge starts, and rotating the server secret rotates it. - - Separated by bridge, so one bridge's signature cannot be presented to - another, and by purpose, so it is not the same value as anything else - derived from the same secret. Rotating the server secret invalidates the - buttons on cards already posted; those cards stay answerable by typing, and - a press on one is refused in the log by name rather than silently. - """ - return hmac.new( - server_secret.encode(), - f"{_KEY_PURPOSE}:{bridge_id}".encode(), - hashlib.sha256, - ).hexdigest() - @dataclass(frozen=True) class Press: diff --git a/core/switch_core/config.py b/core/switch_core/config.py index 8e327775b..33d434908 100644 --- a/core/switch_core/config.py +++ b/core/switch_core/config.py @@ -153,6 +153,19 @@ class SwitchConfig(BaseSettings): server_host: str = "0.0.0.0" server_port: int = 8000 + # Where collaboration bridges take platform callbacks. Only a Mattermost + # button press needs one today: the press is delivered by the Mattermost + # server to a URL, where every other platform Switch bridges to sends it + # down a connection Switch already holds open. + # + # A socket of its own, not a route on the port above, which carries the + # agent API, the MCP server and the operator dashboard. What an operator + # has to expose for a button to work should be callbacks and nothing else, + # so that one over-broad proxy rule cannot publish the other three. It + # stays unbound in a deployment where no bridge asks to be called back. + collaboration_callback_host: str = "0.0.0.0" + collaboration_callback_port: int = 8081 + frontend_base_url: str | None = None # Public origin (scheme + host, no path) of the Switch API β€” the same host diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py b/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py index 992e0e614..af4fde3be 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py @@ -1,5 +1,7 @@ from __future__ import annotations +from unittest.mock import MagicMock + import pytest from pydantic import ValidationError as PydanticValidationError @@ -33,8 +35,13 @@ def _service() -> CollaborationBridgeLifecycleService: get_registered_types / get_config_schema touch just the in-memory registries populated by register_adapter, so the heavy collaborators are - irrelevant here and passed as None. + irrelevant here and passed as None. The config is not one of them: the + shared callback listener is constructed up front, from it. """ + config = MagicMock() + config.collaboration_callback_host = "127.0.0.1" + config.collaboration_callback_port = 0 + config.jwt_secret_key = "server-secret-for-tests" return CollaborationBridgeLifecycleService( bridge_store=None, # type: ignore[arg-type] external_user_store=None, # type: ignore[arg-type] @@ -47,7 +54,7 @@ def _service() -> CollaborationBridgeLifecycleService: room_service=None, # type: ignore[arg-type] matrix_admin=None, # type: ignore[arg-type] session_factory=None, # type: ignore[arg-type] - config=None, # type: ignore[arg-type] + config=config, client_factory=None, # type: ignore[arg-type] ) diff --git a/core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py b/core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py new file mode 100644 index 000000000..91021c4cf --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py @@ -0,0 +1,347 @@ +"""The one HTTP door collaboration bridges are called back on. + +Almost every platform Switch bridges to is dialled out to, and nothing has to +reach Switch for it to work. A Mattermost button press is the exception: the +Mattermost server delivers it to a URL. These cover the door itself β€” that it +stays shut until a bridge asks for it, that a bridge is only reachable while it +is running, and that one bridge's trouble is not the port's. + +What a press has to prove is the bridge's own business, decided by the handler +behind the door. The only credential here is the per-bridge key handed out with +the endpoint, so that no adapter ever holds the server secret it came from. +""" + +from __future__ import annotations + +import logging +import socket +from typing import Any + +import aiohttp +import pytest + +from switch_core.bridges.collaboration.ingress import ( + CallbackIngress, + CallbackRefused, +) + +SECRET = "server-secret-for-tests" +BRIDGE = "bridge-1" +OTHER = "bridge-2" + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port: int = sock.getsockname()[1] + return port + + +def _ingress(port: int) -> CallbackIngress: + return CallbackIngress(host="127.0.0.1", port=port, secret=SECRET) + + +async def _post( + port: int, bridge_id: str, body: Any, bridge_type: str = "mattermost" +) -> tuple[int, dict[str, Any]]: + url = f"http://127.0.0.1:{port}/collaboration/{bridge_type}/{bridge_id}/callback" + async with aiohttp.ClientSession() as session: + async with session.post(url, json=body) as response: + return response.status, await response.json() + + +async def _post_raw(port: int, bridge_id: str, data: str) -> int: + url = f"http://127.0.0.1:{port}/collaboration/mattermost/{bridge_id}/callback" + async with aiohttp.ClientSession() as session: + async with session.post( + url, data=data, headers={"Content-Type": "application/json"} + ) as response: + return response.status + + +# ── Binding ────────────────────────────────────────────────────────────────── + + +async def test_nothing_is_listening_until_a_bridge_asks_to_be_served() -> None: + """A deployment where no bridge is called back opens no port at all. + + The listener is constructed for every process because the lifecycle service + owns one; what it must not do is bind on the strength of that. + """ + port = _free_port() + ingress = _ingress(port) + + with pytest.raises(aiohttp.ClientConnectorError): + await _post(port, BRIDGE, {}) + + await ingress.stop() + + +async def test_a_served_bridge_is_reached_and_its_answer_is_the_reply() -> None: + port = _free_port() + ingress = _ingress(port) + seen: list[dict[str, Any]] = [] + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + seen.append(body) + return {"ephemeral_text": "Noted."} + + await ingress.serve("mattermost", BRIDGE, handle) + try: + status, answer = await _post(port, BRIDGE, {"user_id": "u-1"}) + finally: + await ingress.stop() + + assert status == 200 + assert answer == {"ephemeral_text": "Noted."} + assert seen == [{"user_id": "u-1"}] + + +async def test_two_bridges_share_the_one_port() -> None: + """Two Mattermost servers β€” two tenants, or a test one beside a real one β€” + are ordinary, and a listener each would be a port and an ingress rule + each.""" + port = _free_port() + ingress = _ingress(port) + + async def first(body: dict[str, Any]) -> dict[str, Any]: + return {"who": "first"} + + async def second(body: dict[str, Any]) -> dict[str, Any]: + return {"who": "second"} + + await ingress.serve("mattermost", BRIDGE, first) + await ingress.serve("mattermost", OTHER, second) + try: + assert (await _post(port, BRIDGE, {}))[1] == {"who": "first"} + assert (await _post(port, OTHER, {}))[1] == {"who": "second"} + finally: + await ingress.stop() + + +async def test_the_port_closes_when_the_listener_stops() -> None: + port = _free_port() + ingress = _ingress(port) + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + return {} + + await ingress.serve("mattermost", BRIDGE, handle) + await ingress.stop() + + with pytest.raises(aiohttp.ClientConnectorError): + await _post(port, BRIDGE, {}) + + +# ── Routing ────────────────────────────────────────────────────────────────── + + +async def test_a_bridge_that_is_not_running_is_answered_rather_than_refused( + caplog: pytest.LogCaptureFixture, +) -> None: + """A bridge is stopped while the process keeps running, and a press posted + a minute earlier still arrives. Mattermost can report a 404; a connection + refused is something it retries into.""" + port = _free_port() + ingress = _ingress(port) + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + return {} + + await ingress.serve("mattermost", BRIDGE, handle) + try: + with caplog.at_level(logging.WARNING): + status, _ = await _post(port, OTHER, {}) + finally: + await ingress.stop() + + assert status == 404 + assert OTHER in caplog.text + + +async def test_a_withdrawn_bridge_stops_being_reachable_and_its_neighbour_does_not() -> ( + None +): + port = _free_port() + ingress = _ingress(port) + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + return {"who": "still here"} + + await ingress.serve("mattermost", BRIDGE, handle) + await ingress.serve("mattermost", OTHER, handle) + await ingress.withdraw("mattermost", BRIDGE) + try: + assert (await _post(port, BRIDGE, {}))[0] == 404 + assert (await _post(port, OTHER, {}))[0] == 200 + finally: + await ingress.stop() + + +async def test_a_bridge_of_another_type_at_the_same_id_is_not_the_same_bridge() -> None: + """The type is in the path, so a second platform needing callbacks is a + sibling rather than something that has to pick unique ids.""" + port = _free_port() + ingress = _ingress(port) + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + return {} + + await ingress.serve("mattermost", BRIDGE, handle) + try: + status, _ = await _post(port, BRIDGE, {}, bridge_type="somethingelse") + finally: + await ingress.stop() + + assert status == 404 + + +# ── What comes back ────────────────────────────────────────────────────────── + + +async def test_a_handler_that_refuses_says_so_with_its_own_status() -> None: + port = _free_port() + ingress = _ingress(port) + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + raise CallbackRefused("Not a press this bridge will act on.", status=401) + + await ingress.serve("mattermost", BRIDGE, handle) + try: + status, answer = await _post(port, BRIDGE, {}) + finally: + await ingress.stop() + + assert status == 401 + assert answer["error"]["message"] == "Not a press this bridge will act on." + + +async def test_a_refusal_is_not_logged_as_a_fault( + caplog: pytest.LogCaptureFixture, +) -> None: + """Refusing an unauthenticated caller is what an authenticated route does + all day. A stack trace per attempt buries the one that matters.""" + port = _free_port() + ingress = _ingress(port) + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + raise CallbackRefused("No.", status=401) + + await ingress.serve("mattermost", BRIDGE, handle) + try: + with caplog.at_level(logging.ERROR): + await _post(port, BRIDGE, {}) + finally: + await ingress.stop() + + assert caplog.text == "" + + +async def test_a_handler_that_breaks_is_logged_and_does_not_take_the_port_with_it( + caplog: pytest.LogCaptureFixture, +) -> None: + """The listener is shared, so one bridge's bug must not be every bridge's + outage β€” and must not pass silently either.""" + port = _free_port() + ingress = _ingress(port) + attempts: list[int] = [] + + async def broken(body: dict[str, Any]) -> dict[str, Any]: + attempts.append(1) + raise RuntimeError("the store is down") + + async def working(body: dict[str, Any]) -> dict[str, Any]: + return {"who": "fine"} + + await ingress.serve("mattermost", BRIDGE, broken) + await ingress.serve("mattermost", OTHER, working) + try: + with caplog.at_level(logging.ERROR): + status, _ = await _post(port, BRIDGE, {}) + assert (await _post(port, OTHER, {}))[1] == {"who": "fine"} + assert (await _post(port, BRIDGE, {}))[0] == 500 + finally: + await ingress.stop() + + assert status == 500 + assert "the store is down" in caplog.text + assert len(attempts) == 2 + + +async def test_a_body_that_is_not_a_json_object_is_turned_away() -> None: + port = _free_port() + ingress = _ingress(port) + reached: list[Any] = [] + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + reached.append(body) + return {} + + await ingress.serve("mattermost", BRIDGE, handle) + try: + assert await _post_raw(port, BRIDGE, "not json at all") == 400 + assert (await _post(port, BRIDGE, ["a", "list"]))[0] == 400 + finally: + await ingress.stop() + + assert reached == [] + + +# ── The key that comes with the endpoint ───────────────────────────────────── + + +def test_each_bridge_is_given_a_key_of_its_own() -> None: + ingress = _ingress(_free_port()) + + assert ( + ingress.endpoint_for("mattermost", BRIDGE).key + != ingress.endpoint_for("mattermost", OTHER).key + ) + + +def test_a_bridges_key_is_its_own_platforms() -> None: + """Two bridges could share an id across types; their keys must not.""" + ingress = _ingress(_free_port()) + + assert ( + ingress.endpoint_for("mattermost", BRIDGE).key + != ingress.endpoint_for("somethingelse", BRIDGE).key + ) + + +def test_a_key_is_not_the_server_secret() -> None: + """An adapter holds its key for the life of the bridge. What it must never + hold is the value every other derived secret comes from.""" + key = _ingress(_free_port()).endpoint_for("mattermost", BRIDGE).key + + assert key != SECRET + assert SECRET not in key + + +def test_a_rotated_server_secret_gives_a_different_key() -> None: + port = _free_port() + before = _ingress(port).endpoint_for("mattermost", BRIDGE).key + after = ( + CallbackIngress(host="127.0.0.1", port=port, secret="the-next-one") + .endpoint_for("mattermost", BRIDGE) + .key + ) + + assert before != after + + +def test_the_same_secret_gives_the_same_key_across_restarts() -> None: + """The key is derived, not minted, which is the whole reason a bridge + registered before callbacks existed can take one without being edited.""" + port = _free_port() + + assert ( + _ingress(port).endpoint_for("mattermost", BRIDGE).key + == _ingress(port).endpoint_for("mattermost", BRIDGE).key + ) + + +def test_an_endpoint_knows_the_path_its_bridge_is_reached_on() -> None: + endpoint = _ingress(_free_port()).endpoint_for("mattermost", BRIDGE) + + assert endpoint.path == f"/collaboration/mattermost/{BRIDGE}/callback" diff --git a/core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py b/core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py new file mode 100644 index 000000000..4c30937d7 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py @@ -0,0 +1,133 @@ +"""Handing a running bridge its place on the shared callback listener. + +An adapter is built from its connection config and nothing else β€” it is never +told which bridge it is, which is what stops one bridge addressing another's +callbacks. So the endpoint has to be handed to it, by the one thing that knows +both: the service that started it. + +The other half is that the place goes away with the bridge. A press for a +bridge that has been stopped must not be handled by an adapter that is halfway +through shutting down. +""" + +from __future__ import annotations + +import uuid +from typing import Any + +import aiohttp +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from switch_core.bridges.collaboration.ingress import CallbackEndpoint + +from .test_collaboration_ingress import _free_port +from .test_lifecycle_tenant_binding import ( + _make_bridge, + _make_tenant, + _service, + _StubAdapter, + _StubConfig, +) + + +class _CallbackAdapter(_StubAdapter): + """A bridge that asks to be called back, and remembers what it was given.""" + + def __init__(self, *, config: Any) -> None: + super().__init__(config=config) + self.endpoint: CallbackEndpoint | None = None + + def set_callback_endpoint(self, endpoint: CallbackEndpoint) -> None: + self.endpoint = endpoint + + async def start(self, *a: Any, **k: Any) -> Any: + assert self.endpoint is not None + await self.endpoint.serve(self._take) + + async def _take(self, body: dict[str, Any]) -> dict[str, Any]: + return {"heard": body} + + +async def _start_one( + session_factory: async_sessionmaker[AsyncSession], port: int +) -> tuple[Any, str, _CallbackAdapter]: + tenant = f"tenant-{uuid.uuid4().hex[:8]}" + async with session_factory() as session: + await _make_tenant(session, tenant) + bridge_id, _ = await _make_bridge(session, tenant_id=tenant) + await session.commit() + + service = _service(session_factory, callback_port=port) + built: list[_CallbackAdapter] = [] + + class _Recording(_CallbackAdapter): + def __init__(self, *, config: Any) -> None: + super().__init__(config=config) + built.append(self) + + service.register_adapter("mattermost", _Recording, _StubConfig) + + async def _run(bridge_id: str, tenant_id: str, *_: object) -> None: + return None + + service._run_bridge = _run # type: ignore[method-assign] + + await service.start(bridge_id) + # What the bridge's own task would have done, awaited rather than raced: + # the adapter asks for its place as it starts. + await built[0].start() + return service, bridge_id, built[0] + + +async def _post(port: int, bridge_id: str) -> int: + url = f"http://127.0.0.1:{port}/collaboration/mattermost/{bridge_id}/callback" + async with aiohttp.ClientSession() as session: + async with session.post(url, json={}) as response: + return response.status + + +async def test_a_started_bridge_is_given_its_own_place_and_is_reachable_there( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + port = _free_port() + service, bridge_id, adapter = await _start_one(session_factory, port) + + try: + assert adapter.endpoint is not None + assert adapter.endpoint.path == ( + f"/collaboration/mattermost/{bridge_id}/callback" + ) + assert await _post(port, bridge_id) == 200 + finally: + await service.stop_all() + + +async def test_stopping_a_bridge_takes_its_place_with_it( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """A press posted a minute ago still arrives. It must not be handed to an + adapter that is no longer running.""" + port = _free_port() + service, bridge_id, _ = await _start_one(session_factory, port) + + try: + await service.stop(bridge_id) + + assert await _post(port, bridge_id) == 404 + finally: + await service.stop_all() + + +async def test_shutting_everything_down_closes_the_port( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + port = _free_port() + service, bridge_id, _ = await _start_one(session_factory, port) + + await service.stop_all() + + try: + await _post(port, bridge_id) + except aiohttp.ClientConnectorError: + return + raise AssertionError("the listener is still bound after a full shutdown") diff --git a/core/tests/switch_core/bridges/collaboration/test_lifecycle_tenant_binding.py b/core/tests/switch_core/bridges/collaboration/test_lifecycle_tenant_binding.py index 00bd52d91..a33a224cd 100644 --- a/core/tests/switch_core/bridges/collaboration/test_lifecycle_tenant_binding.py +++ b/core/tests/switch_core/bridges/collaboration/test_lifecycle_tenant_binding.py @@ -47,9 +47,14 @@ def _service( session_factory: async_sessionmaker[AsyncSession], + *, + callback_port: int = 0, ) -> CollaborationBridgeLifecycleService: config = MagicMock() config.gateway_public_url = "https://gw.example" + config.collaboration_callback_host = "127.0.0.1" + config.collaboration_callback_port = callback_port + config.jwt_secret_key = "server-secret-for-tests" return CollaborationBridgeLifecycleService( bridge_store=CollaborationBridgeStore(), external_user_store=MagicMock(), diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py index 5d088d032..cef7fe10f 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py @@ -15,10 +15,10 @@ import logging from typing import Any +from switch_core.bridges.collaboration.ingress import CallbackIngress from switch_core.bridges.collaboration.mattermost.callback import ( CONTEXT_KEY, action_context, - callback_key, read_press, ) @@ -33,8 +33,14 @@ CHANNEL_ID = "channel-abc" +def _key_for(bridge_id: str, secret: str = SERVER_SECRET) -> str: + """The key a bridge would really be handed, derived the way production does.""" + ingress = CallbackIngress(host="127.0.0.1", port=0, secret=secret) + return ingress.endpoint_for("mattermost", bridge_id).key + + def _key() -> str: - return callback_key(SERVER_SECRET, BRIDGE_ID) + return _key_for(BRIDGE_ID) def _body(context: dict[str, Any], **overrides: Any) -> dict[str, Any]: @@ -115,14 +121,13 @@ def test_a_token_changed_after_signing_is_refused() -> None: def test_another_bridges_signature_is_refused() -> None: - other = callback_key(SERVER_SECRET, OTHER_BRIDGE_ID) - context = action_context(other, TOKEN, POSITION) + context = action_context(_key_for(OTHER_BRIDGE_ID), TOKEN, POSITION) assert read_press(_key(), _body(context)) is None def test_a_signature_from_a_rotated_secret_says_so(caplog: Any) -> None: - stale = callback_key("the-previous-server-secret", BRIDGE_ID) + stale = _key_for(BRIDGE_ID, secret="the-previous-server-secret") context = action_context(stale, TOKEN, POSITION) with caplog.at_level(logging.WARNING): @@ -132,19 +137,6 @@ def test_a_signature_from_a_rotated_secret_says_so(caplog: Any) -> None: assert TOKEN in caplog.text -def test_each_bridge_signs_with_a_key_of_its_own() -> None: - assert callback_key(SERVER_SECRET, BRIDGE_ID) != callback_key( - SERVER_SECRET, OTHER_BRIDGE_ID - ) - - -def test_the_key_is_not_the_server_secret() -> None: - key = _key() - - assert SERVER_SECRET not in key - assert key != SERVER_SECRET - - def test_a_press_naming_nobody_is_refused() -> None: context = action_context(_key(), TOKEN, POSITION) diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py new file mode 100644 index 000000000..287bd2cb9 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py @@ -0,0 +1,314 @@ +"""A Mattermost button press arriving at Switch over HTTP. + +Every other way Switch hears from Mattermost comes down the websocket the +bridge dials out on. A press does not: the Mattermost server posts it to a URL, +so the bridge has to be reachable, and anything else that can reach the port +can post the same shape. What separates the two is the signature the button +carried, checked before the press is a press at all. + +What the button says is the card and the option. Who pressed it comes from the +body, which the server fills in. These cover that split, the private reply that +is the one thing a callback can say to the presser alone, and the fact that a +bridge with no address to be called back on says so rather than quietly never +working. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import pytest + +from switch_core.bridges.collaboration.ingress import ( + CallbackIngress, + CallbackRefused, +) +from switch_core.bridges.collaboration.mattermost.adapter import ( + MattermostAdapter, + MattermostConnectionConfig, +) +from switch_core.bridges.collaboration.mattermost.callback import action_context +from switch_core.bridges.collaboration.models import InboundInteraction + +from .test_collaboration_ingress import _free_port +from .test_mattermost_sdk_only import _FakeDriver, _FakePosts, _FakeUsers, _posts + +SECRET = "server-secret-for-tests" +BRIDGE = "bridge-1" +OTHER_BRIDGE = "bridge-2" +CALLBACK_BASE = "http://switch.example:8081" + +TOKEN = "tok-1" +POSITION = 2 +USER = "user-abc" +HANDLE = "alice" +POST = "post-abc" +CHANNEL = "chan-1" + + +def _adapter( + *, + callback_base_url: str | None = CALLBACK_BASE, + ingress: CallbackIngress | None = None, + **users: str, +) -> MattermostAdapter: + adapter = MattermostAdapter( + config=MattermostConnectionConfig( + url="http://mm.example", + admin_user="admin", + admin_password="pw", + team_name="team", + callback_base_url=callback_base_url, + ) + ) + posts = _FakePosts() + directory = _FakeUsers(**({USER: HANDLE} | users)) + adapter._agent_bots["worker"] = {"user_id": "bot-worker"} + adapter._bot_drivers["worker"] = _FakeDriver(posts, directory, "worker") # type: ignore[assignment] + adapter._admin_driver = _FakeDriver(posts, directory, "admin") # type: ignore[assignment] + adapter._admin_bot_driver = _FakeDriver(posts, directory, "admin-bot") # type: ignore[assignment] + adapter._admin_bot_id = "bot-admin" + adapter._main_loop = asyncio.get_event_loop() + adapter.set_callback_endpoint( + (ingress or _ingress()).endpoint_for("mattermost", BRIDGE) + ) + return adapter + + +def _ingress() -> CallbackIngress: + return CallbackIngress(host="127.0.0.1", port=_free_port(), secret=SECRET) + + +def _users(adapter: MattermostAdapter) -> _FakeUsers: + driver: Any = adapter._admin_driver + return driver.users + + +def _key(bridge_id: str = BRIDGE) -> str: + return _ingress().endpoint_for("mattermost", bridge_id).key + + +def _body(context: dict[str, Any], **overrides: Any) -> dict[str, Any]: + body: dict[str, Any] = { + "user_id": USER, + "post_id": POST, + "channel_id": CHANNEL, + "team_id": "team-abc", + "context": context, + } + body.update(overrides) + return body + + +def _signed(**overrides: Any) -> dict[str, Any]: + return _body(action_context(_key(), TOKEN, POSITION), **overrides) + + +def _record(adapter: MattermostAdapter) -> list[InboundInteraction]: + seen: list[InboundInteraction] = [] + + async def handle(interaction: InboundInteraction) -> None: + seen.append(interaction) + + adapter.set_interaction_handler(handle) + return seen + + +# ── What a press turns into ────────────────────────────────────────────────── + + +async def test_a_signed_press_arrives_as_the_card_the_option_and_the_presser() -> None: + adapter = _adapter() + seen = _record(adapter) + + answer = await adapter._handle_callback(_signed()) + + assert answer == {} + assert len(seen) == 1 + assert seen[0].value == TOKEN + assert seen[0].action_id.endswith(f":{POSITION}") + assert seen[0].channel_id == CHANNEL + assert seen[0].message_ref == POST + + +async def test_who_pressed_comes_from_the_server_not_from_the_button() -> None: + """The context is confidential but it is still only what Switch put there. + The presser is the one field on a callback that Mattermost asserts.""" + adapter = _adapter() + seen = _record(adapter) + + await adapter._handle_callback(_signed()) + + assert seen[0].sender_id == USER + assert seen[0].sender_name == HANDLE + + +async def test_an_unsigned_press_never_reaches_the_answer_path() -> None: + """The route is reachable by anything that can reach the port. A body of + the right shape is not a press.""" + adapter = _adapter() + seen = _record(adapter) + + with pytest.raises(CallbackRefused) as refused: + await adapter._handle_callback( + _body({"switch": {"token": TOKEN, "position": POSITION}}) + ) + + assert refused.value.status == 401 + assert seen == [] + + +async def test_a_press_signed_for_another_bridge_is_refused() -> None: + """Two bridges can share a listener, and each is given a key of its own so + a press minted for one is not a press for the other.""" + adapter = _adapter() + seen = _record(adapter) + + with pytest.raises(CallbackRefused): + await adapter._handle_callback( + _body(action_context(_key(OTHER_BRIDGE), TOKEN, POSITION)) + ) + + assert seen == [] + + +async def test_a_bridge_that_handles_no_presses_refuses_and_says_so( + caplog: pytest.LogCaptureFixture, +) -> None: + """A signed press with nowhere to go means a card was drawn with buttons by + a bridge that cannot answer them β€” worth a line, not a silent drop.""" + adapter = _adapter() + + with caplog.at_level(logging.WARNING): + with pytest.raises(CallbackRefused): + await adapter._handle_callback(_signed()) + + assert CHANNEL in caplog.text + + +async def test_a_presser_the_server_cannot_name_is_refused_loudly( + caplog: pytest.LogCaptureFixture, +) -> None: + """The id is what authority is judged against, but the handle is what a + participant would be created under. A lookup failure must not become + somebody's permanent name.""" + adapter = _adapter() + seen = _record(adapter) + _users(adapter).error = RuntimeError("the directory is down") + + with caplog.at_level(logging.ERROR): + with pytest.raises(CallbackRefused): + await adapter._handle_callback(_signed()) + + assert seen == [] + assert "cannot resolve" in caplog.text + + +# ── The private reply ──────────────────────────────────────────────────────── + + +async def test_a_refusal_comes_back_to_the_presser_and_not_to_the_channel() -> None: + """`ephemeral_text` is shown to whoever pressed and to nobody else, which + is the only private reply a callback gets.""" + adapter = _adapter() + + async def handle(interaction: InboundInteraction) -> None: + await adapter.tell_actor( + CHANNEL, USER, HANDLE, "root-1", "That request is already answered." + ) + + adapter.set_interaction_handler(handle) + + answer = await adapter._handle_callback(_signed()) + + assert answer == {"ephemeral_text": "That request is already answered."} + assert _posts(adapter).created == [] + + +async def test_a_press_that_lands_says_nothing_at_all() -> None: + """The card's own redraw is what says the answer was taken. A second notice + saying so is a second thing to read.""" + adapter = _adapter() + _record(adapter) + + assert await adapter._handle_callback(_signed()) == {} + assert _posts(adapter).created == [] + + +async def test_a_typed_answer_is_still_refused_in_the_thread() -> None: + """There is no press to reply to, so the notice goes where the base puts + it: the card's own thread, where everyone reading it sees a notice + addressed to somebody else. That is the platform's limit rather than a + choice, and it must not be swallowed by the press path.""" + adapter = _adapter() + said: list[tuple[str, str, str | None]] = [] + + async def admin_message( + channel_id: str, text: str, thread_root_id: str | None = None + ) -> None: + said.append((channel_id, text, thread_root_id)) + + adapter.admin_message = admin_message # type: ignore[method-assign] + + await adapter.tell_actor(CHANNEL, USER, HANDLE, "root-1", "Not your request.") + + assert len(said) == 1 + assert said[0][0] == CHANNEL + assert "Not your request." in said[0][1] + assert said[0][2] == "root-1" + + +# ── Being reachable at all ─────────────────────────────────────────────────── + + +async def test_a_bridge_with_no_callback_address_takes_no_presses_and_says_why( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing fails: cards keep arriving and are answerable by typing. What + must not happen is that they quietly never carry a button.""" + adapter = _adapter(callback_base_url=None) + + with caplog.at_level(logging.WARNING): + await adapter._start_callbacks() + + assert adapter.callback_url is None + assert "callback_base_url" in caplog.text + + +async def test_the_callback_url_is_the_operators_base_and_this_bridges_path() -> None: + adapter = _adapter() + + assert adapter.callback_url == ( + f"{CALLBACK_BASE}/collaboration/mattermost/{BRIDGE}/callback" + ) + + +async def test_a_trailing_slash_on_the_base_does_not_double_up() -> None: + adapter = _adapter(callback_base_url=f"{CALLBACK_BASE}/") + + assert adapter.callback_url == ( + f"{CALLBACK_BASE}/collaboration/mattermost/{BRIDGE}/callback" + ) + + +async def test_starting_says_where_presses_will_be_taken( + caplog: pytest.LogCaptureFixture, +) -> None: + """The one thing an operator has to get right is that the Mattermost server + can reach this address, so the address is in the log rather than only in a + configuration field.""" + ingress = _ingress() + adapter = _adapter(ingress=ingress) + _record(adapter) + + try: + with caplog.at_level(logging.INFO): + await adapter._start_callbacks() + finally: + await ingress.stop() + + url = adapter.callback_url + assert url is not None + assert url in caplog.text From cd7e4846b7018365186898b23d14181067e10470 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 14:31:07 +0100 Subject: [PATCH 077/120] Mattermost: what a deployment needs before a press can arrive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things have to be true for a button press to reach Switch, and none of them is code. The Mattermost server has to have an address for Switch. That is `callback_base_url` on the bridge, and it is a third address unrelated to the two already there: those are routes to Mattermost, this is the route back, and between two servers it is frequently nothing like the route a browser takes. Both local stacks now set it β€” the service name under the standalone compose, where switch-core is a container, and the host under the dev compose, where it is not. Mattermost has to be allowed to use it. It refuses outbound integration requests to private addresses unless the host is allowlisted, and the symptom otherwise is a press that does nothing with an error only in the Mattermost server log. Both bundled Mattermosts now allow the address the seeder gives them. And an operator has to know the rest: that the listener is a port of its own and not public ingress, that TLS is a proxy's job as it is for Teams, and that rotating the server secret invalidates the buttons on cards already posted while leaving those cards answerable by typing. The seeder skips a bridge that is already registered, so an existing stack keeps working without buttons until `callback_base_url` is added by hand. The Helm chart does not publish the port yet; the setup page says so rather than implying it works there. Co-Authored-By: Claude Opus 5 --- .../standalone-docker-compose.pinned.yml | 9 +++ deploy/local/docker-compose.yml | 9 +++ deploy/local/standalone-docker-compose.yml | 9 +++ deploy/shared_resources/setup.py | 7 ++ docs/old/LOCAL_DEVELOPMENT.md | 1 + docs/old/bridges/MATTERMOST_SETUP.md | 76 ++++++++++++++++++- docs/old/bridges/README.md | 10 +++ 7 files changed, 120 insertions(+), 1 deletion(-) diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/resources/standalone-docker-compose.pinned.yml b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/resources/standalone-docker-compose.pinned.yml index fbd3e6562..86e2d807e 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/resources/standalone-docker-compose.pinned.yml +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/resources/standalone-docker-compose.pinned.yml @@ -184,6 +184,12 @@ services: MM_DISPLAYSETTINGS_CUSTOMURLSCHEMES: switchdash MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION: "true" MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS: "true" + # Mattermost refuses to call a private address from an integration unless + # the host is named here, and a button on a Switch card is delivered by + # exactly that path β€” a POST from this container to switch-core's callback + # port on the compose network. Without it a press fails inside Mattermost + # and the person sees nothing. + MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: switch MM_TEAMSETTINGS_ENABLEOPENSERVER: "true" MM_TEAMSETTINGS_TEAMMATENAMEDISPLAY: "full_name" # Neither plugin is usable in a bundled Switch deployment: Copilot has no @@ -244,6 +250,9 @@ services: SWITCH_URL: http://switch:8000 MATTERMOST_URL: http://mattermost:8065 MATTERMOST_URL_FOR_SWITCH: http://mattermost:8065 + # The route back: where the Mattermost container reaches switch-core's + # callback port, for the button presses Mattermost delivers by HTTP. + MATTERMOST_CALLBACK_BASE_URL: http://switch:8081 MATTERMOST_ADMIN_USER: ${MATTERMOST_ADMIN_USER} MATTERMOST_ADMIN_PASSWORD: ${MATTERMOST_ADMIN_PASSWORD} MATTERMOST_TEAM_NAME: ${MATTERMOST_TEAM_NAME} diff --git a/deploy/local/docker-compose.yml b/deploy/local/docker-compose.yml index 215a88ae7..218f3e287 100644 --- a/deploy/local/docker-compose.yml +++ b/deploy/local/docker-compose.yml @@ -70,6 +70,11 @@ services: MM_SERVICESETTINGS_ENABLELOCALMODE: "true" MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION: "true" MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS: "true" + # Mattermost refuses to call a private address from an integration unless + # the host is named here, and a button on a Switch card is delivered by + # exactly that path. switch-core runs on the host in this compose, not as + # a service, so what Mattermost has to be allowed to reach is the host. + MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: host.docker.internal MM_TEAMSETTINGS_ENABLEOPENSERVER: "true" MM_TEAMSETTINGS_TEAMMATENAMEDISPLAY: "full_name" # Both of these exist for Switch Console's embedded room view (CHOO-1674): @@ -92,6 +97,10 @@ services: SWITCH_URL: http://host.docker.internal:8000 MATTERMOST_URL: http://mattermost:8065 MATTERMOST_URL_FOR_SWITCH: http://localhost:8065 + # The route back: where the Mattermost container reaches switch-core's + # callback port, for the button presses Mattermost delivers by HTTP. + # switch-core runs on the host in this compose, not as a service. + MATTERMOST_CALLBACK_BASE_URL: http://host.docker.internal:8081 MATTERMOST_ADMIN_USER: ${MATTERMOST_ADMIN_USER} MATTERMOST_ADMIN_PASSWORD: ${MATTERMOST_ADMIN_PASSWORD} MATTERMOST_TEAM_NAME: ${MATTERMOST_TEAM_NAME} diff --git a/deploy/local/standalone-docker-compose.yml b/deploy/local/standalone-docker-compose.yml index 4cb439960..d107f19ce 100644 --- a/deploy/local/standalone-docker-compose.yml +++ b/deploy/local/standalone-docker-compose.yml @@ -177,6 +177,12 @@ services: MM_DISPLAYSETTINGS_CUSTOMURLSCHEMES: switchdash MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION: "true" MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS: "true" + # Mattermost refuses to call a private address from an integration unless + # the host is named here, and a button on a Switch card is delivered by + # exactly that path β€” a POST from this container to switch-core's callback + # port on the compose network. Without it a press fails inside Mattermost + # and the person sees nothing. + MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: switch MM_TEAMSETTINGS_ENABLEOPENSERVER: "true" MM_TEAMSETTINGS_TEAMMATENAMEDISPLAY: "full_name" # Neither plugin is usable in a bundled Switch deployment: Copilot has no @@ -237,6 +243,9 @@ services: SWITCH_URL: http://switch:8000 MATTERMOST_URL: http://mattermost:8065 MATTERMOST_URL_FOR_SWITCH: http://mattermost:8065 + # The route back: where the Mattermost container reaches switch-core's + # callback port, for the button presses Mattermost delivers by HTTP. + MATTERMOST_CALLBACK_BASE_URL: http://switch:8081 MATTERMOST_ADMIN_USER: ${MATTERMOST_ADMIN_USER} MATTERMOST_ADMIN_PASSWORD: ${MATTERMOST_ADMIN_PASSWORD} MATTERMOST_TEAM_NAME: ${MATTERMOST_TEAM_NAME} diff --git a/deploy/shared_resources/setup.py b/deploy/shared_resources/setup.py index 9dc0a3958..57c1223ac 100644 --- a/deploy/shared_resources/setup.py +++ b/deploy/shared_resources/setup.py @@ -25,6 +25,11 @@ # channel deeplinks. Differs from MATTERMOST_URL_FOR_SWITCH, which is the # internal address Switch connects to. Optional β€” falls back to the internal URL. MATTERMOST_PUBLIC_URL = os.environ.get("MATTERMOST_PUBLIC_URL") +# Where the Mattermost *server* reaches switch-core's callback listener, for the +# button presses it delivers by HTTP. A third address, unrelated to the two +# above: those are routes to Mattermost, this is the route back. Optional β€” +# without it cards carry no buttons and stay answerable by typing. +MATTERMOST_CALLBACK_BASE_URL = os.environ.get("MATTERMOST_CALLBACK_BASE_URL") MATTERMOST_ADMIN_USER = os.environ["MATTERMOST_ADMIN_USER"] MATTERMOST_ADMIN_PASSWORD = os.environ["MATTERMOST_ADMIN_PASSWORD"] MATTERMOST_ADMIN_EMAIL = os.environ.get( @@ -254,6 +259,8 @@ def register_bridge(client: httpx.Client) -> str: } if MATTERMOST_PUBLIC_URL: connection_config["public_url"] = MATTERMOST_PUBLIC_URL + if MATTERMOST_CALLBACK_BASE_URL: + connection_config["callback_base_url"] = MATTERMOST_CALLBACK_BASE_URL resp = client.post( "/gateway/collaborations", json={ diff --git a/docs/old/LOCAL_DEVELOPMENT.md b/docs/old/LOCAL_DEVELOPMENT.md index 1f8c62092..13c1aac4a 100644 --- a/docs/old/LOCAL_DEVELOPMENT.md +++ b/docs/old/LOCAL_DEVELOPMENT.md @@ -126,6 +126,7 @@ any CORS configuration: as far as the browser can tell, it never left | --- | --- | --- | | `http://localhost:5173` | The operator dashboard (Vite dev server). Open this in a browser. | `just gateway-dev` | | `http://localhost:8000` | switch-core: the Agent Bridge API, the MCP server, and the gateway management API under `/gateway/*`. JSON, not a UI. | `just run` | +| `http://localhost:8081` | switch-core's collaboration callback listener β€” where Mattermost delivers a button press. Bound only once a bridge asks to be called back, so it is often not there at all. | `just run` | | `http://localhost:8065` | Mattermost, seeded as the local collaboration bridge. | `just up` | | `http://localhost:5432` | PostgreSQL. | `just up` | diff --git a/docs/old/bridges/MATTERMOST_SETUP.md b/docs/old/bridges/MATTERMOST_SETUP.md index 3518bc3cb..2de791135 100644 --- a/docs/old/bridges/MATTERMOST_SETUP.md +++ b/docs/old/bridges/MATTERMOST_SETUP.md @@ -4,7 +4,10 @@ Connects a Mattermost server to Switch. Unlike the single-bot platforms, Mattermost uses **one bot account per Switch agent**, created and driven through an **admin account** you supply. Inbound messages arrive over Mattermost's WebSocket (an outbound connection from Switch), so **no public ingress is -required**. +required**. One thing does travel the other way β€” a press on a button Switch +posted, which the Mattermost server delivers over HTTP. That needs the +Mattermost server to reach switch-core, not the internet; see +[step 3](#3-optional-let-mattermost-deliver-button-presses). ## Prerequisites @@ -46,10 +49,73 @@ Fields (`MattermostConnectionConfig`): | `admin_password` | yes | Admin password. | | `team_name` | yes | Team slug that bridged channels are created under. | | `public_url` | no | User-facing base URL when it differs from `url`; used for channel deeplinks so they open in the user's client. Falls back to `url`. | +| `callback_base_url` | no | Base URL (scheme + host + port, no path) the **Mattermost server** reaches switch-core's callback listener on. Unset means cards carry no buttons. See [step 3](#3-optional-let-mattermost-deliver-button-presses). | On success the bridge logs in as the admin, resolves the team, and starts its WebSocket. Per-agent bot accounts are created as agents are used. +## 3. (Optional) Let Mattermost deliver button presses + +Everything else Switch hears from Mattermost comes down the WebSocket the bridge +dialled out on. A **button press does not**: the Mattermost server POSTs it to a +URL, so switch-core has to be reachable *from the Mattermost server*. Skip this +section and nothing breaks β€” cards arrive without buttons and stay answerable by +typing a reply, and the bridge logs one warning saying so at startup. + +**The listener.** switch-core takes callbacks on a socket of its own, separate +from the agent API on `SERVER_PORT` (8000), which also carries the MCP server and +the operator dashboard. What you expose for a button should be callbacks and +nothing else. + +| Variable | Default | Description | +| --- | --- | --- | +| `COLLABORATION_CALLBACK_HOST` | `0.0.0.0` | Bind address. | +| `COLLABORATION_CALLBACK_PORT` | `8081` | Bind port. | + +Nothing binds until a bridge asks to be served, so a deployment with no +`callback_base_url` anywhere opens no port at all. The listener is shared: every +bridge that needs callbacks is routed by type and id below one port, so a second +Mattermost server is not a second port and not a second firewall rule. + +**Reachability.** Set `callback_base_url` on the bridge to whatever address the +Mattermost server itself can use. This is frequently nothing like the URL a +browser uses β€” a service name on a container network, or an internal hostname: + +``` +http://switch:8081 # container network, service name +http://host.docker.internal:8081 # Mattermost in Docker, switch-core on the host +https://switch-callbacks.example.invalid # behind a reverse proxy +``` + +**Mattermost must be allowed to call it.** Mattermost refuses outbound +integration requests to private addresses unless the host is listed in System +Console β†’ Environment β†’ Developer β†’ *Allow untrusted internal connections to* +(`ServiceSettings.AllowedUntrustedInternalConnections`, space-separated hosts). +Add the host from `callback_base_url`. Switch's own compose stacks set it +already; a server you bring yourself does not, and the symptom is a press that +silently does nothing with an error only in the Mattermost server log. + +**TLS** is a proxy's job, as it is for Teams: the listener speaks plain HTTP. If +the hop between the two servers leaves a network you trust, terminate TLS in +front of it and point `callback_base_url` at the proxy. + +**The credential.** Each button carries a signature inside the action's +`context`, which Mattermost keeps server-side and never sends to the browser. The +signing key is **derived** from `JWT_SECRET_KEY` and the bridge's own identity β€” +it is not stored anywhere, so there is nothing extra to configure, back up, or +keep in step, and a bridge registered before any of this existed needs no edit. +Two consequences: + +- A signature minted for one bridge is not valid for another, so a leaked + `context` is worth one button on one card. +- **Rotating `JWT_SECRET_KEY` invalidates the buttons on cards already posted.** + Those presses are refused and logged; the requests behind them stay answerable + by typing. New cards work immediately. + +**Kubernetes.** The Helm chart does not publish the callback port yet, so a +chart deployment needs the Service port and route added by hand for now; the +`switchCore.teamsBridge` block in `values.yaml` is the shape it will take. + ## Local development The local stack (`just up` / `just standalone-up`) runs a **Mattermost server in @@ -71,6 +137,14 @@ The seeder creates the admin user + team, then registers the bridge with don't onboard Mattermost by hand β€” it's already there after setup. Log in at `http://localhost:8065` with the `MATTERMOST_USER` credentials to try it. +Both stacks also wire up button presses ([step 3](#3-optional-let-mattermost-deliver-button-presses)): +the compose file allows Mattermost to call the private address, and the seeder +sets `callback_base_url` β€” `http://switch:8081` under `standalone-up`, where +switch-core is a service, and `http://host.docker.internal:8081` under `just up`, +where it runs on your host. The seeder skips a bridge that is **already** +registered, so a stack created before this existed keeps working without buttons +until you add `callback_base_url` to the bridge in the operator dashboard. + ## Notes - **Identity.** Each agent gets its own Mattermost bot account, so agent messages diff --git a/docs/old/bridges/README.md b/docs/old/bridges/README.md index a6767d7b0..4b6931c67 100644 --- a/docs/old/bridges/README.md +++ b/docs/old/bridges/README.md @@ -135,6 +135,16 @@ are deployment-level environment config on switch-core: where people *read* the message, not from the Switch host β€” a loopback origin builds links that work only on the machine running Switch, so each bridge warns at startup when it finds one. +- **`COLLABORATION_CALLBACK_HOST` / `COLLABORATION_CALLBACK_PORT`** + (`0.0.0.0:8081`) β€” where collaboration bridges take platform callbacks, on a + socket of its own rather than a route on the API port. Only **Mattermost** + uses it today, and only for button presses: the Mattermost server delivers + those by HTTP where every other platform Switch bridges to carries them down a + connection Switch already holds open. It is not public ingress β€” it has to be + reachable from the Mattermost server, which is usually an address on an + internal network. Nothing binds until a bridge asks, so a deployment with no + `callback_base_url` on any bridge opens no port. See + [`MATTERMOST_SETUP.md`](MATTERMOST_SETUP.md#3-optional-let-mattermost-deliver-button-presses). - **Teams** additionally needs public HTTPS ingress to the bridge's listener, on its own port β€” it is the only bridge Switch does not reach outbound. See [`TEAMS_SETUP.md`](TEAMS_SETUP.md) for the bridge side, and the Helm chart's From 3624db14ce2918af58c20df03385e1a38b5c0a17 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 14:35:13 +0100 Subject: [PATCH 078/120] Slack: stream a turn's activity into one message An edit replaces a message's whole blocks array, so the client redraws the plan block from scratch and closes any step the reader had expanded. That is why the clock lived in a message of its own: at a redraw every five seconds, nothing stayed open long enough to read. chat.appendStream replaces nothing. A task_update carrying an id Slack already holds merges into that card, and a plan_update moves the header without touching the cards, so the two messages become one, the clock ticks in its header, and an expanded step stays expanded. Verified against the live API before it was built: in-place card mutation, and the undocumented plain-string `details` a chunk requires where the task_card block wants rich_text. Threads with no asker, no workspace id or no thread at all fall back to an ordinary post, with a warning naming the missing piece. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 16 +- .../collaboration/session/renderers/slack.py | 257 +++++++- .../bridges/collaboration/slack/adapter.py | 373 +++++++++++- .../bridges/collaboration/slack_fakes.py | 26 + .../collaboration/test_session_activity.py | 29 +- .../test_session_compact_presentation.py | 41 +- .../test_session_slack_streaming.py | 552 ++++++++++++++++++ .../test_session_turn_messages.py | 48 +- 8 files changed, 1270 insertions(+), 72 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 889c7f1b7..53bdbba58 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -308,15 +308,19 @@ def _status_state( """What the status message is already showing. Two publishes with the same answer would draw the same message, and - the second is an edit nobody would see. The clock counts only where - the platform redraws for it; elsewhere what the status shows is the - turn's state and its tools, and the elapsed time goes out with the - next change to either. + the second is an edit nobody would see. The turn's state and its tools + always count, because the status line names the tool it is running + even where the log is a message of its own. The clock counts only + where the platform redraws for it; elsewhere the elapsed time goes out + with the next change to either. """ - drawn = ( + clock = ( f"{int(elapsed_seconds) if elapsed_seconds is not None else ''}" if self._timer_redraws - else ",".join(f"{item.item_id}:{item.revision}" for item in items) + else "" + ) + drawn = f"{clock}/" + ",".join( + f"{item.item_id}:{item.revision}" for item in items ) return (turn.turn_id, f"{turn.status}:{drawn}", session_url) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index ce600e609..be139d64f 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -27,6 +27,7 @@ from __future__ import annotations import hashlib +import json import re from dataclasses import dataclass from html import unescape @@ -138,6 +139,15 @@ _MAX_TASK_ID = 64 +# Slack measures a `task_update` or `plan_update` chunk serialised and rejects +# the whole append over 256 characters, taking the other chunks in it with it. +# The budget is spent title first: a card whose detail was cut still says what +# it did, where one whose title was cut says nothing. +_MAX_CHUNK = 256 +# Below this a trimmed detail is an ellipsis with a word in front of it, which +# takes room from the title to say nothing. Drop it instead. +_MIN_CHUNK_DETAILS = 12 + # Slack's three task states against the contract's four. `declined` is not an # error β€” the call did what it was told, and what it was told was no β€” but # Slack has nowhere else to put it, so the status says only that the step is @@ -968,23 +978,10 @@ def render_activity( hidden = max(0, count - _MAX_PLAN_TASKS) if hidden: title += f" Β· {hidden} earlier not shown" - if did and turn.status not in TURN_ENDED: - current = next( - (item for item in reversed(did) if item.status == "in-progress"), None - ) - label = "Running" if current else "Last" - tool = current or did[-1] - title += f" Β· {label}: {plain_text(tool.title) if tool.title else 'Tool'}" + title += _running_step(did, turn) plan["title"] = _fit(title, _MAX_PLAN_TITLE) for task, item in zip(plan["tasks"], did[-_MAX_PLAN_TASKS:]): - # A historical call can finish unsuccessfully. Preserve its warning - # in the title without making the whole log look like a failed plan. - if item.status != "in-progress" or turn.status in TURN_ENDED: - task["status"] = "complete" - if item.status == "in-progress" and turn.status in TURN_ENDED: - task["title"] = _fit( - "Unfinished: " + task["title"], _MAX_PLAN_TASK_TITLE - ) + _settled(task, item, turn) return SlackMessage( text=plan["title"] + "\n" + "\n".join(_activity_lines(did)), blocks=[plan] ) @@ -1118,6 +1115,236 @@ def _activity_lines(items: list[Item]) -> list[str]: return lines +def render_activity_plan( + items: list[Item], + turn: TurnUpsert, + *, + elapsed_seconds: float | None = None, + session_url: str | None = None, +) -> SlackMessage: + """A turn's activity as one plan block: the header, and the steps behind it. + + What a stream draws, drawn as an ordinary message instead, for a thread a + stream could not be opened in. The two have to agree β€” a reader should not + be able to tell which transport carried their turn β€” so both take their + header from `_activity_title` and settle their cards the same way. + + This is deliberately the plan alone. The agent's own words are posted to + the room as messages in their own right, and repeating them inside the + activity block would say everything twice. + + A turn that has run no tools yet has no plan to show, so it falls back to + the spinning card the status line used to be β€” this one message stands in + for both of the two it replaced. + """ + did = [item for item in items if item.kind == "tool-activity"] + kept = did[len(did) - _MAX_PLAN_TASKS :] + title = _activity_title( + items, turn, elapsed_seconds=elapsed_seconds, omitted=len(did) - len(kept) + ) + blocks: list[dict[str, Any]] = [] + if kept: + blocks.append( + { + "type": "plan", + "title": _fit(title, _MAX_PLAN_TITLE), + "tasks": [_settled(_plan_task(item), item, turn) for item in kept], + } + ) + elif turn.status not in TURN_ENDED: + blocks.append( + { + "type": "task_card", + "task_id": _task_id(turn.turn_id), + "title": _fit(title, _MAX_PLAN_TASK_TITLE), + "status": "in_progress", + } + ) + else: + blocks.append(_context(title)) + if session_url and urlsplit(session_url).scheme in {"https", "http", "switchdash"}: + blocks.append(_context(f"<{session_url}|Open in Console app>")) + return SlackMessage(text=title, blocks=blocks) + + +@dataclass +class StreamedActivity: + """A turn's activity as the pieces a stream is built from. + + The whole turn every time, not a delta: which of these Slack has already + been told is the streaming adapter's bookkeeping, because only it knows + what its own appends landed. Keeping that out of here leaves this a pure + function of the turn, testable without a stream. + """ + + title: str + tasks: list[dict[str, Any]] + + +def render_activity_stream( + items: list[Item], + turn: TurnUpsert, + *, + elapsed_seconds: float | None = None, + omitted: int = 0, +) -> StreamedActivity: + """The same turn as `render_activity`, shaped for `chat.appendStream`. + + A streamed plan and a posted one draw the same thing and are built + differently. A posted plan is one block replaced whole, so it can show a + window onto the newest steps and drop the rest. A stream only ever adds: + a card that has been appended stays, and there is no call that removes it. + So the cap here is on cards ever *created*, `omitted` is what the caller + could not create once it hit that cap, and the header says so β€” the same + disclosure the block form makes about its window, for the opposite reason. + + `details` is a plain string, where the `task_card` block wants a rich_text + entity. Measured against the live API, which rejects every rich_text shape + on a chunk; Slack stores what it is given here as rich_text anyway, so the + two paths render identically despite taking different input. + """ + did = [item for item in items if item.kind == "tool-activity"] + return StreamedActivity( + title=_fit( + _activity_title( + items, turn, elapsed_seconds=elapsed_seconds, omitted=omitted + ), + _MAX_PLAN_TITLE, + ), + tasks=[_stream_task(item, turn) for item in did], + ) + + +def _stream_task(item: Item, turn: TurnUpsert) -> dict[str, Any]: + """One tool call as a streaming chunk. + + The card the same item draws in a plan block, with the two differences the + chunk form insists on: `id` rather than `task_id`, and a plain-string + detail where the block wants a rich_text entity. Trimmed to what an append + will carry β€” see `_within_chunk`. + """ + card = _settled(_plan_task(item), item, turn) + chunk: dict[str, Any] = { + "type": "task_update", + "id": card["task_id"], + "title": card["title"], + "status": card["status"], + } + details = plain_text(item.text) if item.text else "" + if details: + chunk["details"] = _fit(details, _MAX_PLAN_TASK_DETAILS) + return _within_chunk(chunk) + + +def _settled(task: dict[str, Any], item: Item, turn: TurnUpsert) -> dict[str, Any]: + """A card's status as the log shows it, rather than as the item stands. + + A historical call that finished unsuccessfully keeps its warning in the + title but not in its status, because one failed step does not make the + whole plan a failed plan. A call still open when the turn stopped will + never close, so the title says so rather than leaving a step spinning for + good. + """ + if item.status != "in-progress" or turn.status in TURN_ENDED: + task["status"] = "complete" + if item.status == "in-progress" and turn.status in TURN_ENDED: + task["title"] = _fit("Unfinished: " + task["title"], _MAX_PLAN_TASK_TITLE) + return task + + +def _within_chunk(chunk: dict[str, Any]) -> dict[str, Any]: + """Trim a chunk to the 256 characters an append will accept. + + Detail goes before title, and a detail with nothing useful left goes + entirely rather than becoming a lone ellipsis. The title is cut last and + never dropped: a card has to say what it is. + + Each cut is measured again rather than worked out from the overshoot, + because the budget is spent on the serialised form and a character does + not cost one there β€” the ellipsis a trim adds is six. + """ + for field in ("details", "title"): + while (over := _chunk_length(chunk) - _MAX_CHUNK) > 0 and chunk.get(field): + keep = len(chunk[field]) - over + if field == "details" and keep < _MIN_CHUNK_DETAILS: + del chunk["details"] + break + if keep < 1: + break + chunk[field] = _shorten(chunk[field], keep) + return chunk + + +def _shorten(text: str, limit: int) -> str: + """Cut text that has already been escaped, without splitting an entity. + + `_fit` cuts the source and escapes the result, which is the right way round + and not available here: by this point the value has been escaped and the + budget being spent is on the escaped form. Slicing it blind can leave `&am` + in front of the reader, so a cut that landed inside an entity backs up to + where it started. + """ + if len(text) <= limit: + return text + cut = _truncate(text, limit)[:-1] + opened = cut.rfind("&") + if opened != -1 and ";" not in cut[opened:]: + cut = cut[:opened] + return f"{cut}…" + + +def _chunk_length(chunk: dict[str, Any]) -> int: + """How long Slack will consider this chunk: the serialised form. + + Measured the way the SDK puts it on the wire, which escapes every + non-ASCII character to `\\uXXXX`. Whether Slack counts the wire form or the + decoded string is not documented, so this counts the longer of the two: + trimming a title further than it needed is a cosmetic loss, while + undershooting has Slack reject the append and every other card in it. + """ + return len(json.dumps(chunk, separators=(",", ":"))) + + +def _activity_title( + items: list[Item], + turn: TurnUpsert, + *, + elapsed_seconds: float | None = None, + omitted: int = 0, +) -> str: + """The one line a reader gets with the plan collapsed. + + This message replaced a pair β€” a status line that ticked, and a tool log + that did not β€” so its header has to carry both jobs: where the turn got to + and how long it has been there, then the step it is on. A running turn + names that step, because "Working… 40s" alone does not say whether + anything is happening. An ended one does not: `turn_state` already counts + what it did, and the last step it ran is no longer news. + + What is not being shown is said here for the same reason β€” the header is + the only part of a collapsed block, so a cut nobody is told about is a cut + nobody can see. + """ + title = turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds) + title += _running_step([i for i in items if i.kind == "tool-activity"], turn) + if omitted: + step = "step" if omitted == 1 else "steps" + title += f" Β· {omitted} earlier {step} not shown" + return title + + +def _running_step(did: list[Item], turn: TurnUpsert) -> str: + """The step a live turn is on, as a suffix, or nothing for an ended one.""" + if not did or turn.status in TURN_ENDED: + return "" + current = next( + (item for item in reversed(did) if item.status == "in-progress"), None + ) + tool = current or did[-1] + label = "Running" if current else "Last" + return f" Β· {label}: {plain_text(tool.title) if tool.title else 'Tool'}" + + def _plan( items: list[Item], did: list[Item], diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index f68e95fe3..62e6d3821 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -8,9 +8,9 @@ import uuid from collections import OrderedDict from collections.abc import Awaitable, Callable -from dataclasses import replace +from dataclasses import dataclass, field, replace from datetime import datetime -from typing import Any, ClassVar +from typing import Any, ClassVar, NoReturn import httpx from pydantic import BaseModel, Field @@ -46,7 +46,10 @@ ) from switch_core.bridges.collaboration.session.renderers.slack import ( SlackMessage, + StreamedActivity, render_activity, + render_activity_plan, + render_activity_stream, render_attention, render_request, render_turn_with_request, @@ -57,6 +60,7 @@ ) from switch_core.bridges.collaboration.slack.avatar import on_slack_background from switch_core.bridges.collaboration.slack.mrkdwn import escape_mrkdwn +from switch_core.sessions.contract import TURN_ENDED logger = logging.getLogger(__name__) @@ -165,10 +169,66 @@ class SlackConnectionConfig(BridgeConnectionConfig): ) +# A stream cannot take a card back, so this caps cards ever created rather +# than cards shown. Slack draws at most 50 tasks in a plan and drops the rest. +_MAX_STREAM_TASKS = 50 + +# A turn whose end never arrives β€” the agent died, the session was dropped β€” +# leaves its stream open with nothing to close it. Far more than this many at +# once is a bridge holding turns nobody is waiting on, so the oldest goes. +_MAX_OPEN_STREAMS = 100 + +# Slack has stopped taking appends for this message and always will have: the +# stream was closed, the reader stopped it, or it belongs to another app. The +# message itself is still there, so the turn is redrawn as an ordinary post. +_STREAM_CLOSED_ERRORS = frozenset( + { + "message_not_in_streaming_state", + "message_not_owned_by_app", + "stopped_by_user", + "message_not_found", + } +) + + +@dataclass +class _ActivityStream: + """An open `chat.startStream` message, and what it has been told so far. + + A stream is a conversation, not a document: Slack keeps the message and + each append moves part of it. So the adapter has to remember what it last + said to work out what is worth saying next β€” sending a card that has not + changed costs an append and risks nothing useful. + + `created` counts cards ever opened rather than cards currently interesting, + because a stream cannot take one back. Once it reaches the cap, later tool + calls are counted into `omitted` and disclosed in the header instead. + """ + + channel_id: str + ts: str + title: str = "" + cards: dict[str, dict[str, Any]] = field(default_factory=dict) + omitted: int = 0 + linked: bool = False + + @property + def created(self) -> int: + return len(self.cards) + + class SlackAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True - separate_activity_log: ClassVar[bool] = True + #: One message per turn: the plan, with the turn's state and clock as its + #: header. The status used to be a second message purely so the clock could + #: advance without rebuilding the log and collapsing a plan the reader had + #: open β€” a `plan_update` chunk moves the header without touching the cards, + #: so the split has nothing left to buy. + separate_activity_log: ClassVar[bool] = False separate_attention_slot: ClassVar[bool] = True + #: Cheap on a stream in a way it never was on an edit. An append is rate + #: limited at 100+/min and redraws nothing, so the clock ticks in the one + #: message without disturbing what is open in front of a reader. redraws_for_elapsed_time: ClassVar[bool] = True supports_activity_reactions: ClassVar[bool] = True supports_queue_reaction: ClassVar[bool] = True @@ -210,6 +270,15 @@ def __init__(self, *, config: SlackConnectionConfig) -> None: # message per channel β€” used to thread the "thinking" indicator into the # conversation the agent is responding to. self._last_thread_ts: dict[str, str] = {} + # Who last spoke in a thread, by (channel, thread root). A stream has + # to name the person it is being streamed to, and the person who asked + # is the one waiting on the answer. A thread nobody has touched in the + # last few hundred is one no turn is about to be published into. + self._thread_requester: OrderedDict[tuple[str, str], str] = OrderedDict() + self._thread_requester_max = 500 + # Open activity streams by message ref, holding what has already been + # appended so a redraw can send only what changed. + self._streams: OrderedDict[str, _ActivityStream] = OrderedDict() # Folded Slack username β†’ user id, for resolving outbound @mentions to # real Slack mentions. Primed from the bridge's known external users and # topped up as new ones are resolved. @@ -521,8 +590,20 @@ async def post_rich( content: RichContent, thread_root_id: str | None = None, ) -> str: - """Post `content` as a Block Kit message: the activity block for a - turn, or the request card, whichever `content` is.""" + """Post `content`: the activity for a turn, or the request card. + + A turn's activity is streamed where Slack will take a stream, because + an append moves one card and leaves the rest of the message β€” and the + reader's expanded plan β€” alone. Everything else, and every thread a + stream cannot be opened in, is an ordinary Block Kit post. + """ + if self._streamable(content, thread_root_id): + assert isinstance(content, TurnActivity) and thread_root_id + ref = await self._open_stream( + channel_id, agent_name, content, thread_root_id + ) + if ref is not None: + return ref message = self._render_rich(content) ref = await self.post_blocks( channel_id, agent_name, message.text, message.blocks, thread_root_id @@ -556,6 +637,10 @@ async def update_rich( raise RichContentThrottled( retry_after=remaining, text="Waiting for Slack to allow updates." ) + stream = self._streams.get(message_ref) + if stream is not None and isinstance(content, TurnActivity): + await self._extend_stream(stream, message_ref, content) + return responder_name = None if isinstance(content, RequestCard) and content.responder_external_id: user = await self._resolve_user_name(content.responder_external_id) @@ -573,14 +658,7 @@ async def update_rich( error.response.get("error") == "ratelimited" or getattr(error.response, "status_code", None) == 429 ): - headers = getattr(error.response, "headers", {}) or {} - try: - delay = float( - headers.get("Retry-After", headers.get("retry-after", 30)) - ) - delay = max(1.0, delay) if math.isfinite(delay) else 30.0 - except (ValueError, TypeError): - delay = 30.0 + delay = self._retry_after(error) self._rich_update_after = time.monotonic() + delay raise RichContentThrottled( retry_after=delay, text=message.text @@ -590,6 +668,267 @@ async def update_rich( text=message.text, ) from error + @staticmethod + def _retry_after(error: SlackApiError) -> float: + """How long Slack asked to be left alone for, in seconds.""" + headers = getattr(error.response, "headers", {}) or {} + try: + delay = float(headers.get("Retry-After", headers.get("retry-after", 30))) + except (ValueError, TypeError): + return 30.0 + return max(1.0, delay) if math.isfinite(delay) else 30.0 + + def _streamable(self, content: RichContent, thread_root_id: str | None) -> bool: + """Whether this is a turn's activity, in a thread a stream can open in. + + A stream needs a thread to live in, a person to be addressed to and the + workspace's own id, and Slack allows one per thread. An attention post + and a request card are separate messages by design and stay ordinary + posts β€” only the activity, which is the thing that redraws, streams. + + Missing any of those is a degraded draw rather than a broken one: the + turn still appears, and its plan still collapses on every redraw. That + is worth a line in the log, so the second group of checks says which + piece was absent where the first only says this was never a stream. + """ + if not isinstance(content, TurnActivity): + return False + if content.status_only or content.error_summary or content.tool_log: + return False + missing = ( + "no thread to open it in" + if thread_root_id is None + else "no Slack client" + if self._web_client is None + else "the workspace id is unknown" + if not self._team_id + else "nobody in the thread to address it to" + if not self._requester_for(thread_root_id) + else None + ) + if missing is None: + return True + logger.warning( + "Not streaming the activity for turn %s (%s); drawing it as an " + "ordinary message, which collapses an expanded plan on each redraw.", + content.turn.turn_id, + missing, + ) + return False + + def _note_requester( + self, + channel_id: str, + message_ts: str, + thread_ts: str | None, + user_id: str | None, + bot_id: str | None, + ) -> None: + """Remember who spoke in a thread, so a stream there has an addressee. + + A bot's own post is not somebody waiting on an answer, so it does not + displace the person who asked. + """ + if not user_id or bot_id: + return + key = (channel_id, thread_ts or message_ts) + self._thread_requester[key] = user_id + self._thread_requester.move_to_end(key) + if len(self._thread_requester) > self._thread_requester_max: + self._thread_requester.popitem(last=False) + + def _requester_for(self, thread_root_id: str | None) -> str | None: + """The person a stream in this thread would be addressed to.""" + thread_ts = self._thread_ts_of(thread_root_id) + if thread_ts is None: + return None + channel_id = (thread_root_id or "").split(":", 1)[0] + return self._thread_requester.get((channel_id, thread_ts)) + + async def _open_stream( + self, + channel_id: str, + agent_name: str, + content: TurnActivity, + thread_root_id: str, + ) -> str | None: + """Open a stream for this turn, or report that there is none to use. + + A refusal is not an error here: every reason Slack declines leaves the + ordinary post as a working way to draw the turn, so the caller falls + back to it rather than failing the publication. It is logged at warning + because a bridge quietly drawing turns the slower way is something an + operator should be able to see. + """ + client = self._web_client + assert client is not None + thread_ts = self._thread_ts_of(thread_root_id) + requester = self._requester_for(thread_root_id) + assert thread_ts is not None and requester is not None + try: + opened = await client.chat_startStream( + channel=channel_id, + thread_ts=thread_ts, + recipient_user_id=requester, + recipient_team_id=self._team_id, + task_display_mode="plan", + username=agent_name, + icon_url=await self.agent_icon_url(agent_name), + ) + except SlackApiError as error: + logger.warning( + "Slack would not open an activity stream for %s in %s (%s); " + "drawing the turn as an ordinary message instead.", + agent_name, + channel_id, + error.response.get("error"), + ) + return None + ts = opened.get("ts") + if not ts: + logger.warning( + "Slack opened an activity stream for %s with no ts; " + "drawing the turn as an ordinary message instead.", + agent_name, + ) + return None + ref = f"{channel_id}:{ts}" + stream = _ActivityStream(channel_id=channel_id, ts=str(ts)) + self._streams[ref] = stream + while len(self._streams) > _MAX_OPEN_STREAMS: + abandoned, _ = self._streams.popitem(last=False) + logger.warning( + "Forgetting the activity stream %s to make room; its turn never " + "ended, so the message is left in its streaming state.", + abandoned, + ) + await self._extend_stream(stream, ref, content) + return ref + + async def _extend_stream( + self, stream: _ActivityStream, message_ref: str, content: TurnActivity + ) -> None: + """Send what changed since the last append, and close a finished turn. + + Only the header and the cards that actually moved. Re-sending a card + Slack already has is what makes the difference between this and an + edit: the same id merges into the card that is there, so the message + around it β€” and whatever the reader has open β€” is left alone. + """ + client = self._web_client + if client is None: + raise RuntimeError("Cannot extend an activity stream: Slack disconnected.") + if message_ref in self._streams: + self._streams.move_to_end(message_ref) + + def draw(omitted: int) -> StreamedActivity: + return render_activity_stream( + content.items, + content.turn, + elapsed_seconds=content.elapsed_seconds, + omitted=omitted, + ) + + moved: list[dict[str, Any]] = [] + omitted = stream.omitted + room = _MAX_STREAM_TASKS - stream.created + for task in draw(omitted).tasks: + known = stream.cards.get(task["id"]) + if known == task: + continue + if known is None: + if room <= 0: + omitted += 1 + continue + room -= 1 + moved.append(task) + # Counted here rather than before the loop because a card cut just now + # has to be disclosed by the header that goes out beside it. + title = draw(omitted).title + + chunks: list[dict[str, Any]] = [] + if title != stream.title: + chunks.append({"type": "plan_update", "title": title}) + chunks.extend(moved) + link = bool(content.session_url) and not stream.linked + if link: + chunks.append( + { + "type": "markdown_text", + "text": f"<{content.session_url}|Open in Console app>", + } + ) + + if chunks: + try: + await client.chat_appendStream( + channel=stream.channel_id, ts=stream.ts, chunks=chunks + ) + except SlackApiError as error: + # Nothing below runs: every path out of here raises. The + # stream's record of what Slack holds stays as it was, so a + # retry sends the same chunks rather than assuming they landed. + self._stream_failed(error, message_ref, title) + stream.title = title + stream.omitted = omitted + stream.linked = stream.linked or link + for task in moved: + stream.cards[task["id"]] = task + if content.turn.status in TURN_ENDED: + await self._close_stream(client, stream, message_ref) + + async def _close_stream( + self, client: AsyncWebClient, stream: _ActivityStream, message_ref: str + ) -> None: + """Stop the stream and leave the message where it is. + + The turn is over and the plan is the record of what it did, so unlike + the old progress card there is nothing here to delete. + """ + self._streams.pop(message_ref, None) + try: + await client.chat_stopStream(channel=stream.channel_id, ts=stream.ts) + except SlackApiError as error: + logger.warning( + "Slack would not close the activity stream %s (%s); the message " + "stands, but it will keep its streaming state.", + message_ref, + error.response.get("error"), + ) + + def _stream_failed( + self, + error: SlackApiError, + message_ref: str, + text: str, + ) -> NoReturn: + """Turn a refused append into the failure the caller already handles. + + A stream that has been stopped, or that this app no longer owns, is + gone rather than temporarily unwell: forget it so the publication is + redrawn as an ordinary post rather than appended to forever. + """ + code = error.response.get("error") + if code in _STREAM_CLOSED_ERRORS: + self._streams.pop(message_ref, None) + logger.warning( + "The activity stream %s is no longer accepting appends (%s); " + "later updates will be drawn as an ordinary message.", + message_ref, + code, + ) + raise RichContentFailed( + f"Slack closed the activity stream {message_ref}: {code}", text=text + ) from error + if code == "ratelimited" or getattr(error.response, "status_code", None) == 429: + delay = self._retry_after(error) + self._rich_update_after = time.monotonic() + delay + raise RichContentThrottled(retry_after=delay, text=text) from error + raise RichContentFailed( + f"Slack could not extend the activity stream {message_ref}: {error}", + text=text, + ) from error + def _render_rich( self, content: RichContent, *, responder_name: str | None = None ) -> SlackMessage: @@ -597,6 +936,13 @@ def _render_rich( message = ( render_attention(content.error_summary) if content.error_summary + else render_activity_plan( + content.items, + content.turn, + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + ) + if not (content.status_only or content.tool_log) else render_activity( content.items, content.turn, @@ -1923,6 +2269,7 @@ async def _handle_message_event(self, event: dict[str, object]) -> None: # Remember the thread this message belongs to so the "thinking" # indicator can be posted into the same conversation. self._last_thread_ts[channel_id] = thread_ts or message_ts + self._note_requester(channel_id, message_ts, thread_ts, user_id, bot_id) message_ref = f"{channel_id}:{message_ts}" stripped = text.strip() if user_id and not bot_id: diff --git a/core/tests/switch_core/bridges/collaboration/slack_fakes.py b/core/tests/switch_core/bridges/collaboration/slack_fakes.py index f2e33e03f..c5572c255 100644 --- a/core/tests/switch_core/bridges/collaboration/slack_fakes.py +++ b/core/tests/switch_core/bridges/collaboration/slack_fakes.py @@ -24,6 +24,15 @@ def __init__(self) -> None: # its replies in order. self.thread: list[dict[str, Any]] = [] self.replies_error: str | None = None + # Streamed activity: the openings, every append's chunks in order, and + # the closes. Kept apart from `posted`/`updated` because a stream is a + # different call shape, and a test that expects one should not pass on + # the other. + self.started: list[dict[str, Any]] = [] + self.appended: list[dict[str, Any]] = [] + self.stopped: list[dict[str, Any]] = [] + self.start_error: str | None = None + self.append_error: str | None = None self._ts = 0 async def api_call(self, method: str, **kwargs: Any) -> FakeResponse: @@ -57,6 +66,23 @@ async def chat_update(self, **kwargs: Any) -> FakeResponse: self.updated.append(kwargs) return FakeResponse({"ok": True}) + async def chat_startStream(self, **kwargs: Any) -> FakeResponse: + if self.start_error: + raise SlackApiError("no", FakeResponse({"error": self.start_error})) + self._ts += 1 + self.started.append(kwargs) + return FakeResponse({"ts": f"{self._ts}.0"}) + + async def chat_appendStream(self, **kwargs: Any) -> FakeResponse: + if self.append_error: + raise SlackApiError("no", FakeResponse({"error": self.append_error})) + self.appended.append(kwargs) + return FakeResponse({"ok": True}) + + async def chat_stopStream(self, **kwargs: Any) -> FakeResponse: + self.stopped.append(kwargs) + return FakeResponse({"ok": True}) + async def conversations_replies(self, **kwargs: Any) -> FakeResponse: if self.replies_error: raise SlackApiError("failed", FakeResponse({"error": self.replies_error})) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index ec2c69de2..cc6fbb152 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -24,6 +24,7 @@ ) from switch_core.bridges.collaboration.session.renderers.slack import ( render_activity, + render_activity_plan, render_activity_text, render_request, render_turn_with_request, @@ -730,4 +731,30 @@ async def test_a_turn_activity_card_is_unaffected_by_the_request_card_change() - rendered = _slack_adapter()._render_rich(TurnActivity(items, turn)) - assert rendered == render_activity(items, turn) + assert rendered == render_activity_plan(items, turn) + + +async def test_the_one_message_leads_with_the_state_and_the_step_it_is_on() -> None: + """It stands in for the pair it replaced, so its header has to do both + jobs: where the turn got to, and what it is doing right now.""" + items = await _items() + running = _item(itemId="live", status="in-progress", title="Read") + + drawn = render_activity_plan([*items, running], _turn(), elapsed_seconds=40) + + plan = drawn.blocks[0] + assert plan["type"] == "plan" + assert plan["title"].endswith("Β· Running: Read") + assert "40s" in plan["title"] + assert plan["tasks"][-1]["title"] == "Read" + + +async def test_a_turn_with_nothing_to_plan_yet_still_shows_it_is_working() -> None: + """A plan block with no tasks says nothing, and the message this replaced + spun a card from the moment the turn opened.""" + running = render_activity_plan([], _turn(), elapsed_seconds=3) + ended = render_activity_plan([], _turn("completed")) + + assert running.blocks[0]["type"] == "task_card" + assert running.blocks[0]["status"] == "in_progress" + assert ended.blocks[0]["type"] == "context" diff --git a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py index 21dac8cb2..3e1903337 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py @@ -52,7 +52,12 @@ async def test_completed_log_keeps_tools_without_console_links(): assert "Worked for" not in message.text -async def test_live_clock_updates_visible_link_without_editing_the_tool_log(): +async def test_the_clock_and_the_tool_log_move_the_one_message_link_and_all(): + """No thread requester here, so this is the unstreamed fallback path. + + Both things that used to have a message each β€” the clock and the tool log + β€” now move the same one, and the Console link stays on it throughout. + """ adapter, client = _adapter() activity = SessionTurnActivity(adapter) items = await _items() @@ -65,19 +70,22 @@ async def test_live_clock_updates_visible_link_without_editing_the_tool_log(): session_url=URL, ) await activity.publish(items, _turn("running"), elapsed_seconds=1, **kwargs) + assert len(client.posted) == 1 await activity.publish(items, _turn("running"), elapsed_seconds=30, **kwargs) assert len(client.updated) == 1 assert client.updated[0]["ts"] == "1.0" assert "30s" in client.updated[0]["text"] - assert URL in client.updated[0]["blocks"][0]["elements"][0]["text"] + assert URL in json.dumps(client.updated[0]["blocks"]) + + # A tool moving without the clock moving is still a change to this message. tool_index = next(i for i, item in enumerate(items) if item.kind == "tool-activity") items[tool_index] = items[tool_index].model_copy( update={"revision": items[tool_index].revision + 1} ) await activity.publish(items, _turn("running"), elapsed_seconds=30, **kwargs) assert len(client.updated) == 2 - assert client.updated[1]["ts"] == "2.0" # Only the tool log changed. - assert URL not in json.dumps(client.updated[1]["blocks"]) + assert client.updated[1]["ts"] == "1.0" + assert URL in json.dumps(client.updated[1]["blocks"]) def test_request_keeps_answer_buttons_and_recovery_marker_without_console_link(): @@ -141,10 +149,10 @@ async def test_attention_retries_do_not_create_extra_posts_and_recovery_clears_w await activity.publish( [], _turn("running"), error_summary="The host is offline.", **kwargs ) - assert len(client.posted) == 3 # Status, reserved log, and one attention reply. - alert_ref = "3.0" + assert len(client.posted) == 2 # The turn, and one attention reply. + alert_ref = "2.0" await activity.publish([], _turn("running"), **kwargs) - assert len(client.posted) == 3 + assert len(client.posted) == 2 edits = [edit for edit in client.updated if edit["ts"] == alert_ref] assert edits[-1]["blocks"][0]["type"] == "task_card" assert "Working" in edits[-1]["text"] @@ -158,7 +166,7 @@ def test_untrusted_url_scheme_is_not_rendered(): assert "javascript" not in json.dumps(result.blocks) -async def test_completion_keeps_status_first_and_tool_log_second(): +async def test_a_turn_running_to_completion_posts_once_and_deletes_nothing(): adapter, client = _adapter() activity = SessionTurnActivity(adapter) kwargs = dict( @@ -170,18 +178,15 @@ async def test_completion_keeps_status_first_and_tool_log_second(): session_url=URL, ) await activity.publish([], _turn("running"), elapsed_seconds=1, **kwargs) - assert len(client.posted) == 2 + assert len(client.posted) == 1 assert "Working" in client.posted[0]["text"] - assert "No tool calls yet" in client.posted[1]["text"] items = await _items() await activity.publish(items, _turn("running"), elapsed_seconds=30, **kwargs) await activity.publish(items, _turn("completed"), elapsed_seconds=100, **kwargs) - assert len(client.posted) == 2 + assert len(client.posted) == 1 + assert "Worked for 1m 40s" in client.updated[-1]["text"] assert not client.deleted - status = [edit for edit in client.updated if edit["ts"] == "1.0"][-1] - log = [edit for edit in client.updated if edit["ts"] == "2.0"][-1] - assert "Worked for 1m 40s" in status["text"] - assert status["text"].count(URL) == 1 - assert log["blocks"][0]["type"] == "plan" - assert URL not in json.dumps(log) - assert "Worked for" not in log["text"] + settled = [edit for edit in client.updated if edit["ts"] == "1.0"][-1] + assert settled["blocks"][0]["type"] == "plan" + assert "Worked for 1m 40s" in settled["blocks"][0]["title"] + assert json.dumps(settled["blocks"]).count(URL) == 1 diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py new file mode 100644 index 000000000..ca7ebd717 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -0,0 +1,552 @@ +"""A turn's activity, streamed into one Slack message instead of edited into it. + +An edit replaces a message's whole blocks array, and the client redraws the +`plan` block from scratch β€” which closes a section the reader had expanded. +That is why the clock used to live in a message of its own: at one redraw every +five seconds, anything open collapsed before it could be read. + +`chat.appendStream` does not replace anything. A `task_update` carrying an id +Slack already holds merges into that card, and a `plan_update` moves the header +without touching the cards at all. So the two messages become one, the clock +ticks in its header, and an expanded step stays expanded. Measured against the +live API before it was built, not assumed. + +What these cover is the adapter's half: that it opens a stream where it can, +sends only what moved, discloses what it had to leave out, stops at the end of +the turn, and falls back visibly to an ordinary post everywhere else. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +import pytest +from slack_sdk.errors import SlackApiError + +from switch_core.bridges.collaboration.adapter import ( + RichContentFailed, + RichContentThrottled, + TurnActivity, +) +from switch_core.bridges.collaboration.session.outbound import SessionTurnActivity +from switch_core.bridges.collaboration.slack.adapter import ( + _MAX_OPEN_STREAMS, + SlackAdapter, + SlackConnectionConfig, +) +from switch_core.sessions.contract import Item, TurnUpsert + +from .slack_fakes import FakeResponse, FakeWebClient + +CHANNEL = "C1" +THREAD = "C1:root" +ASKER = "U0ASKER" +TURN = "turn-1" + + +def _adapter(client: FakeWebClient) -> SlackAdapter: + adapter = SlackAdapter( + config=SlackConnectionConfig( + bot_token="unused", app_token="unused", workspace_id="T123" + ) + ) + adapter._web_client = client # type: ignore[assignment] + adapter._team_id = "T123" + adapter._channel_type_cache[CHANNEL] = "channel" + adapter._thread_requester[(CHANNEL, "root")] = ASKER + return adapter + + +def _turn(status: str = "running") -> TurnUpsert: + return TurnUpsert.model_validate( + {"type": "turn.upsert", "turnId": TURN, "status": status, "commandId": None} + ) + + +def _tool( + item_id: str, title: str, status: str = "in-progress", text: str = "" +) -> Item: + return Item.model_validate( + { + "itemId": item_id, + "turnId": TURN, + "revision": 1, + "kind": "tool-activity", + "status": status, + "title": title, + "text": text, + "attachments": [], + "origin": None, + } + ) + + +def _chunks(client: FakeWebClient) -> list[list[dict[str, Any]]]: + return [call["chunks"] for call in client.appended] + + +def _cards(client: FakeWebClient) -> list[dict[str, Any]]: + return [ + chunk + for call in client.appended + for chunk in call["chunks"] + if chunk["type"] == "task_update" + ] + + +# ── Opening ────────────────────────────────────────────────────────────────── + + +async def test_a_turn_in_a_thread_with_an_asker_opens_a_plan_mode_stream() -> None: + """Everything the stream needs is on the call that opens it. + + `task_display_mode` is what makes this a plan rather than a timeline, and + the recipient pair is what Slack requires to stream into a channel at all β€” + without either, the message is not the one this change is for. + """ + client = FakeWebClient() + adapter = _adapter(client) + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([_tool("t1", "Read")], _turn()), THREAD + ) + + assert client.posted == [] + assert ref == f"{CHANNEL}:1.0" + opened = client.started[0] + assert opened["channel"] == CHANNEL + assert opened["thread_ts"] == "root" + assert opened["task_display_mode"] == "plan" + assert opened["recipient_user_id"] == ASKER + assert opened["recipient_team_id"] == "T123" + + +async def test_the_asker_is_whoever_last_spoke_in_the_thread_and_not_a_bot() -> None: + """A stream has to be addressed to somebody, and it should be the person + waiting on the answer rather than the agent that answered last.""" + adapter = SlackAdapter( + config=SlackConnectionConfig( + bot_token="unused", app_token="unused", workspace_id="T123" + ) + ) + + adapter._note_requester(CHANNEL, "root", "root", ASKER, None) + adapter._note_requester(CHANNEL, "reply", "root", None, "B0BOT") + + assert adapter._requester_for(THREAD) == ASKER + + +# ── Sending only what moved ────────────────────────────────────────────────── + + +async def test_only_the_header_and_the_cards_that_changed_are_appended() -> None: + """The whole point of a stream over an edit. + + A redraw that re-sent every card would cost what an edit costs and lose the + property it was chosen for, so a card Slack already holds unchanged is not + sent again. + """ + client = FakeWebClient() + adapter = _adapter(client) + first, second = _tool("t1", "Read"), _tool("t2", "Grep") + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([first], _turn(), 1.0), THREAD + ) + done = first.model_copy(update={"revision": 2, "status": "completed"}) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([done, second], _turn(), 9.0), THREAD + ) + + assert [c["type"] for c in _chunks(client)[0]] == ["plan_update", "task_update"] + later = _chunks(client)[1] + assert [c["type"] for c in later] == ["plan_update", "task_update", "task_update"] + assert later[0]["title"] == "Working… 9s Β· Running: Grep" + assert (later[1]["title"], later[1]["status"]) == ("Read", "complete") + assert (later[2]["title"], later[2]["status"]) == ("Grep", "in_progress") + + +async def test_a_publish_that_changed_nothing_appends_nothing() -> None: + """The clock ticks every five seconds whether or not anything happened.""" + client = FakeWebClient() + adapter = _adapter(client) + content = TurnActivity([_tool("t1", "Read")], _turn(), 4.0) + + ref = await adapter.post_rich(CHANNEL, "Agent", content, THREAD) + await adapter.update_rich(CHANNEL, "Agent", ref, content, THREAD) + + assert len(client.appended) == 1 + + +async def test_the_clock_moves_the_header_without_resending_a_card() -> None: + """What the second message existed to buy, bought inside the first.""" + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 5.0), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([tool], _turn(), 10.0), THREAD + ) + + assert _chunks(client)[1] == [ + {"type": "plan_update", "title": "Working… 10s Β· Running: Read"} + ] + + +async def test_a_card_carries_its_detail_as_a_plain_string() -> None: + """Measured, not read: the live API rejects every rich_text shape here, + though the `task_card` block of the same name requires one.""" + client = FakeWebClient() + adapter = _adapter(client) + + await adapter.post_rich( + CHANNEL, + "Agent", + TurnActivity( + [_tool("t1", "Read", status="completed", text="312 lines")], _turn() + ), + THREAD, + ) + + assert _cards(client)[0]["details"] == "312 lines" + + +async def test_no_chunk_exceeds_what_an_append_will_carry() -> None: + """Slack measures a chunk serialised and rejects the append over 256 + characters, taking every other chunk in it down with it. The title is what + survives: a card whose detail was cut still says what it did.""" + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "R" * 400, status="completed", text="D" * 400) + + await adapter.post_rich(CHANNEL, "Agent", TurnActivity([tool], _turn()), THREAD) + + card = _cards(client)[0] + assert len(json.dumps(card, separators=(",", ":"))) <= 256 + assert card["title"].startswith("RRR") + assert "details" not in card + + +async def test_a_trim_never_leaves_half_an_escaped_character_on_screen() -> None: + """The value being cut has already been escaped, so a blind slice can leave + `&am` in front of the reader instead of an `&`.""" + client = FakeWebClient() + adapter = _adapter(client) + + await adapter.post_rich( + CHANNEL, + "Agent", + TurnActivity([_tool("t1", "&" * 200, status="completed")], _turn()), + THREAD, + ) + + title = _cards(client)[0]["title"] + assert title.endswith("…") + assert re.fullmatch(r"(&)+", title[:-1]) + + +async def test_the_console_link_goes_out_once(caplog: Any) -> None: + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + url = "https://switch.example/session" + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0, session_url=url), THREAD + ) + await adapter.update_rich( + CHANNEL, + "Agent", + ref, + TurnActivity([tool], _turn(), 20.0, session_url=url), + THREAD, + ) + + links = [ + chunk + for call in client.appended + for chunk in call["chunks"] + if chunk["type"] == "markdown_text" + ] + assert len(links) == 1 + assert url in links[0]["text"] + + +# ── The cap ────────────────────────────────────────────────────────────────── + + +async def test_cards_past_the_cap_are_left_out_and_the_header_says_so() -> None: + """A stream cannot take a card back, so the cap is on cards ever created. + + The posted plan drops its oldest and keeps a window on the newest; this + cannot, so it keeps the oldest and says how many it could not open. Either + way the reader is told the log is not complete. + """ + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(53)] + + await adapter.post_rich(CHANNEL, "Agent", TurnActivity(many, _turn()), THREAD) + + assert len(_cards(client)) == 50 + assert "3 earlier steps not shown" in _chunks(client)[0][0]["title"] + + +# ── Ending ─────────────────────────────────────────────────────────────────── + + +async def test_a_finished_turn_stops_the_stream_and_leaves_the_message() -> None: + """Unlike Slack's own progress card there is nothing here to delete: the + plan is the record of what the turn did.""" + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0), THREAD + ) + done = tool.model_copy(update={"revision": 2, "status": "completed"}) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([done], _turn("completed"), 30.0), THREAD + ) + + assert client.stopped == [{"channel": CHANNEL, "ts": "1.0"}] + assert client.deleted == [] + assert _chunks(client)[-1][0]["title"] == "Worked for 30s. 1 tool call." + + +async def test_an_unfinished_step_is_named_rather_than_left_spinning() -> None: + """A turn can stop with a call still open, and nothing will ever close it.""" + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn()), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([tool], _turn("interrupted"), 4.0), THREAD + ) + + last = _cards(client)[-1] + assert last["status"] == "complete" + assert last["title"] == "Unfinished: Read" + + +async def test_the_stream_is_forgotten_once_it_is_stopped() -> None: + """A second turn in the same thread opens its own stream rather than + appending to the one the last turn left behind.""" + client = FakeWebClient() + adapter = _adapter(client) + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([_tool("t1", "Read")], _turn()), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([], _turn("completed"), 1.0), THREAD + ) + + assert adapter._streams == {} + + +async def test_turns_that_never_end_do_not_pile_up_forever(caplog: Any) -> None: + """Only a turn ending closes a stream, and a turn whose agent died never + ends. The oldest is dropped rather than held for the life of the process.""" + client = FakeWebClient() + adapter = _adapter(client) + adapter._thread_requester[(CHANNEL, "root")] = ASKER + + refs = [] + with caplog.at_level(logging.WARNING): + for index in range(_MAX_OPEN_STREAMS + 1): + ref = await adapter.post_rich( + CHANNEL, + "Agent", + TurnActivity([_tool(f"t{index}", "Read")], _turn()), + THREAD, + ) + refs.append(ref) + + assert len(adapter._streams) == _MAX_OPEN_STREAMS + assert refs[0] not in adapter._streams + assert refs[-1] in adapter._streams + assert "Forgetting the activity stream" in caplog.text + + +# ── When Slack will not stream ─────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "break_it, missing", + [ + (lambda a: a._thread_requester.clear(), "nobody in the thread"), + (lambda a: setattr(a, "_team_id", ""), "workspace id is unknown"), + ], +) +async def test_a_turn_that_cannot_stream_is_posted_and_the_reason_logged( + break_it: Any, missing: str, caplog: Any +) -> None: + """Degraded, not broken: the turn still appears, and its plan still + collapses on every redraw. An operator should be able to see which.""" + client = FakeWebClient() + adapter = _adapter(client) + break_it(adapter) + + with caplog.at_level(logging.WARNING): + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([_tool("t1", "Read")], _turn()), THREAD + ) + + assert client.started == [] + assert len(client.posted) == 1 + assert client.posted[0]["blocks"][0]["type"] == "plan" + assert ref == f"{CHANNEL}:1.0" + assert missing in caplog.text + + +async def test_a_turn_outside_a_thread_is_posted_rather_than_streamed() -> None: + client = FakeWebClient() + adapter = _adapter(client) + + await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([_tool("t1", "Read")], _turn()), None + ) + + assert client.started == [] + assert len(client.posted) == 1 + + +async def test_slack_refusing_to_open_a_stream_falls_back_to_a_post( + caplog: Any, +) -> None: + """Every reason Slack declines leaves the ordinary post working, so the + publication is drawn rather than failed.""" + client = FakeWebClient() + adapter = _adapter(client) + client.start_error = "not_an_agent" + + with caplog.at_level(logging.WARNING): + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([_tool("t1", "Read")], _turn()), THREAD + ) + + assert ref == f"{CHANNEL}:1.0" + assert len(client.posted) == 1 + assert "not_an_agent" in caplog.text + + +async def test_an_attention_post_is_never_streamed() -> None: + """One stream per thread, and the attention reply is a message of its own.""" + client = FakeWebClient() + adapter = _adapter(client) + + await adapter.post_rich( + CHANNEL, + "Agent", + TurnActivity([], _turn("error"), status_only=True, error_summary="Offline."), + THREAD, + ) + + assert client.started == [] + assert len(client.posted) == 1 + + +# ── When an append is refused ──────────────────────────────────────────────── + + +async def test_a_closed_stream_is_forgotten_so_later_updates_are_edits( + caplog: Any, +) -> None: + """A stopped stream, or one this app no longer owns, is gone rather than + temporarily unwell. The message is still there, so the turn goes on being + drawn β€” as an ordinary edit, collapsing and all.""" + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0), THREAD + ) + + client.append_error = "stopped_by_user" + with pytest.raises(RichContentFailed), caplog.at_level(logging.WARNING): + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([tool], _turn(), 9.0), THREAD + ) + client.append_error = None + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([tool], _turn(), 14.0), THREAD + ) + + assert "stopped_by_user" in caplog.text + assert adapter._streams == {} + assert [call["ts"] for call in client.updated] == ["1.0"] + + +async def test_a_throttled_append_asks_the_caller_to_wait_and_keeps_the_stream( + monkeypatch: Any, +) -> None: + """Rate limiting is temporary, so the stream survives it β€” and the chunks + that were refused are still owed, not marked as sent.""" + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0), THREAD + ) + + async def refuse(**kwargs: Any) -> None: + raise SlackApiError( + "no", + FakeResponse({"error": "ratelimited"}, headers={"Retry-After": "7"}), + ) + + monkeypatch.setattr(client, "chat_appendStream", refuse) + with pytest.raises(RichContentThrottled) as refused: + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([tool], _turn(), 9.0), THREAD + ) + + assert refused.value.retry_after == 7.0 + assert ref in adapter._streams + assert adapter._streams[ref].title == "Working… 1s Β· Running: Read" + + +# ── End to end, through the publisher ──────────────────────────────────────── + + +async def test_a_turn_published_from_start_to_finish_is_one_streamed_message() -> None: + """What a reader ends up with: one message, opened once, never rebuilt.""" + client = FakeWebClient() + adapter = _adapter(client) + activity = SessionTurnActivity(adapter) + kwargs = dict( + session_id="session", + channel_id=CHANNEL, + thread_root_id=THREAD, + asked_on=None, + agent_name="Agent", + ) + read = _tool("t1", "Read") + + await activity.publish([read], _turn(), elapsed_seconds=0, **kwargs) + await activity.publish([read], _turn(), elapsed_seconds=5, **kwargs) + done = read.model_copy(update={"revision": 2, "status": "completed"}) + await activity.publish([done], _turn("completed"), elapsed_seconds=12, **kwargs) + + assert client.posted == [] + assert client.updated == [] + assert len(client.started) == 1 + assert len(client.stopped) == 1 + assert [ + [chunk["type"] for chunk in call["chunks"]] for call in client.appended + ] == [ + ["plan_update", "task_update"], + ["plan_update"], + ["plan_update", "task_update"], + ] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py index d73bfbaf7..cd2034d5d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py @@ -132,9 +132,9 @@ async def test_a_turn_is_posted_once_and_edited_every_time_after() -> None: await _publish(activity, items.turn_activity(TURN), _turn("running")) await _publish(activity, items.turn_activity(TURN), _turn("completed")) - assert len(client.posted) == 2 # status and tool log - assert len(client.updated) == 2 # final status and final tool log - assert [call["ts"] for call in client.updated] == ["1.0", "2.0"] + assert len(client.posted) == 1 + assert len(client.updated) == 1 # the turn's final state + assert [call["ts"] for call in client.updated] == ["1.0"] async def test_the_last_edit_is_the_state_the_turn_ended_in() -> None: @@ -168,7 +168,7 @@ async def test_a_second_turn_gets_its_own_message() -> None: await _publish(activity, [_item()], _turn("completed")) await _publish(activity, [_item("turn-two")], _turn("running", "turn-two")) - assert len(client.posted) == 4 + assert len(client.posted) == 2 assert client.updated == [] @@ -180,7 +180,7 @@ async def test_two_sessions_with_the_same_turn_id_do_not_share_a_message() -> No await _publish(activity, [_item()], _turn("running")) await _publish(activity, [_item()], _turn("running"), session_id="session-other") - assert len(client.posted) == 4 + assert len(client.posted) == 2 assert client.updated == [] @@ -202,7 +202,7 @@ async def test_a_turn_slack_refused_is_posted_afresh_rather_than_edited() -> Non adapter._web_client = client # type: ignore[assignment] await _publish(activity, [_item()], _turn("running")) - assert len(client.posted) == 2 + assert len(client.posted) == 1 assert client.updated == [] @@ -225,8 +225,10 @@ async def test_an_edit_slack_refused_keeps_the_message_for_the_next_change( await _publish(activity, [_item()], _turn("completed")) assert "Could not update the activity for turn" in caplog.text - assert len(client.posted) == 2 - assert [call["ts"] for call in client.updated] == ["1.0", "2.0"] + assert len(client.posted) == 1 + # The fake records only the edits it accepted, so this one entry is the + # edit after the refusal β€” on the message the refused one was for. + assert [call["ts"] for call in client.updated] == ["1.0"] async def test_a_failed_last_edit_says_the_channel_is_left_looking_live( @@ -263,7 +265,7 @@ async def test_more_live_turns_than_are_held_forgets_the_oldest_and_says_so( await _publish(activity, [_item("turn-one")], _turn("running", "turn-one")) assert "turn turn-one" in caplog.text - assert len(client.posted) == 8 + assert len(client.posted) == 4 assert client.updated == [] @@ -342,7 +344,7 @@ async def test_a_reaction_failure_does_not_fail_the_turns_own_draw( ) assert drawn is True - assert len(client.posted) == 2 + assert len(client.posted) == 1 assert "Could not add the working reaction on parent-1 in C1" in caplog.text @@ -481,28 +483,36 @@ async def test_internal_narration_is_not_published_in_activity_or_fallback() -> await _publish(activity, [narration, tool], _turn("completed"), elapsed_seconds=25) for call in [*client.posted, *client.updated]: assert "Answered in the room" not in json.dumps(call) - assert "Read file" in _blocks(client.posted[1]) + assert "Read file" in _blocks(client.posted[0]) assert "Worked for 25s" in _blocks(client.updated[0]) -async def test_plan_log_is_not_redrawn_by_timer_and_preserves_tool_warnings() -> None: +async def test_the_clock_and_the_tool_log_share_one_message_and_keep_warnings() -> None: + """Both halves of the old pair, in the message that replaced them. + + The status used to be posted separately so the clock could advance without + rebuilding the plan. Streaming moves the header on its own, so there is one + message, and its plan block carries the turn's state as its title. + """ client = FakeWebClient() activity = SessionTurnActivity(_adapter(client)) tool = _item().model_copy( update={"kind": "tool-activity", "title": "Read", "status": "in-progress"} ) await _publish(activity, [tool], _turn("running"), elapsed_seconds=0) - assert client.posted[1]["blocks"][0]["type"] == "plan" - assert client.posted[1]["blocks"][0]["title"] == "1 tool call Β· Running: Read" + assert len(client.posted) == 1 + assert client.posted[0]["blocks"][0]["type"] == "plan" + assert client.posted[0]["blocks"][0]["title"] == "Working… 0s Β· Running: Read" + await _publish(activity, [tool], _turn("running"), elapsed_seconds=5) assert [call["ts"] for call in client.updated] == ["1.0"] + failed = tool.model_copy(update={"revision": 2, "status": "failed"}) await _publish(activity, [failed], _turn("completed"), elapsed_seconds=10) - task = client.updated[-1]["blocks"][0]["tasks"][0] - assert client.updated[-1]["blocks"][0]["title"] == "1 tool call" + plan = client.updated[-1]["blocks"][0] + assert client.updated[-1]["ts"] == "1.0" + assert "Worked for 10s" in plan["title"] + task = plan["tasks"][0] assert task["status"] == "complete" assert "Read" in task["title"] and task["title"] != "Read" - assert "Worked for 10s" in _blocks(client.updated[-2]) - assert client.updated[-2]["ts"] == "1.0" - assert client.updated[-1]["ts"] == "2.0" assert client.api_calls == [] From 5a9a3bf75712fd903e524fde85951913191606c8 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 15:22:18 +0100 Subject: [PATCH 079/120] Close two gaps in the shared callback listener's lifecycle Two bridges configured for callbacks start in tasks of their own, so they reach the first bind together: both found nothing bound, both tried, and the loser failed startup on a port its own neighbour had just taken. The bind is now serialised, and a bind that fails cleans up the runner it set up rather than leaking it. A bridge also registered itself before binding, so a failed bind left the door claiming to serve a bridge that never came up. Bind first, register after: a press in the gap is answered as not running, which is what it is. The other half is a bridge that dies in its own task. Nothing calls stop for it, so its place on the listener stayed registered and went on handing presses to an adapter that was no longer running. The crash cleanup now withdraws the endpoint with the rest. Both were reachable only once a card carries buttons, which is why they are fixed before that lands rather than after. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/ingress.py | 43 +++++++++++++----- .../collaboration/lifecycle_service.py | 6 +++ .../test_collaboration_ingress.py | 41 +++++++++++++++++ .../test_lifecycle_callback_endpoint.py | 45 ++++++++++++++++--- 4 files changed, 118 insertions(+), 17 deletions(-) diff --git a/core/switch_core/bridges/collaboration/ingress.py b/core/switch_core/bridges/collaboration/ingress.py index c8d8b7599..9f6a40eaa 100644 --- a/core/switch_core/bridges/collaboration/ingress.py +++ b/core/switch_core/bridges/collaboration/ingress.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import hashlib import hmac import logging @@ -69,6 +70,10 @@ def __init__(self, *, host: str, port: int, secret: str) -> None: self._secret = secret self._handlers: dict[tuple[str, str], Handler] = {} self._runner: web.AppRunner | None = None + # Each bridge runs in a task of its own, so two starting together reach + # the bind together. Without this both would find nothing bound and the + # loser would fail on a port its own neighbour had just taken. + self._bind_lock = asyncio.Lock() def endpoint_for(self, bridge_type: str, bridge_id: str) -> CallbackEndpoint: return CallbackEndpoint( @@ -105,9 +110,14 @@ def _key_for(self, bridge_type: str, bridge_id: str) -> str: ).hexdigest() async def serve(self, bridge_type: str, bridge_id: str, handle: Handler) -> None: - """Take callbacks for one bridge, binding the listener if it is the first.""" - self._handlers[(bridge_type, bridge_id)] = handle + """Take callbacks for one bridge, binding the listener if it is the first. + + Bound before registered, so a bind that fails leaves nothing behind + claiming to serve this bridge. A press arriving in the gap between the + two is answered as not running, which is what it is. + """ await self._listen() + self._handlers[(bridge_type, bridge_id)] = handle async def withdraw(self, bridge_type: str, bridge_id: str) -> None: """Stop taking callbacks for one bridge. @@ -128,16 +138,25 @@ async def stop(self) -> None: logger.info("Collaboration callback listener stopped") async def _listen(self) -> None: - if self._runner is not None: - return - app = web.Application() - app.router.add_post(_ROUTE, self._dispatch) - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, self._host, self._port) - await site.start() - self._runner = runner - logger.info("Collaboration callback listener on %s:%s", self._host, self._port) + async with self._bind_lock: + if self._runner is not None: + return + app = web.Application() + app.router.add_post(_ROUTE, self._dispatch) + runner = web.AppRunner(app) + await runner.setup() + try: + site = web.TCPSite(runner, self._host, self._port) + await site.start() + except Exception: + # A runner that has been set up holds resources whether or not + # anything ever bound through it. + await runner.cleanup() + raise + self._runner = runner + logger.info( + "Collaboration callback listener on %s:%s", self._host, self._port + ) async def _dispatch(self, request: web.Request) -> web.StreamResponse: bridge_type = request.match_info["bridge_type"] diff --git a/core/switch_core/bridges/collaboration/lifecycle_service.py b/core/switch_core/bridges/collaboration/lifecycle_service.py index e6bcbbb07..16241d9bc 100644 --- a/core/switch_core/bridges/collaboration/lifecycle_service.py +++ b/core/switch_core/bridges/collaboration/lifecycle_service.py @@ -646,6 +646,12 @@ async def _run_bridge( self._bridges.pop(bridge_id, None) self._tasks.pop(bridge_id, None) self._held_resources.pop(bridge_id, None) + # The adapter may already have asked to be served before the + # failure, so a crash that leaves the endpoint registered + # leaves presses being handled by a bridge that is not running. + endpoint = self._callback_endpoints.pop(bridge_id, None) + if endpoint is not None: + await endpoint.withdraw() async def stop(self, bridge_id: str) -> None: # Before the adapter goes, so a press in flight is answered as gone diff --git a/core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py b/core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py index 91021c4cf..29710373e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py +++ b/core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py @@ -13,6 +13,7 @@ from __future__ import annotations +import asyncio import logging import socket from typing import Any @@ -119,6 +120,46 @@ async def second(body: dict[str, Any]) -> dict[str, Any]: await ingress.stop() +async def test_two_bridges_starting_together_bind_the_port_once() -> None: + """Each bridge runs in a task of its own, so two configured for callbacks + reach the bind together on boot. Both must come up: a bridge that failed + because its neighbour won the race would be down for no reason it could + report.""" + port = _free_port() + ingress = _ingress(port) + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + return {"who": "either"} + + await asyncio.gather( + ingress.serve("mattermost", BRIDGE, handle), + ingress.serve("mattermost", OTHER, handle), + ) + try: + assert (await _post(port, BRIDGE, {}))[0] == 200 + assert (await _post(port, OTHER, {}))[0] == 200 + finally: + await ingress.stop() + + +async def test_a_bridge_that_cannot_bind_does_not_leave_itself_registered() -> None: + """Something else on the port is a startup failure, and it has to be a + clean one: a handler left behind would have the door claiming to serve a + bridge that never came up.""" + with socket.socket() as taken: + taken.bind(("127.0.0.1", 0)) + taken.listen() + ingress = _ingress(taken.getsockname()[1]) + + async def handle(body: dict[str, Any]) -> dict[str, Any]: + return {} + + with pytest.raises(OSError): + await ingress.serve("mattermost", BRIDGE, handle) + + assert ingress._handlers == {} + + async def test_the_port_closes_when_the_listener_stops() -> None: port = _free_port() ingress = _ingress(port) diff --git a/core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py b/core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py index 4c30937d7..3e604b7db 100644 --- a/core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py +++ b/core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py @@ -48,9 +48,23 @@ async def _take(self, body: dict[str, Any]) -> dict[str, Any]: return {"heard": body} +class _StubCore: + async def start(self) -> None: + return None + + +class _FailingClient: + """A bridge client whose connection to the room server never comes up.""" + + client_id = "bridge-client" + + async def start(self) -> None: + raise RuntimeError("the room client is down") + + async def _start_one( session_factory: async_sessionmaker[AsyncSession], port: int -) -> tuple[Any, str, _CallbackAdapter]: +) -> tuple[Any, str, str, _CallbackAdapter]: tenant = f"tenant-{uuid.uuid4().hex[:8]}" async with session_factory() as session: await _make_tenant(session, tenant) @@ -76,7 +90,7 @@ async def _run(bridge_id: str, tenant_id: str, *_: object) -> None: # What the bridge's own task would have done, awaited rather than raced: # the adapter asks for its place as it starts. await built[0].start() - return service, bridge_id, built[0] + return service, tenant, bridge_id, built[0] async def _post(port: int, bridge_id: str) -> int: @@ -90,7 +104,7 @@ async def test_a_started_bridge_is_given_its_own_place_and_is_reachable_there( session_factory: async_sessionmaker[AsyncSession], ) -> None: port = _free_port() - service, bridge_id, adapter = await _start_one(session_factory, port) + service, _tenant, bridge_id, adapter = await _start_one(session_factory, port) try: assert adapter.endpoint is not None @@ -108,7 +122,7 @@ async def test_stopping_a_bridge_takes_its_place_with_it( """A press posted a minute ago still arrives. It must not be handed to an adapter that is no longer running.""" port = _free_port() - service, bridge_id, _ = await _start_one(session_factory, port) + service, _tenant, bridge_id, _ = await _start_one(session_factory, port) try: await service.stop(bridge_id) @@ -118,11 +132,32 @@ async def test_stopping_a_bridge_takes_its_place_with_it( await service.stop_all() +async def test_a_bridge_that_crashes_takes_its_place_with_it( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """A bridge dies in its own task, so nothing calls `stop` for it. Left + registered, its place on the listener would go on answering presses that + reach an adapter which is no longer running.""" + port = _free_port() + service, tenant, bridge_id, _ = await _start_one(session_factory, port) + + try: + assert await _post(port, bridge_id) == 200 + + await type(service)._run_bridge( + service, bridge_id, tenant, _StubCore(), _FailingClient() + ) + + assert await _post(port, bridge_id) == 404 + finally: + await service.stop_all() + + async def test_shutting_everything_down_closes_the_port( session_factory: async_sessionmaker[AsyncSession], ) -> None: port = _free_port() - service, bridge_id, _ = await _start_one(session_factory, port) + service, _tenant, bridge_id, _ = await _start_one(session_factory, port) await service.stop_all() From 011513e3a6edf3c96d8803d1ed5720a822971fd8 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 15:42:53 +0100 Subject: [PATCH 080/120] Draw the answer buttons on a Mattermost card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card's options become message-attachment actions in the post's props, one per option, numbered the way the body numbers them and the way a typed answer names them. Each carries its own signed context addressed to this bridge's place on the shared callback listener, so a press cannot be retargeted at another option by anything a client can see. The body still prints every option. Mattermost documents no budget for a button's label, so there is no width at which an option could be called fully shown by the control. Buttons come off a card the moment it stops being pressable, because every redraw builds the actions again. That makes props part of a card's edit, and a patch replaces props wholesale β€” on the server version deployed locally nothing re-applies the identity props it set when the post was made. So a redraw reads the post back and merges, rather than writing the props Switch knows about and silently dropping `from_bot` off the card. A bridge that draws no buttons at all does neither, and behaves exactly as it did before. Action ids are written rather than left to the server, which mints them only on the create path. A card is redrawn many times, and an id that changed under a reader would remount the control they were mid-press on. Co-Authored-By: Claude Opus 5 --- .../collaboration/mattermost/adapter.py | 150 ++++++-- .../collaboration/mattermost/callback.py | 43 +++ .../test_mattermost_card_buttons.py | 333 ++++++++++++++++++ .../collaboration/test_mattermost_sdk_only.py | 27 +- 4 files changed, 528 insertions(+), 25 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index eb2759a94..d30df5c50 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -43,7 +43,10 @@ CallbackEndpoint, CallbackRefused, ) -from switch_core.bridges.collaboration.mattermost.callback import read_press +from switch_core.bridges.collaboration.mattermost.callback import ( + answer_actions, + read_press, +) from switch_core.bridges.collaboration.models import ( Attachment, AttachmentFailure, @@ -58,9 +61,13 @@ InboundUserJoin, OutboundAttachment, ) -from switch_core.bridges.collaboration.session.renderers import position_action +from switch_core.bridges.collaboration.session.renderers import ( + Drawn, + offered_controls, + position_action, +) from switch_core.bridges.collaboration.session.renderers.neutral import ( - request_summary, + render_request, turn_status, ) @@ -92,6 +99,12 @@ # editing the status in place cannot drop it. _PUBLICATION_PROP = "switch_publication" +# Where a post's buttons live. Mattermost carries interactive actions inside a +# message attachment in the post's props, and keeps each action's `integration` +# β€” the callback URL and its context β€” server-side, never serialising it to a +# client. +_ATTACHMENTS_PROP = "attachments" + # How far before the recorded reservation time to start looking for a post that # may or may not exist. Covers ordinary clock skew between Switch and the # Mattermost server without widening the search into unrelated history. @@ -730,7 +743,7 @@ async def _create_post( channel_id: str, content: str, thread_root_id: str | None, - props: dict[str, str] | None = None, + props: dict[str, Any] | None = None, ) -> str | None: try: return await self._post_or_raise( @@ -746,7 +759,7 @@ async def _post_or_raise( channel_id: str, content: str, thread_root_id: str | None, - props: dict[str, str] | None, + props: dict[str, Any] | None, ) -> str: """Create a post and hand back its id, or raise saying why not. @@ -800,11 +813,11 @@ def rich_fallback_text(self, content: RichContent) -> str: the string that goes in a `RichContentFailed`, where a failed lookup on top of a failed post would say nothing useful anyway. """ - return self._draw(content, mention=None, responder=None) + return self._draw(content, mention=None, responder=None).text def _draw( self, content: RichContent, *, mention: str | None, responder: str | None - ) -> str: + ) -> Drawn: escape = self._rich_escape limit = self.rich_fallback_limit() markup = self.rich_markup() @@ -813,8 +826,8 @@ def _draw( # just fits, plus a line saying it reached nobody, is a post # Mattermost refuses. tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" - return ( - turn_status( + return Drawn( + text=turn_status( content.items, content.turn, escape=escape, @@ -826,7 +839,8 @@ def _draw( error_summary=content.error_summary, tool_detail=True, ) - + tail + + tail, + answerable=False, ) # The handle goes on its own line rather than in front of the heading: # a card is a block, and a handle wedged before "**Permission needed**" @@ -836,7 +850,7 @@ def _draw( # a request nobody was named in is a request nobody was asked. lead = f"{mention}\n" if mention else "" tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" - body = request_summary( + drawn = render_request( content.request, content.reference, escape=escape, @@ -844,17 +858,70 @@ def _draw( markup=markup, responder=responder, unavailable_reason=content.unavailable_reason, + # The body prints every option even where buttons are drawn. + # Mattermost documents no budget for a button's label, so there is + # no width at which an option can be called fully shown by the + # control β€” and a numbered list is what a typed answer names. + control_label_limit=None, ) - return f"{lead}{body}{tail}" + return replace(drawn, text=f"{lead}{drawn.text}{tail}") + + def _button_address(self) -> tuple[str, str] | None: + """Where a press goes and what signs it, or None if this bridge takes none. + + All three have to hold and they are settled at different moments: an + address the Mattermost server can reach, a place on the shared listener + for a press to arrive at, and something to route it to once it has. + Asked on the redraw path as well as the drawing one, so a deployment + that draws no buttons never rewrites a post's props to remove them + either. + """ + url = self.callback_url + endpoint = self._callback + if url is None or endpoint is None or self._on_interaction is None: + return None + return url, endpoint.key + + def _controls(self, content: RichContent, drawn: Drawn) -> list[dict[str, Any]]: + """The card's options as buttons, or nothing where a press cannot land. + + Nothing at all is the ordinary answer: a status has no options, a + settled card has none left, a bridge with no callback address has + nowhere for a press to go, and a card that cannot be answered where it + is showing says so β€” a live control under that sentence invites the + refusal the sentence just explained. Because every redraw builds this + again, the buttons come off a card at the moment it stops being + pressable, without anything having to remember that it once had them. + + Whether the drawing earned them comes from `drawn` rather than from + reading the request a second time. A body cut short of the difference + between two options is one a reader cannot decide from, and only the + renderer that cut it knows that. A press would still resolve against + the stored record and settle the request, so the whole of the + protection is not offering the button. + """ + address = self._button_address() + if address is None: + return [] + if not isinstance(content, RequestCard) or not drawn.answerable: + return [] + controls = offered_controls(content.request) + if not controls: + return [] + url, key = address + return answer_actions(key, url, content.reference.token, controls) - async def _render_rich(self, content: RichContent) -> str: + async def _render_rich( + self, content: RichContent + ) -> tuple[str, list[dict[str, Any]]]: mention = await self._mention(content.notify_external_id) responder = ( await self._mention(content.responder_external_id) if isinstance(content, RequestCard) else None ) - return self._draw(content, mention=mention, responder=responder) + drawn = self._draw(content, mention=mention, responder=responder) + return drawn.text, self._controls(content, drawn) async def post_rich( self, @@ -877,7 +944,7 @@ async def post_rich( Mattermost actually gave. A send whose outcome nobody knows raises the transport's own error and keeps the reservation. """ - text = await self._render_rich(content) + text, actions = await self._render_rich(content) driver = self._bot_drivers.get(agent_name) if driver is None: raise RichContentFailed( @@ -890,13 +957,14 @@ async def post_rich( if isinstance(content, TurnActivity) else content.reference.token ) + props: dict[str, Any] = {} + if token: + props[_PUBLICATION_PROP] = token + if actions: + props[_ATTACHMENTS_PROP] = [{"actions": actions}] try: ref = await self._post_or_raise( - driver, - channel_id, - text, - thread_root_id, - {_PUBLICATION_PROP: token} if token else None, + driver, channel_id, text, thread_root_id, props or None ) except Exception as error: failure = _as_rich_failure( @@ -933,11 +1001,17 @@ async def update_rich( Says "did not happen" only where Mattermost refused the edit. An edit whose outcome is unknown may well have landed, and reporting it as a refusal buys a fallback reply about a card that is already correct. + + A card's buttons are carried in the post's props, so where this bridge + draws any the props are part of the edit β€” which is what takes them off + a card the moment it stops being answerable. """ # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the channel that never resolves to # anything new for the person it names. - text = await self._render_rich(replace(content, notify_external_id=None)) + text, actions = await self._render_rich( + replace(content, notify_external_id=None) + ) driver = self._bot_drivers.get(agent_name) or self._admin_driver loop = self._main_loop if driver is None or loop is None: @@ -946,8 +1020,13 @@ async def update_rich( text=text, ) try: + patch: dict[str, Any] = {"message": text} + if isinstance(content, RequestCard) and self._button_address() is not None: + patch["props"] = await self._props_with_actions( + driver, loop, message_ref, actions + ) await loop.run_in_executor( - None, driver.posts.patch_post, message_ref, {"message": text} + None, driver.posts.patch_post, message_ref, patch ) except Exception as error: failure = _as_rich_failure( @@ -962,6 +1041,33 @@ async def update_rich( raise raise failure from error + async def _props_with_actions( + self, + driver: Driver, + loop: asyncio.AbstractEventLoop, + message_ref: str, + actions: list[dict[str, Any]], + ) -> dict[str, Any]: + """The post's props as they should be, with its buttons set to `actions`. + + Read back first rather than written fresh. A patch replaces a post's + props wholesale, and some of what is on them was put there by the + Mattermost server when the post was made β€” the marker saying it came + from a bot among them. Sending only the props Switch knows about would + strip those off the card as a side effect of taking a button off it. + + No actions means the key goes, which is how a settled card loses its + buttons: an empty list would leave an attachment on the post with + nothing in it. + """ + post = await loop.run_in_executor(None, driver.posts.get_post, message_ref) + props = dict(post.get("props") or {}) + if actions: + props[_ATTACHMENTS_PROP] = [{"actions": actions}] + else: + props.pop(_ATTACHMENTS_PROP, None) + return props + async def find_request_card( self, channel_id: str, diff --git a/core/switch_core/bridges/collaboration/mattermost/callback.py b/core/switch_core/bridges/collaboration/mattermost/callback.py index 691449521..900173180 100644 --- a/core/switch_core/bridges/collaboration/mattermost/callback.py +++ b/core/switch_core/bridges/collaboration/mattermost/callback.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from typing import Any +from switch_core.bridges.collaboration.session.renderers import Control + logger = logging.getLogger(__name__) # Where a press carries what it is answering. Nested under one key because @@ -58,6 +60,47 @@ def action_context(secret: str, token: str, position: int) -> dict[str, Any]: } +def answer_actions( + secret: str, url: str, token: str, controls: list[Control] +) -> list[dict[str, Any]]: + """The buttons a card offers, in the shape a Mattermost post carries them. + + One action per control, each addressed to this bridge's own callback URL + and carrying its own signed context β€” the option is in the credential, so + a press cannot be retargeted at another option by editing anything a + client can see. + + The id is written rather than left to the server, which mints one per + action that arrives without it. A card is redrawn many times over its + life, and an id regenerated on every redraw is a control the client + remounts underneath a reader who may be mid-press. Letters and digits + only: that is what Mattermost documents an action id may contain. + """ + return [ + { + "id": f"switch{control.position}", + "name": _button_name(control), + "integration": { + "url": url, + "context": action_context(secret, token, control.position), + }, + } + for control in controls + ] + + +def _button_name(control: Control) -> str: + """What the button says: the option's number, then the option. + + Numbered because the body numbers it, and a reader looking at "2." in the + text and "Decline" on a button should not have to work out that they are + the same choice. Not cut to a width, because Mattermost documents no limit + on how long a name may be. + """ + label = control.label.strip() or f"Option {control.position}" + return f"{control.position}. {label}" + + def read_press(secret: str, body: dict[str, Any]) -> Press | None: """What a callback is asking for, or None if it is not ours to act on. diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py new file mode 100644 index 000000000..6bbb50d8c --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py @@ -0,0 +1,333 @@ +"""Answering a Mattermost permission card by pressing it. + +The buttons are message-attachment actions, which Mattermost carries in the +post's props rather than in anything a reader sees. Each one holds an +`integration` β€” the URL the press is delivered to and a context to deliver with +it β€” and the server keeps that half to itself: it is never serialised to a +client, which is what makes it somewhere a credential can live. + +What travels in a button is the card's opaque token, the number beside the +option, and a signature over the two. Who pressed comes from the body +Mattermost posts, and both halves are resolved against the stored record rather +than trusted. + +The body still lists every option in full. A button's label has no documented +budget here, so there is no width at which an option could be called fully +shown by the control β€” and a numbered list is what a typed answer names. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +from switch_core.bridges.collaboration.mattermost.adapter import MattermostAdapter +from switch_core.bridges.collaboration.mattermost.callback import read_press +from switch_core.bridges.collaboration.session.form import ( + posted_form, + resolve_pressed_position, +) +from switch_core.bridges.collaboration.session.renderers import parse_answer_position +from switch_core.sessions.contract import ApprovalResult + +from .test_mattermost_press import ( + CALLBACK_BASE, + CHANNEL, + _adapter, + _body, + _key, + _record, +) +from .test_mattermost_sdk_only import _activity, _card, _posts + +CALLBACK_URL = f"{CALLBACK_BASE}/collaboration/mattermost/bridge-1/callback" + + +def _handled(**kwargs: Any) -> tuple[MattermostAdapter, list[Any]]: + """An adapter that takes presses, and the list of the ones it took.""" + adapter = _adapter(**kwargs) + return adapter, _record(adapter) + + +def _buttons(post: dict[str, Any]) -> list[dict[str, Any]]: + """Every action on a post, as Mattermost would find them in its props.""" + attachments = (post.get("props") or {}).get("attachments") or [] + return [action for attachment in attachments for action in attachment["actions"]] + + +def _created(adapter: MattermostAdapter) -> dict[str, Any]: + return _posts(adapter).created[0] + + +def _patched(adapter: MattermostAdapter) -> dict[str, Any]: + return _posts(adapter).patched[0][1] + + +# ── What the card offers ───────────────────────────────────────────────────── + + +async def test_an_open_card_offers_a_button_for_every_option_it_lists() -> None: + """Numbered the way the body numbers them and the way a typed answer names + them, so pressing and typing mean the same thing by the same word.""" + adapter, _ = _handled() + + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + assert [button["name"] for button in _buttons(_created(adapter))] == [ + "1. Allow once", + "2. Deny", + ] + + +async def test_every_button_is_addressed_to_this_bridges_own_callback_url() -> None: + """The listener is shared between bridges and routes on the path, so the + path is the whole of what says which bridge a press belongs to.""" + adapter, _ = _handled() + + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + assert {button["integration"]["url"] for button in _buttons(_created(adapter))} == { + CALLBACK_URL + } + + +async def test_a_button_carries_the_card_and_the_place_and_nothing_else() -> None: + """No option id, no actor, no label. Everything a client could rewrite is + resolved against the record, so the less a press carries the less there is + to resolve β€” and the signature is only as wide as what it covers.""" + adapter, _ = _handled() + + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + for button in _buttons(_created(adapter)): + context = button["integration"]["context"] + assert set(context) == {"switch"} + assert set(context["switch"]) == {"token", "position", "signature"} + assert "allow-once" not in str(_buttons(_created(adapter))) + + +async def test_each_button_is_signed_for_its_own_option() -> None: + """One credential per button rather than one per card. A context lifted off + the cheaper option cannot be posted back as the costlier one, because the + number it names is part of what was signed.""" + adapter, _ = _handled() + + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + contexts = [ + button["integration"]["context"]["switch"] + for button in _buttons(_created(adapter)) + ] + assert len({context["signature"] for context in contexts}) == 2 + presses = [read_press(_key(), _body({"switch": context})) for context in contexts] + assert [press.position for press in presses if press is not None] == [1, 2] + + +async def test_a_context_signed_for_one_option_does_not_verify_for_another() -> None: + adapter, _ = _handled() + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + first, second = _buttons(_created(adapter)) + + forged = dict(first["integration"]["context"]["switch"]) + forged["position"] = second["integration"]["context"]["switch"]["position"] + + assert read_press(_key(), _body({"switch": forged})) is None + + +async def test_the_body_still_lists_every_option_the_buttons_offer() -> None: + """A button's label has no documented limit here, so nothing is gained by + dropping the option from the body β€” and a card is answerable by typing + whether or not the reader's client drew the buttons.""" + adapter, _ = _handled() + + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + text = _created(adapter)["message"] + assert "1. Allow once" in text + assert "2. Deny" in text + assert "R7" in text + + +async def test_a_button_keeps_its_id_across_a_redraw() -> None: + """Mattermost mints an id for an action that arrives without one, and only + on the create path. A card is redrawn many times, and an id that changed + under a reader would remount the control they were mid-press on.""" + adapter, _ = _handled() + card = await _card() + ref = await adapter.post_rich(CHANNEL, "worker", card, "root-1") + + await adapter.update_rich(CHANNEL, "worker", ref, card, "root-1") + + assert [button["id"] for button in _buttons(_created(adapter))] == [ + "switch1", + "switch2", + ] + assert [button["id"] for button in _buttons(_patched(adapter))] == [ + "switch1", + "switch2", + ] + + +# ── When the buttons come off ──────────────────────────────────────────────── + + +async def test_a_settled_card_is_redrawn_without_its_buttons() -> None: + """Every redraw builds the actions again, so a request that settled loses + its controls without anything having to remember that it had them.""" + adapter, _ = _handled() + card = await _card() + ref = await adapter.post_rich(CHANNEL, "worker", card, "root-1") + + settled = replace( + card, request=card.request.model_copy(update={"state": "resolved"}) + ) + await adapter.update_rich(CHANNEL, "worker", ref, settled, "root-1") + + assert _buttons(_created(adapter)) != [] + assert _patched(adapter)["props"] == { + "from_bot": "true", + "switch_publication": "tok-1", + } + + +async def test_a_redraw_keeps_the_props_the_server_put_on_the_post() -> None: + """A patch replaces props wholesale, and what is on them is not only what + Switch sent: the marker saying the post came from a bot was added by + Mattermost. Writing the props fresh would take it off the card, and the + reader would stop being told who they are answering.""" + adapter, _ = _handled() + card = await _card() + ref = await adapter.post_rich(CHANNEL, "worker", card, "root-1") + + await adapter.update_rich(CHANNEL, "worker", ref, card, "root-1") + + props = _patched(adapter)["props"] + assert props["from_bot"] == "true" + assert props["switch_publication"] == "tok-1" + assert _buttons({"props": props}) != [] + + +async def test_a_card_that_cannot_be_answered_here_offers_nothing_to_press() -> None: + """It says why in its own words, and a live button under that sentence is + an invitation to the refusal the sentence just explained.""" + adapter, _ = _handled() + + await adapter.post_rich( + CHANNEL, + "worker", + await _card(unavailable_reason="Answer this one in the Console."), + "root-1", + ) + + assert _buttons(_created(adapter)) == [] + + +async def test_a_card_that_could_not_show_its_decision_offers_nothing_to_press() -> ( + None +): + """The body says it is too long to answer here β€” and a button beside that + sentence answers it anyway. The press would resolve against the saved form + and settle the request on text the reader never saw.""" + adapter, _ = _handled() + card = await _card() + clipped = card.request.model_copy( + update={ + "content": card.request.content.model_copy( + update={"detail": "Deletes the production volume. " * 2000} + ) + } + ) + + await adapter.post_rich(CHANNEL, "worker", replace(card, request=clipped), "root-1") + + assert "cannot be answered from this message" in _created(adapter)["message"] + assert _buttons(_created(adapter)) == [] + + +async def test_a_status_has_nothing_to_press() -> None: + adapter, _ = _handled() + + await adapter.post_rich( + CHANNEL, "worker", _activity(publication_token="tok-turn"), "root-1" + ) + + assert _buttons(_created(adapter)) == [] + + +async def test_a_statuss_redraw_leaves_the_posts_props_alone() -> None: + """Only a card has buttons, so only a card's redraw has any business + rewriting props β€” and a patch that carried them would have to read the post + back first for no gain.""" + adapter, _ = _handled() + ref = await adapter.post_rich( + CHANNEL, "worker", _activity(publication_token="tok-turn"), "root-1" + ) + + await adapter.update_rich( + CHANNEL, "worker", ref, _activity(publication_token="tok-turn"), "root-1" + ) + + assert set(_patched(adapter)) == {"message"} + + +# ── Bridges that draw none ─────────────────────────────────────────────────── + + +async def test_a_bridge_with_no_callback_address_draws_no_buttons() -> None: + """Nothing fails: the card still posts and is still answerable by typing. + A button whose press could not be delivered would be a control that reports + a failure of its own to whoever pressed it.""" + adapter, _ = _handled(callback_base_url=None) + + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + assert _buttons(_created(adapter)) == [] + assert _created(adapter)["props"] == {"switch_publication": "tok-1"} + + +async def test_a_bridge_that_takes_no_presses_draws_no_buttons() -> None: + """The address is reachable and the place on the listener is held, but + nothing is wired up to route a press: it would be refused on arrival.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + assert _buttons(_created(adapter)) == [] + + +async def test_a_bridge_that_draws_no_buttons_does_not_read_a_post_back() -> None: + """The read is what keeps a props rewrite from dropping the server's own + marks. Where there is nothing to rewrite there is nothing to protect, and a + deployment without buttons behaves exactly as it did before them.""" + adapter, _ = _handled(callback_base_url=None) + card = await _card() + ref = await adapter.post_rich(CHANNEL, "worker", card, "root-1") + _posts(adapter).read_error = AssertionError("the post was read back") + + await adapter.update_rich(CHANNEL, "worker", ref, card, "root-1") + + assert set(_patched(adapter)) == {"message"} + + +# ── The press that comes back ──────────────────────────────────────────────── + + +async def test_the_card_and_the_press_agree_on_the_option() -> None: + """The loop: the adapter draws the control, Mattermost hands the context + back, and the record turns it into the option the reader pressed.""" + adapter, seen = _handled() + card = await _card() + ref = await adapter.post_rich(CHANNEL, "worker", card, "root-1") + context = _buttons(_created(adapter))[1]["integration"]["context"] + + assert await adapter._handle_callback(_body(context, post_id=ref)) == {} + + interaction = seen[0] + assert interaction.value == card.reference.token + assert interaction.message_ref == ref + answer = resolve_pressed_position( + posted_form(card.request), + parse_answer_position(interaction.action_id) or 0, + ) + assert isinstance(answer, ApprovalResult) + assert answer.option_id == card.request.content.options[1].option_id diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index d76b89478..7c84f6eea 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -66,22 +66,43 @@ def __init__(self) -> None: self.delete_error: Exception | None = None self.thread_calls: list[str] = [] self.channel_calls: list[tuple[str, dict[str, Any] | None]] = [] + # Posts as the server holds them, so a caller that reads one back sees + # what its own writes left there. + self.stored: dict[str, dict[str, Any]] = {} self._next = iter(f"post-{n}" for n in range(1, 50)) def create_post(self, post: dict[str, Any]) -> dict[str, str]: if self.create_error: raise self.create_error self.created.append(post) - if self.created_id is not None: - return {"id": self.created_id} - return {"id": next(self._next)} + post_id = self.created_id if self.created_id is not None else next(self._next) + self.stored[post_id] = { + "id": post_id, + "message": post.get("message", ""), + # The server marks a post made by a bot as one, and nothing Switch + # sends says so. A caller rewriting props has to keep it. + "props": {"from_bot": "true"} | dict(post.get("props") or {}), + } + return {"id": post_id} def patch_post(self, post_id: str, body: dict[str, Any]) -> dict[str, str]: if self.patch_error: raise self.patch_error self.patched.append((post_id, body)) + held = self.stored.setdefault(post_id, {"id": post_id, "props": {}}) + if "message" in body: + held["message"] = body["message"] + if "props" in body: + held["props"] = dict(body["props"]) return {"id": post_id} + def get_post(self, post_id: str) -> dict[str, Any]: + if self.read_error: + raise self.read_error + if post_id not in self.stored: + raise ResourceNotFound(f"no post {post_id}") + return self.stored[post_id] + def delete_post(self, post_id: str) -> dict[str, str]: if self.delete_error: raise self.delete_error From 324b8a1999fdb063e44b82725fff8a35da7e5f3e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 15:48:25 +0100 Subject: [PATCH 081/120] Give an existing Mattermost bridge its callback address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seeder registered a fresh bridge with one and left an existing bridge alone, which meant every deployment that predates callbacks β€” including every developer's β€” kept drawing cards with no buttons and no way to say why. The dashboard is no help: its registration form is generated from the connection schema and does offer the field, but there is no form for editing a connection afterwards, only the two toggles. So the only cure was a hand-written API call. The seeder now sends the address on every run, the way it already adopts the default-bridge invariant. Sent rather than compared because a bridge's config is not readable back β€” it carries the admin password, so no endpoint returns it β€” and the patch is merged over what is stored, so nothing else has to be restated. The setup page picks up the rest: the API call for an operator who is not running the seeder, that a Mattermost container created before the allowlist landed has to be recreated rather than restarted, and what a reader actually sees on a card now that one carries buttons. Co-Authored-By: Claude Opus 5 --- deploy/shared_resources/setup.py | 19 ++++++++++++ docs/old/bridges/MATTERMOST_SETUP.md | 45 ++++++++++++++++++++++++++-- docs/old/bridges/README.md | 2 +- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/deploy/shared_resources/setup.py b/deploy/shared_resources/setup.py index 57c1223ac..776d703a6 100644 --- a/deploy/shared_resources/setup.py +++ b/deploy/shared_resources/setup.py @@ -243,6 +243,25 @@ def register_bridge(client: httpx.Client) -> str: f"/gateway/collaborations/{bridge_id}/default" ).raise_for_status() print(f"Set Mattermost bridge as default: {bridge_id}") + # Same reason: a bridge registered before callbacks existed + # would otherwise keep drawing cards with no buttons on them, + # and the only cure would be editing a connection field by + # hand. Sent every run rather than only when it is missing, + # because the config a bridge holds is not readable back β€” it + # carries the admin password, so no endpoint returns it. + if MATTERMOST_CALLBACK_BASE_URL: + client.patch( + f"/gateway/collaborations/{bridge_id}", + json={ + "connection_config": { + "callback_base_url": MATTERMOST_CALLBACK_BASE_URL + } + }, + ).raise_for_status() + print( + f"Set Mattermost callback address: " + f"{MATTERMOST_CALLBACK_BASE_URL}" + ) return bridge_id # Register new bridge diff --git a/docs/old/bridges/MATTERMOST_SETUP.md b/docs/old/bridges/MATTERMOST_SETUP.md index 2de791135..d682bd599 100644 --- a/docs/old/bridges/MATTERMOST_SETUP.md +++ b/docs/old/bridges/MATTERMOST_SETUP.md @@ -87,6 +87,23 @@ http://host.docker.internal:8081 # Mattermost in Docker, switch-core on t https://switch-callbacks.example.invalid # behind a reverse proxy ``` +**A bridge that already exists** cannot be given the field from the operator +dashboard: the registration form is generated from the connection schema and so +offers `callback_base_url`, but there is no form for editing a connection +afterwards β€” only the greetings and channel-creation toggles. Use the API, as a +gateway admin: + +```bash +curl -X PATCH "$GATEWAY_URL/gateway/collaborations/$BRIDGE_ID" \ + -H 'Content-Type: application/json' \ + -H "Cookie: switch_auth=$TOKEN" \ + -d '{"connection_config": {"callback_base_url": "http://switch:8081"}}' +``` + +The config is merged over what is stored, so the admin password does not have to +be re-sent, and the bridge is restarted so the change takes effect rather than +waiting for the next deploy. + **Mattermost must be allowed to call it.** Mattermost refuses outbound integration requests to private addresses unless the host is listed in System Console β†’ Environment β†’ Developer β†’ *Allow untrusted internal connections to* @@ -112,6 +129,19 @@ Two consequences: Those presses are refused and logged; the requests behind them stay answerable by typing. New cards work immediately. +**What a reader sees.** An open permission card gains one button per option, +numbered the way the card's own text numbers them, so pressing and typing name +the same choice. The buttons disappear when the request is answered, cancelled +or expires. A press by somebody who may not answer, or on a card that has +already settled, is explained to that person alone β€” nobody else in the channel +sees it. A card that says it is too long to answer from Mattermost carries no +buttons, because the reader has not been shown what they would be deciding. + +Buttons ride in the post's props, which an edit replaces wholesale, so a redraw +reads the post back and merges rather than overwriting what the Mattermost +server itself put there. It is one extra API call, made only for request cards +on bridges that take callbacks. + **Kubernetes.** The Helm chart does not publish the callback port yet, so a chart deployment needs the Service port and route added by hand for now; the `switchCore.teamsBridge` block in `values.yaml` is the shape it will take. @@ -141,9 +171,18 @@ Both stacks also wire up button presses ([step 3](#3-optional-let-mattermost-del the compose file allows Mattermost to call the private address, and the seeder sets `callback_base_url` β€” `http://switch:8081` under `standalone-up`, where switch-core is a service, and `http://host.docker.internal:8081` under `just up`, -where it runs on your host. The seeder skips a bridge that is **already** -registered, so a stack created before this existed keeps working without buttons -until you add `callback_base_url` to the bridge in the operator dashboard. +where it runs on your host. A bridge that is **already** registered keeps its +existing configuration, except for this one field: the seeder sets the callback +address on every run, because the config a bridge holds carries the admin +password and so is not readable back to compare against. A bridge registered +before callbacks existed therefore gains its buttons on the next stack start, +and the bridge restarts as part of that. + +A **Mattermost container** created before callbacks existed does not get the +allowlist by being restarted β€” its environment was fixed when it was created. +Recreate it (`docker compose up -d --force-recreate mattermost`; the volume and +so the data survive), or set the value by hand in System Console β†’ Environment β†’ +Developer. ## Notes diff --git a/docs/old/bridges/README.md b/docs/old/bridges/README.md index 4b6931c67..bbefd24a3 100644 --- a/docs/old/bridges/README.md +++ b/docs/old/bridges/README.md @@ -14,7 +14,7 @@ shared onboarding model, then the per-platform guide: | Platform | Guide | Identity model | Inbound transport | Public ingress | | --- | --- | --- | --- | --- | | Slack | [`SLACK_SETUP.md`](SLACK_SETUP.md) | single bot app | Socket Mode (outbound WS) | not required | -| Mattermost | [`MATTERMOST_SETUP.md`](MATTERMOST_SETUP.md) | one bot account per agent | WebSocket (outbound) | not required | +| Mattermost | [`MATTERMOST_SETUP.md`](MATTERMOST_SETUP.md) | one bot account per agent | WebSocket (outbound), plus HTTP push for button presses | not required, unless the Mattermost server is outside the network | | Microsoft Teams | [`TEAMS_SETUP.md`](TEAMS_SETUP.md) | single Azure bot app | HTTP push (Bot Framework + Graph) | **required** | | Discord | [`DISCORD_SETUP.md`](DISCORD_SETUP.md) | single bot app | Gateway WebSocket (outbound) | not required | | Telegram | [`TELEGRAM_SETUP.md`](TELEGRAM_SETUP.md) | single bot, agent named in the message body | long polling (outbound) | not required | From b0fa2066c401d981c816d27acf7c791d086d3018 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 16:28:30 +0100 Subject: [PATCH 082/120] Keep a streamed turn to three blocks however long it runs A streamed activity message grew without bound and repeated itself. Two defects, one code path. The Console link went out on a `task_update` that was re-sent whenever the card moved. `details` on a `task_update` *appends* to what the card already holds rather than replacing it, so the link accumulated: a reader watching a long turn saw "Open in Console app" nine times. Measured against the live API, not inferred. The link now lives on one card of its own, sent once. The steps were `task_update` chunks too, and a stream cannot take a card back. Past fifty Slack silently stored no more, so the header disclosed a count of steps nobody could ever see. They move into ordinary `plan` blocks carried inside the stream, addressed by `block_id` and replaced whole. Two of those blocks rotate through fifty-step pages, so the newest hundred steps are always on screen and the message is the same three blocks at step 5 and at step 230. What fell off the front is said in the older page's title, which is the line a reader sees with the block collapsed. Boundaries are fixed rather than a sliding window: a step never moves between pages once it has landed, so a settled page is not rewritten and a card the reader opened is still the card they opened. Checked against the live API over 230 steps: 231 appends accepted, the finished message holds three blocks, and the link appears once. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 166 ++++++---- .../bridges/collaboration/slack/adapter.py | 111 +++---- .../test_session_slack_streaming.py | 295 ++++++++++++++---- 3 files changed, 397 insertions(+), 175 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index be139d64f..5f44a547c 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -141,12 +141,20 @@ # Slack measures a `task_update` or `plan_update` chunk serialised and rejects # the whole append over 256 characters, taking the other chunks in it with it. -# The budget is spent title first: a card whose detail was cut still says what -# it did, where one whose title was cut says nothing. +# The steps are not chunks and are not bound by this β€” they ride in `blocks` +# chunks, which take kilobytes β€” so what it bounds is the header and the one +# card the stream's own plan holds. _MAX_CHUNK = 256 -# Below this a trimmed detail is an ellipsis with a word in front of it, which -# takes room from the title to say nothing. Drop it instead. -_MIN_CHUNK_DETAILS = 12 + +# The two blocks a stream draws its steps in, oldest first. Slack keeps a block +# at the position it was first written, so which of these holds the older page +# is fixed by the order they are created in and never changes after that. +_STEP_PAGES = ("switch-steps-older", "switch-steps-newer") + +# The single card in the stream's own plan. Sent once: `details` on a +# `task_update` appends to what the card already has rather than replacing it, +# so a card re-sent with the same link shows the link twice. +_SESSION_CARD = "switch-session" # Slack's three task states against the contract's four. `declined` is not an # error β€” the call did what it was told, and what it was told was no β€” but @@ -1178,7 +1186,8 @@ class StreamedActivity: """ title: str - tasks: list[dict[str, Any]] + session: dict[str, Any] + pages: list[dict[str, Any]] def render_activity_stream( @@ -1186,54 +1195,100 @@ def render_activity_stream( turn: TurnUpsert, *, elapsed_seconds: float | None = None, - omitted: int = 0, + session_url: str | None = None, ) -> StreamedActivity: """The same turn as `render_activity`, shaped for `chat.appendStream`. - A streamed plan and a posted one draw the same thing and are built - differently. A posted plan is one block replaced whole, so it can show a - window onto the newest steps and drop the rest. A stream only ever adds: - a card that has been appended stays, and there is no call that removes it. - So the cap here is on cards ever *created*, `omitted` is what the caller - could not create once it hit that cap, and the header says so β€” the same - disclosure the block form makes about its window, for the opposite reason. - - `details` is a plain string, where the `task_card` block wants a rich_text - entity. Measured against the live API, which rejects every rich_text shape - on a chunk; Slack stores what it is given here as rich_text anyway, so the - two paths render identically despite taking different input. + Three pieces, because a streamed message is drawn in two different ways at + once. The header and the card under it belong to the stream's own plan, + which is addressed with chunks and can only ever be added to. The steps + belong to ordinary `plan` blocks carried inside the stream, which are + addressed by `block_id` and are replaced whole β€” so unlike the stream's + plan they can drop a step, reorder one, or hold a different fifty than + they held a minute ago. + + That is what lets a long turn stay one readable message. The stream's plan + holds the status line and nothing that grows; the steps live in two blocks + that rotate, so a turn of any length draws the same handful of blocks. """ did = [item for item in items if item.kind == "tool-activity"] - return StreamedActivity( - title=_fit( - _activity_title( - items, turn, elapsed_seconds=elapsed_seconds, omitted=omitted - ), + header = { + "type": "plan_update", + "title": _fit( + _activity_title(items, turn, elapsed_seconds=elapsed_seconds), _MAX_PLAN_TITLE, ), - tasks=[_stream_task(item, turn) for item in did], + } + return StreamedActivity( + title=_within_chunk(header)["title"], + session=_session_card(session_url), + pages=_step_pages([_settled(_plan_task(item), item, turn) for item in did]), ) -def _stream_task(item: Item, turn: TurnUpsert) -> dict[str, Any]: - """One tool call as a streaming chunk. +def _session_card(session_url: str | None) -> dict[str, Any]: + """The one card in the stream's own plan, and where the link lives. + + A streamed plan with no cards in it does not draw at all, so without this + the status line would have nowhere to appear. It doubles as the place the + Console link is asked to be: first row of the first block, visible in the + same expansion that opens the plan. - The card the same item draws in a plan block, with the two differences the - chunk form insists on: `id` rather than `task_id`, and a plain-string - detail where the block wants a rich_text entity. Trimmed to what an append - will carry β€” see `_within_chunk`. + The link is dropped rather than trimmed when it will not fit. `_within_chunk` + cuts a detail to make a chunk fit, and half a URL is not a link β€” it is a + line of text that looks like one and goes nowhere. """ - card = _settled(_plan_task(item), item, turn) - chunk: dict[str, Any] = { + card: dict[str, Any] = { "type": "task_update", - "id": card["task_id"], - "title": card["title"], - "status": card["status"], + "id": _SESSION_CARD, + "title": "Switch session", + "status": "complete", } - details = plain_text(item.text) if item.text else "" - if details: - chunk["details"] = _fit(details, _MAX_PLAN_TASK_DETAILS) - return _within_chunk(chunk) + if not session_url or urlsplit(session_url).scheme not in { + "https", + "http", + "switchdash", + }: + return card + linked = {**card, "details": f"<{session_url}|Open in Console app>"} + return linked if _chunk_length(linked) <= _MAX_CHUNK else card + + +def _step_pages(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: + """The steps as the last two plan blocks that will hold them. + + Pages are cut on fixed boundaries β€” the first fifty, the next fifty β€” so a + step never moves between pages once it has landed in one, and only the two + newest pages are drawn. Everything before them is gone from the message, + which the older page says in its own title: that is the line a reader sees + with the block collapsed, so a cut nobody is told about is one nobody can + see. + + The pages are written to a fixed pair of block ids rather than one per + fifty. Slack keeps a block where it was first written and has no call that + removes one, so a new id per page would grow the message without bound and + in the wrong order. Rotating the contents through two ids keeps both the + length and the order fixed however long the turn runs. + """ + if not steps: + return [] + last = (len(steps) - 1) // _MAX_PLAN_TASKS + pages: list[dict[str, Any]] = [] + for slot, page in enumerate(range(max(0, last - 1), last + 1)): + start = page * _MAX_PLAN_TASKS + shown = steps[start : start + _MAX_PLAN_TASKS] + title = f"Steps {start + 1}–{start + len(shown)}" + if start and not slot: + title += f" Β· {start} earlier not shown" + pages.append( + { + "type": "plan", + "block_id": _STEP_PAGES[slot], + "title": _truncate(title, _MAX_PLAN_TITLE), + "tasks": shown, + } + ) + return pages def _settled(task: dict[str, Any], item: Item, turn: TurnUpsert) -> dict[str, Any]: @@ -1253,25 +1308,24 @@ def _settled(task: dict[str, Any], item: Item, turn: TurnUpsert) -> dict[str, An def _within_chunk(chunk: dict[str, Any]) -> dict[str, Any]: - """Trim a chunk to the 256 characters an append will accept. + """Trim a chunk's title to the 256 characters an append will accept. - Detail goes before title, and a detail with nothing useful left goes - entirely rather than becoming a lone ellipsis. The title is cut last and - never dropped: a card has to say what it is. + Cut rather than dropped: the header is the whole of a collapsed block, and + one that says nothing is worse than one that says half. - Each cut is measured again rather than worked out from the overshoot, - because the budget is spent on the serialised form and a character does - not cost one there β€” the ellipsis a trim adds is six. + Each cut is measured again, and taken as a proportion of the overshoot + rather than a count of characters off it, because the budget is spent on + the serialised form where a character does not cost one: an emoji costs + twelve. Counting characters can ask for a cut longer than the text, which + is how this used to give up and hand Slack a chunk it would reject β€” + taking every other chunk in the same append down with it. """ - for field in ("details", "title"): - while (over := _chunk_length(chunk) - _MAX_CHUNK) > 0 and chunk.get(field): - keep = len(chunk[field]) - over - if field == "details" and keep < _MIN_CHUNK_DETAILS: - del chunk["details"] - break - if keep < 1: - break - chunk[field] = _shorten(chunk[field], keep) + while _chunk_length(chunk) > _MAX_CHUNK and chunk.get("title"): + keep = len(chunk["title"]) * _MAX_CHUNK // _chunk_length(chunk) + cut = _shorten(chunk["title"], max(keep, 1)) + if cut == chunk["title"]: + break + chunk["title"] = cut return chunk diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 62e6d3821..55e984b7a 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -46,7 +46,6 @@ ) from switch_core.bridges.collaboration.session.renderers.slack import ( SlackMessage, - StreamedActivity, render_activity, render_activity_plan, render_activity_stream, @@ -169,10 +168,6 @@ class SlackConnectionConfig(BridgeConnectionConfig): ) -# A stream cannot take a card back, so this caps cards ever created rather -# than cards shown. Slack draws at most 50 tasks in a plan and drops the rest. -_MAX_STREAM_TASKS = 50 - # A turn whose end never arrives β€” the agent died, the session was dropped β€” # leaves its stream open with nothing to close it. Far more than this many at # once is a bridge holding turns nobody is waiting on, so the oldest goes. @@ -197,24 +192,19 @@ class _ActivityStream: A stream is a conversation, not a document: Slack keeps the message and each append moves part of it. So the adapter has to remember what it last - said to work out what is worth saying next β€” sending a card that has not - changed costs an append and risks nothing useful. + said to work out what is worth saying next β€” resending a page of fifty + cards that has not changed costs an append and risks nothing useful. - `created` counts cards ever opened rather than cards currently interesting, - because a stream cannot take one back. Once it reaches the cap, later tool - calls are counted into `omitted` and disclosed in the header instead. + `session` is the card the status line is drawn around, held here because it + can only be sent once. `pages` is the last thing written to each step block, + by `block_id`, so a redraw sends only the page that actually moved. """ channel_id: str ts: str title: str = "" - cards: dict[str, dict[str, Any]] = field(default_factory=dict) - omitted: int = 0 - linked: bool = False - - @property - def created(self) -> int: - return len(self.cards) + session: dict[str, Any] | None = None + pages: dict[str, dict[str, Any]] = field(default_factory=dict) class SlackAdapter(CollaborationAdapter): @@ -810,10 +800,14 @@ async def _extend_stream( ) -> None: """Send what changed since the last append, and close a finished turn. - Only the header and the cards that actually moved. Re-sending a card - Slack already has is what makes the difference between this and an - edit: the same id merges into the card that is there, so the message - around it β€” and whatever the reader has open β€” is left alone. + Only the header, the session card while it is still owed, and the step + pages that actually moved. A page is one `blocks` chunk carrying one + `plan`: Slack replaces the block it names and leaves the rest of the + message β€” and whatever the reader has open β€” alone. + + One chunk per plan. Slack refuses a `blocks` chunk holding more than a + single plan block, though any number of such chunks ride in one append + alongside the header and the card. """ client = self._web_client if client is None: @@ -821,43 +815,21 @@ async def _extend_stream( if message_ref in self._streams: self._streams.move_to_end(message_ref) - def draw(omitted: int) -> StreamedActivity: - return render_activity_stream( - content.items, - content.turn, - elapsed_seconds=content.elapsed_seconds, - omitted=omitted, - ) - - moved: list[dict[str, Any]] = [] - omitted = stream.omitted - room = _MAX_STREAM_TASKS - stream.created - for task in draw(omitted).tasks: - known = stream.cards.get(task["id"]) - if known == task: - continue - if known is None: - if room <= 0: - omitted += 1 - continue - room -= 1 - moved.append(task) - # Counted here rather than before the loop because a card cut just now - # has to be disclosed by the header that goes out beside it. - title = draw(omitted).title - + drawn = render_activity_stream( + content.items, + content.turn, + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + ) chunks: list[dict[str, Any]] = [] - if title != stream.title: - chunks.append({"type": "plan_update", "title": title}) - chunks.extend(moved) - link = bool(content.session_url) and not stream.linked - if link: - chunks.append( - { - "type": "markdown_text", - "text": f"<{content.session_url}|Open in Console app>", - } - ) + if drawn.title != stream.title: + chunks.append({"type": "plan_update", "title": drawn.title}) + if self._session_owed(stream.session, drawn.session): + chunks.append(drawn.session) + moved = [ + page for page in drawn.pages if stream.pages.get(page["block_id"]) != page + ] + chunks.extend({"type": "blocks", "blocks": [page]} for page in moved) if chunks: try: @@ -868,15 +840,28 @@ def draw(omitted: int) -> StreamedActivity: # Nothing below runs: every path out of here raises. The # stream's record of what Slack holds stays as it was, so a # retry sends the same chunks rather than assuming they landed. - self._stream_failed(error, message_ref, title) - stream.title = title - stream.omitted = omitted - stream.linked = stream.linked or link - for task in moved: - stream.cards[task["id"]] = task + self._stream_failed(error, message_ref, drawn.title) + stream.title = drawn.title + stream.session = drawn.session + for page in moved: + stream.pages[page["block_id"]] = page if content.turn.status in TURN_ENDED: await self._close_stream(client, stream, message_ref) + @staticmethod + def _session_owed(sent: dict[str, Any] | None, drawn: dict[str, Any]) -> bool: + """Whether the session card still has something Slack has not been told. + + Once, when the stream opens, and once more if the Console link only + turned up later β€” a card sent without a detail can still be given one, + because appending to nothing leaves just the link. It is never sent a + third time: `details` on a `task_update` appends, so a card that + already carries the link would come back carrying it twice. + """ + if sent is None: + return True + return "details" in drawn and "details" not in sent + async def _close_stream( self, client: AsyncWebClient, stream: _ActivityStream, message_ref: str ) -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py index ca7ebd717..59321ef75 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -5,11 +5,22 @@ That is why the clock used to live in a message of its own: at one redraw every five seconds, anything open collapsed before it could be read. -`chat.appendStream` does not replace anything. A `task_update` carrying an id -Slack already holds merges into that card, and a `plan_update` moves the header -without touching the cards at all. So the two messages become one, the clock -ticks in its header, and an expanded step stays expanded. Measured against the -live API before it was built, not assumed. +`chat.appendStream` does not replace the message. A `plan_update` moves the +header without touching anything under it, and a `blocks` chunk replaces the one +block it names and leaves the rest of the message β€” and whatever the reader has +open β€” alone. So the two messages become one, the clock ticks in its header, and +an expanded step stays expanded. Measured against the live API before it was +built, not assumed. + +A streamed message is drawn in two ways at once, and the difference is the whole +shape of this. The stream's own plan is addressed with chunks and can only ever +be added to β€” a card cannot be taken back, and `details` on a `task_update` +*appends* to what the card already holds rather than replacing it. So that plan +carries the status line and one card that is sent once, and nothing that grows. +The steps live in ordinary `plan` blocks carried inside the stream, addressed by +`block_id` and replaced whole, so they can hold a different fifty than they held +a minute ago. Two of those blocks rotate, and a turn of any length draws the same +handful of blocks. What these cover is the adapter's half: that it opens a stream where it can, sends only what moved, discloses what it had to leave out, stops at the end of @@ -89,6 +100,7 @@ def _chunks(client: FakeWebClient) -> list[list[dict[str, Any]]]: def _cards(client: FakeWebClient) -> list[dict[str, Any]]: + """Every `task_update` sent: the stream's own plan, which is the status.""" return [ chunk for call in client.appended @@ -97,6 +109,41 @@ def _cards(client: FakeWebClient) -> list[dict[str, Any]]: ] +def _pages(client: FakeWebClient) -> dict[str, dict[str, Any]]: + """The last plan written to each step block, in the order the blocks appeared. + + Slack keeps a block where it was first written, so insertion order here is + the order a reader sees them down the message. + """ + pages: dict[str, dict[str, Any]] = {} + for call in client.appended: + for chunk in call["chunks"]: + if chunk["type"] == "blocks": + for block in chunk["blocks"]: + pages[block["block_id"]] = block + return pages + + +def _steps(client: FakeWebClient) -> list[dict[str, Any]]: + """Every step card the message is currently showing, oldest first.""" + return [task for page in _pages(client).values() for task in page["tasks"]] + + +def _sized(client: FakeWebClient) -> list[dict[str, Any]]: + """The chunks Slack would refuse: over 256 characters serialised. + + `blocks` chunks are not on this budget β€” a fifty-card page is kilobytes and + the live API takes it β€” so only the header and the session card are measured. + """ + return [ + chunk + for call in client.appended + for chunk in call["chunks"] + if chunk["type"] in {"plan_update", "task_update"} + and len(json.dumps(chunk, separators=(",", ":"))) > 256 + ] + + # ── Opening ────────────────────────────────────────────────────────────────── @@ -142,12 +189,12 @@ async def test_the_asker_is_whoever_last_spoke_in_the_thread_and_not_a_bot() -> # ── Sending only what moved ────────────────────────────────────────────────── -async def test_only_the_header_and_the_cards_that_changed_are_appended() -> None: +async def test_only_the_header_and_the_pages_that_moved_are_appended() -> None: """The whole point of a stream over an edit. - A redraw that re-sent every card would cost what an edit costs and lose the - property it was chosen for, so a card Slack already holds unchanged is not - sent again. + An edit replaces the message's whole blocks array; an append replaces the + one block it names. The header moves on its own, the page of steps is + rewritten whole, and the session card goes out once and never again. """ client = FakeWebClient() adapter = _adapter(client) @@ -161,12 +208,50 @@ async def test_only_the_header_and_the_cards_that_changed_are_appended() -> None CHANNEL, "Agent", ref, TurnActivity([done, second], _turn(), 9.0), THREAD ) - assert [c["type"] for c in _chunks(client)[0]] == ["plan_update", "task_update"] + opened = _chunks(client)[0] + assert [c["type"] for c in opened] == ["plan_update", "task_update", "blocks"] later = _chunks(client)[1] - assert [c["type"] for c in later] == ["plan_update", "task_update", "task_update"] + assert [c["type"] for c in later] == ["plan_update", "blocks"] assert later[0]["title"] == "Working… 9s Β· Running: Grep" - assert (later[1]["title"], later[1]["status"]) == ("Read", "complete") - assert (later[2]["title"], later[2]["status"]) == ("Grep", "in_progress") + assert [(t["title"], t["status"]) for t in later[1]["blocks"][0]["tasks"]] == [ + ("Read", "complete"), + ("Grep", "in_progress"), + ] + + +async def test_a_blocks_chunk_never_carries_more_than_one_plan() -> None: + """Measured, not read: Slack refuses a `blocks` chunk holding two plan + blocks and takes the whole append with it. Several such chunks in one + append are fine, which is how both pages move together.""" + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(51)] + + await adapter.post_rich(CHANNEL, "Agent", TurnActivity(many, _turn()), THREAD) + + sent = [c for c in _chunks(client)[0] if c["type"] == "blocks"] + assert len(sent) == 2 + assert all(len(chunk["blocks"]) == 1 for chunk in sent) + + +async def test_a_page_that_did_not_move_is_not_sent_again() -> None: + """A full page is fifty cards and several kilobytes. Once a step lands in + one it never moves to another, so a settled page is left where it is.""" + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(50)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many, _turn(), 1.0), THREAD + ) + over = [*many, _tool("t50", "Tool 50", status="completed")] + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(over, _turn(), 9.0), THREAD + ) + + later = [c for c in _chunks(client)[1] if c["type"] == "blocks"] + assert len(later) == 1 + assert later[0]["blocks"][0]["title"] == "Steps 51–51" async def test_a_publish_that_changed_nothing_appends_nothing() -> None: @@ -199,9 +284,15 @@ async def test_the_clock_moves_the_header_without_resending_a_card() -> None: ] -async def test_a_card_carries_its_detail_as_a_plain_string() -> None: - """Measured, not read: the live API rejects every rich_text shape here, - though the `task_card` block of the same name requires one.""" +async def test_a_detail_takes_the_shape_of_the_place_it_is_sent_to() -> None: + """One field name, two shapes, and Slack rejects the wrong one. + + Measured, not read: `details` on a `task_update` chunk is a plain string and + the live API refuses every rich_text form of it, while `details` on a + `task_card` inside a `plan` block requires rich_text and refuses the string. + The step cards moved from the first to the second, so the shape moved with + them β€” and getting it wrong takes down the whole append. + """ client = FakeWebClient() adapter = _adapter(client) @@ -209,49 +300,83 @@ async def test_a_card_carries_its_detail_as_a_plain_string() -> None: CHANNEL, "Agent", TurnActivity( - [_tool("t1", "Read", status="completed", text="312 lines")], _turn() + [_tool("t1", "Read", status="completed", text="312 lines")], + _turn(), + session_url="https://switch.example/session", ), THREAD, ) - assert _cards(client)[0]["details"] == "312 lines" + assert _cards(client)[0]["details"] == ( + "" + ) + assert _steps(client)[0]["details"]["type"] == "rich_text" + assert _steps(client)[0]["details"]["elements"][0]["elements"][0] == { + "type": "text", + "text": "312 lines", + } async def test_no_chunk_exceeds_what_an_append_will_carry() -> None: """Slack measures a chunk serialised and rejects the append over 256 - characters, taking every other chunk in it down with it. The title is what - survives: a card whose detail was cut still says what it did.""" + characters, taking every other chunk in it down with it. + + Only the header and the session card are chunks. A step is a card inside a + block, which is not on this budget β€” which is why its detail survives here + where a chunk's would have been spent to keep the title.""" client = FakeWebClient() adapter = _adapter(client) tool = _tool("t1", "R" * 400, status="completed", text="D" * 400) await adapter.post_rich(CHANNEL, "Agent", TurnActivity([tool], _turn()), THREAD) - card = _cards(client)[0] - assert len(json.dumps(card, separators=(",", ":"))) <= 256 - assert card["title"].startswith("RRR") - assert "details" not in card + assert _sized(client) == [] + step = _steps(client)[0] + assert step["title"].startswith("RRR") + assert step["details"]["elements"][0]["elements"][0]["text"].startswith("DDD") + + +async def test_the_budget_holds_for_text_that_is_not_one_byte_a_character() -> None: + """The budget is spent on the serialised form, where an emoji costs twelve + characters and not one. Counting characters asks for a cut longer than the + text, gives up, and hands Slack a chunk it refuses β€” with the whole append.""" + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "\U0001f680" * 200, status="completed", text="βœ“" * 200) + + await adapter.post_rich(CHANNEL, "Agent", TurnActivity([tool], _turn()), THREAD) + + assert _sized(client) == [] + header = _chunks(client)[0][0] + assert header["type"] == "plan_update" + assert "\U0001f680" in header["title"] async def test_a_trim_never_leaves_half_an_escaped_character_on_screen() -> None: """The value being cut has already been escaped, so a blind slice can leave - `&am` in front of the reader instead of an `&`.""" + `&am` in front of the reader instead of an `&`. + + The header is the one place this can still happen. It is cut twice β€” once + to its own character budget, then again to fit the chunk β€” and the second + cut is made on text that has already been escaped, because by then the + escaping is part of what is being measured. + """ client = FakeWebClient() adapter = _adapter(client) + tool = _tool("t1", "&βœ“" * 200, status="completed") - await adapter.post_rich( - CHANNEL, - "Agent", - TurnActivity([_tool("t1", "&" * 200, status="completed")], _turn()), - THREAD, - ) + await adapter.post_rich(CHANNEL, "Agent", TurnActivity([tool], _turn()), THREAD) - title = _cards(client)[0]["title"] + title = _chunks(client)[0][0]["title"] assert title.endswith("…") - assert re.fullmatch(r"(&)+", title[:-1]) + assert "&" in title + assert not re.search(r"&(?!amp;|lt;|gt;)", title) -async def test_the_console_link_goes_out_once(caplog: Any) -> None: +async def test_the_console_link_goes_out_once() -> None: + """`details` on a `task_update` appends to what the card already holds + rather than replacing it, so a card re-sent with the same link shows the + link twice β€” and again on every redraw after that.""" client = FakeWebClient() adapter = _adapter(client) tool = _tool("t1", "Read") @@ -260,42 +385,100 @@ async def test_the_console_link_goes_out_once(caplog: Any) -> None: ref = await adapter.post_rich( CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0, session_url=url), THREAD ) + for elapsed in (10.0, 20.0, 30.0): + await adapter.update_rich( + CHANNEL, + "Agent", + ref, + TurnActivity([tool], _turn(), elapsed, session_url=url), + THREAD, + ) + + assert [card["details"] for card in _cards(client)] == [ + f"<{url}|Open in Console app>" + ] + + +async def test_a_link_that_only_turns_up_later_is_still_sent() -> None: + """Appending to a card that has no detail yet leaves just the link, so the + one card the stream owns is not spent before the session url arrives.""" + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + url = "https://switch.example/session" + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0), THREAD + ) await adapter.update_rich( CHANNEL, "Agent", ref, - TurnActivity([tool], _turn(), 20.0, session_url=url), + TurnActivity([tool], _turn(), 9.0, session_url=url), THREAD, ) - links = [ - chunk - for call in client.appended - for chunk in call["chunks"] - if chunk["type"] == "markdown_text" + assert [card.get("details") for card in _cards(client)] == [ + None, + f"<{url}|Open in Console app>", ] - assert len(links) == 1 - assert url in links[0]["text"] - -# ── The cap ────────────────────────────────────────────────────────────────── +# ── Paging ─────────────────────────────────────────────────────────────────── -async def test_cards_past_the_cap_are_left_out_and_the_header_says_so() -> None: - """A stream cannot take a card back, so the cap is on cards ever created. - The posted plan drops its oldest and keeps a window on the newest; this - cannot, so it keeps the oldest and says how many it could not open. Either - way the reader is told the log is not complete. +async def test_a_turn_of_any_length_draws_the_same_two_step_blocks() -> None: + """Slack keeps a block where it was first written and has no call that + removes one, so a block per fifty steps would grow the message without + bound. Rotating the newest two pages through a fixed pair of ids keeps both + the length and the order of the message fixed however long the turn runs. """ client = FakeWebClient() adapter = _adapter(client) - many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(53)] + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(240)] - await adapter.post_rich(CHANNEL, "Agent", TurnActivity(many, _turn()), THREAD) + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many[:1], _turn(), 1.0), THREAD + ) + for size in range(2, len(many) + 1, 7): + await adapter.update_rich( + CHANNEL, + "Agent", + ref, + TurnActivity(many[:size], _turn(), float(size)), + THREAD, + ) + + pages = _pages(client) + assert len(pages) == 2 + older, newer = pages.values() + assert older["title"] == "Steps 151–200 Β· 150 earlier not shown" + assert [task["title"] for task in older["tasks"]][:1] == ["Tool 150"] + assert newer["title"] == "Steps 201–240" + assert [task["title"] for task in newer["tasks"]][-1:] == ["Tool 239"] + + +async def test_a_step_never_moves_between_pages_once_it_has_landed() -> None: + """Pages are cut on fixed boundaries β€” the first fifty, the next fifty β€” + rather than as a window on the newest hundred. A window would shuffle every + card down one on every step, which is a redraw of both blocks each time and + a card that is not the one the reader opened.""" + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(120)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many[:51], _turn(), 1.0), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(many[:52], _turn(), 9.0), THREAD + ) - assert len(_cards(client)) == 50 - assert "3 earlier steps not shown" in _chunks(client)[0][0]["title"] + moved = [c for c in _chunks(client)[1] if c["type"] == "blocks"] + assert [chunk["blocks"][0]["title"] for chunk in moved] == ["Steps 51–52"] + older, newer = _pages(client).values() + assert [task["title"] for task in older["tasks"]][:1] == ["Tool 0"] + assert [task["title"] for task in newer["tasks"]] == ["Tool 50", "Tool 51"] # ── Ending ─────────────────────────────────────────────────────────────────── @@ -334,7 +517,7 @@ async def test_an_unfinished_step_is_named_rather_than_left_spinning() -> None: CHANNEL, "Agent", ref, TurnActivity([tool], _turn("interrupted"), 4.0), THREAD ) - last = _cards(client)[-1] + last = _steps(client)[-1] assert last["status"] == "complete" assert last["title"] == "Unfinished: Read" @@ -546,7 +729,7 @@ async def test_a_turn_published_from_start_to_finish_is_one_streamed_message() - assert [ [chunk["type"] for chunk in call["chunks"]] for call in client.appended ] == [ - ["plan_update", "task_update"], + ["plan_update", "task_update", "blocks"], ["plan_update"], - ["plan_update", "task_update"], + ["plan_update", "blocks"], ] From bd150be167827a80d642ee445e80d6a47f8469d5 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 16:43:45 +0100 Subject: [PATCH 083/120] Say what a stream dropped on a line of its own, and keep the link whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the streamed turn, all in the same path. The Console link was being dropped from the session card whenever the card serialised past 256 characters, which every real session url does: the raw switchdash form runs to 186 and the gateway redirect Slack actually gets to 205. The 256 came from a refusal seen once and never searched for. Bisecting the live API found no boundary below 8000 characters for a task_update's details, its title, its id, a plan_update title, or two cards in one append, and a 213-character production-shaped url is accepted and stored as a link. So the budget and the trimming built on it are gone, and the whole url goes in the card. What the message had to leave out was written into the older section's title. It is now one cumulative line of its own above both sections, naming the whole missing range β€” Steps 1-150 no longer shown β€” rather than a count a reader has to add up. That line has to be created before the sections it describes, because Slack fixes a block where it was first written, so the top of the three block ids starts as the first page of steps and is replaced in place by the line once there is something to disclose. The adapter was remembering the session card it last drew rather than the one it sent, so a publish that arrived without the url erased the record of the link and the next one appended the same link to a card already carrying it. Verified against the live API over 230 steps in the adapter's own chunk order: four blocks in the finished message, the disclosure line above the two pages, the 213-character url stored whole, and the link present exactly once. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 172 ++++++---------- .../bridges/collaboration/slack/adapter.py | 30 +-- .../test_session_slack_streaming.py | 192 ++++++++++-------- 3 files changed, 193 insertions(+), 201 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 5f44a547c..b705bf45c 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -27,7 +27,6 @@ from __future__ import annotations import hashlib -import json import re from dataclasses import dataclass from html import unescape @@ -139,17 +138,14 @@ _MAX_TASK_ID = 64 -# Slack measures a `task_update` or `plan_update` chunk serialised and rejects -# the whole append over 256 characters, taking the other chunks in it with it. -# The steps are not chunks and are not bound by this β€” they ride in `blocks` -# chunks, which take kilobytes β€” so what it bounds is the header and the one -# card the stream's own plan holds. -_MAX_CHUNK = 256 - -# The two blocks a stream draws its steps in, oldest first. Slack keeps a block -# at the position it was first written, so which of these holds the older page -# is fixed by the order they are created in and never changes after that. -_STEP_PAGES = ("switch-steps-older", "switch-steps-newer") +# The three blocks a stream draws its steps in, top to bottom. Slack fixes a +# block at the position it was first written and has no call that removes one, +# so where each one sits is decided by the order they are created in and cannot +# be changed afterwards. That is the whole reason there are three: the top block +# has to exist before either of the others to be able to carry the line saying +# what is no longer shown, so it starts as the first page of steps and becomes +# that line when there is something to disclose. +_STEP_BLOCKS = ("switch-steps-top", "switch-steps-middle", "switch-steps-bottom") # The single card in the stream's own plan. Sent once: `details` on a # `task_update` appends to what the card already has rather than replacing it, @@ -1187,7 +1183,7 @@ class StreamedActivity: title: str session: dict[str, Any] - pages: list[dict[str, Any]] + blocks: list[dict[str, Any]] def render_activity_stream( @@ -1208,21 +1204,17 @@ def render_activity_stream( they held a minute ago. That is what lets a long turn stay one readable message. The stream's plan - holds the status line and nothing that grows; the steps live in two blocks - that rotate, so a turn of any length draws the same handful of blocks. + holds the status line and nothing that grows; the steps live in three + blocks that rotate, so a turn of any length draws the same four blocks. """ did = [item for item in items if item.kind == "tool-activity"] - header = { - "type": "plan_update", - "title": _fit( + return StreamedActivity( + title=_fit( _activity_title(items, turn, elapsed_seconds=elapsed_seconds), _MAX_PLAN_TITLE, ), - } - return StreamedActivity( - title=_within_chunk(header)["title"], session=_session_card(session_url), - pages=_step_pages([_settled(_plan_task(item), item, turn) for item in did]), + blocks=_step_blocks([_settled(_plan_task(item), item, turn) for item in did]), ) @@ -1234,9 +1226,11 @@ def _session_card(session_url: str | None) -> dict[str, Any]: Console link is asked to be: first row of the first block, visible in the same expansion that opens the plan. - The link is dropped rather than trimmed when it will not fit. `_within_chunk` - cuts a detail to make a chunk fit, and half a URL is not a link β€” it is a - line of text that looks like one and goes nowhere. + The whole url goes in or the card carries no link at all. A session url is + built from a configured origin and three ids rather than written by an + agent, so its length is the deployment's, not something to defend against β€” + and half a url is not a link, it is a line of text that looks like one and + goes nowhere. """ card: dict[str, Any] = { "type": "task_update", @@ -1250,45 +1244,59 @@ def _session_card(session_url: str | None) -> dict[str, Any]: "switchdash", }: return card - linked = {**card, "details": f"<{session_url}|Open in Console app>"} - return linked if _chunk_length(linked) <= _MAX_CHUNK else card + return {**card, "details": f"<{session_url}|Open in Console app>"} -def _step_pages(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: - """The steps as the last two plan blocks that will hold them. +def _step_blocks(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: + """The steps as the three blocks that hold them: what is gone, then two pages. Pages are cut on fixed boundaries β€” the first fifty, the next fifty β€” so a - step never moves between pages once it has landed in one, and only the two - newest pages are drawn. Everything before them is gone from the message, - which the older page says in its own title: that is the line a reader sees - with the block collapsed, so a cut nobody is told about is one nobody can - see. - - The pages are written to a fixed pair of block ids rather than one per - fifty. Slack keeps a block where it was first written and has no call that - removes one, so a new id per page would grow the message without bound and - in the wrong order. Rotating the contents through two ids keeps both the - length and the order fixed however long the turn runs. + step never moves between pages once it has landed in one, which keeps a + settled page from being rewritten under a reader who has it open. + + Only the newest two pages are drawn, and everything before them is gone from + the message. That is said on one line of its own above them, naming the + whole range rather than only the most recent thing dropped, so the reader is + never left to add up several disclosures to find out what is missing. + + The line has to be the first of the three blocks written, because Slack + fixes a block where it was created and one made later would render *below* + the pages it is describing. So the top block starts life as the first page + of steps and is replaced by the line when there is finally something to + disclose β€” a substitution in place, which keeps its position. There is no + call that removes a block, and this needs none. """ if not steps: return [] + top, middle, bottom = _STEP_BLOCKS last = (len(steps) - 1) // _MAX_PLAN_TASKS - pages: list[dict[str, Any]] = [] - for slot, page in enumerate(range(max(0, last - 1), last + 1)): - start = page * _MAX_PLAN_TASKS - shown = steps[start : start + _MAX_PLAN_TASKS] - title = f"Steps {start + 1}–{start + len(shown)}" - if start and not slot: - title += f" Β· {start} earlier not shown" - pages.append( - { - "type": "plan", - "block_id": _STEP_PAGES[slot], - "title": _truncate(title, _MAX_PLAN_TITLE), - "tasks": shown, - } - ) - return pages + if last < 2: + pages = [top, middle] + return [_step_page(steps, page, pages[page]) for page in range(last + 1)] + gone = (last - 1) * _MAX_PLAN_TASKS + return [ + { + "type": "context", + "block_id": top, + "elements": [ + {"type": "mrkdwn", "text": f"_Steps 1–{gone} no longer shown_"} + ], + }, + _step_page(steps, last - 1, middle), + _step_page(steps, last, bottom), + ] + + +def _step_page(steps: list[dict[str, Any]], page: int, block_id: str) -> dict[str, Any]: + """One fifty-step page as the plan block that draws it.""" + start = page * _MAX_PLAN_TASKS + shown = steps[start : start + _MAX_PLAN_TASKS] + return { + "type": "plan", + "block_id": block_id, + "title": _truncate(f"Steps {start + 1}–{start + len(shown)}", _MAX_PLAN_TITLE), + "tasks": shown, + } def _settled(task: dict[str, Any], item: Item, turn: TurnUpsert) -> dict[str, Any]: @@ -1307,58 +1315,6 @@ def _settled(task: dict[str, Any], item: Item, turn: TurnUpsert) -> dict[str, An return task -def _within_chunk(chunk: dict[str, Any]) -> dict[str, Any]: - """Trim a chunk's title to the 256 characters an append will accept. - - Cut rather than dropped: the header is the whole of a collapsed block, and - one that says nothing is worse than one that says half. - - Each cut is measured again, and taken as a proportion of the overshoot - rather than a count of characters off it, because the budget is spent on - the serialised form where a character does not cost one: an emoji costs - twelve. Counting characters can ask for a cut longer than the text, which - is how this used to give up and hand Slack a chunk it would reject β€” - taking every other chunk in the same append down with it. - """ - while _chunk_length(chunk) > _MAX_CHUNK and chunk.get("title"): - keep = len(chunk["title"]) * _MAX_CHUNK // _chunk_length(chunk) - cut = _shorten(chunk["title"], max(keep, 1)) - if cut == chunk["title"]: - break - chunk["title"] = cut - return chunk - - -def _shorten(text: str, limit: int) -> str: - """Cut text that has already been escaped, without splitting an entity. - - `_fit` cuts the source and escapes the result, which is the right way round - and not available here: by this point the value has been escaped and the - budget being spent is on the escaped form. Slicing it blind can leave `&am` - in front of the reader, so a cut that landed inside an entity backs up to - where it started. - """ - if len(text) <= limit: - return text - cut = _truncate(text, limit)[:-1] - opened = cut.rfind("&") - if opened != -1 and ";" not in cut[opened:]: - cut = cut[:opened] - return f"{cut}…" - - -def _chunk_length(chunk: dict[str, Any]) -> int: - """How long Slack will consider this chunk: the serialised form. - - Measured the way the SDK puts it on the wire, which escapes every - non-ASCII character to `\\uXXXX`. Whether Slack counts the wire form or the - decoded string is not documented, so this counts the longer of the two: - trimming a title further than it needed is a cosmetic loss, while - undershooting has Slack reject the append and every other card in it. - """ - return len(json.dumps(chunk, separators=(",", ":"))) - - def _activity_title( items: list[Item], turn: TurnUpsert, diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 55e984b7a..40ba849c1 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -196,15 +196,17 @@ class _ActivityStream: cards that has not changed costs an append and risks nothing useful. `session` is the card the status line is drawn around, held here because it - can only be sent once. `pages` is the last thing written to each step block, - by `block_id`, so a redraw sends only the page that actually moved. + can only be sent once β€” and holding what was *sent* rather than what was + last drawn, so a redraw that happens to be missing the link cannot make the + stream forget the link it already sent. `blocks` is the last thing written + to each step block, by `block_id`, so a redraw sends only what moved. """ channel_id: str ts: str title: str = "" session: dict[str, Any] | None = None - pages: dict[str, dict[str, Any]] = field(default_factory=dict) + blocks: dict[str, dict[str, Any]] = field(default_factory=dict) class SlackAdapter(CollaborationAdapter): @@ -801,9 +803,9 @@ async def _extend_stream( """Send what changed since the last append, and close a finished turn. Only the header, the session card while it is still owed, and the step - pages that actually moved. A page is one `blocks` chunk carrying one - `plan`: Slack replaces the block it names and leaves the rest of the - message β€” and whatever the reader has open β€” alone. + blocks that actually moved. Each goes in its own `blocks` chunk: Slack + replaces the block it names and leaves the rest of the message β€” and + whatever the reader has open β€” alone. One chunk per plan. Slack refuses a `blocks` chunk holding more than a single plan block, though any number of such chunks ride in one append @@ -824,12 +826,15 @@ async def _extend_stream( chunks: list[dict[str, Any]] = [] if drawn.title != stream.title: chunks.append({"type": "plan_update", "title": drawn.title}) - if self._session_owed(stream.session, drawn.session): + owed = self._session_owed(stream.session, drawn.session) + if owed: chunks.append(drawn.session) moved = [ - page for page in drawn.pages if stream.pages.get(page["block_id"]) != page + block + for block in drawn.blocks + if stream.blocks.get(block["block_id"]) != block ] - chunks.extend({"type": "blocks", "blocks": [page]} for page in moved) + chunks.extend({"type": "blocks", "blocks": [block]} for block in moved) if chunks: try: @@ -842,9 +847,10 @@ async def _extend_stream( # retry sends the same chunks rather than assuming they landed. self._stream_failed(error, message_ref, drawn.title) stream.title = drawn.title - stream.session = drawn.session - for page in moved: - stream.pages[page["block_id"]] = page + if owed: + stream.session = drawn.session + for block in moved: + stream.blocks[block["block_id"]] = block if content.turn.status in TURN_ENDED: await self._close_stream(client, stream, message_ref) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py index 59321ef75..5b1942f07 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -19,8 +19,8 @@ carries the status line and one card that is sent once, and nothing that grows. The steps live in ordinary `plan` blocks carried inside the stream, addressed by `block_id` and replaced whole, so they can hold a different fifty than they held -a minute ago. Two of those blocks rotate, and a turn of any length draws the same -handful of blocks. +a minute ago. Three of those blocks rotate β€” a line saying what is no longer +shown, then the newest two pages β€” and a turn of any length draws the same four. What these cover is the adapter's half: that it opens a stream where it can, sends only what moved, discloses what it had to leave out, stops at the end of @@ -29,9 +29,7 @@ from __future__ import annotations -import json import logging -import re from typing import Any import pytest @@ -48,7 +46,9 @@ SlackAdapter, SlackConnectionConfig, ) +from switch_core.deeplinks import deeplink_for_platform from switch_core.sessions.contract import Item, TurnUpsert +from switch_core.sessions.presentation import session_console_url from .slack_fakes import FakeResponse, FakeWebClient @@ -109,39 +109,29 @@ def _cards(client: FakeWebClient) -> list[dict[str, Any]]: ] -def _pages(client: FakeWebClient) -> dict[str, dict[str, Any]]: - """The last plan written to each step block, in the order the blocks appeared. +def _drawn(client: FakeWebClient) -> dict[str, dict[str, Any]]: + """The last block written to each block id, in the order the ids appeared. Slack keeps a block where it was first written, so insertion order here is the order a reader sees them down the message. """ - pages: dict[str, dict[str, Any]] = {} + drawn: dict[str, dict[str, Any]] = {} for call in client.appended: for chunk in call["chunks"]: if chunk["type"] == "blocks": for block in chunk["blocks"]: - pages[block["block_id"]] = block - return pages + drawn[block["block_id"]] = block + return drawn -def _steps(client: FakeWebClient) -> list[dict[str, Any]]: - """Every step card the message is currently showing, oldest first.""" - return [task for page in _pages(client).values() for task in page["tasks"]] - +def _pages(client: FakeWebClient) -> list[dict[str, Any]]: + """The step pages the message is currently showing, oldest first.""" + return [block for block in _drawn(client).values() if block["type"] == "plan"] -def _sized(client: FakeWebClient) -> list[dict[str, Any]]: - """The chunks Slack would refuse: over 256 characters serialised. - `blocks` chunks are not on this budget β€” a fifty-card page is kilobytes and - the live API takes it β€” so only the header and the session card are measured. - """ - return [ - chunk - for call in client.appended - for chunk in call["chunks"] - if chunk["type"] in {"plan_update", "task_update"} - and len(json.dumps(chunk, separators=(",", ":"))) > 256 - ] +def _steps(client: FakeWebClient) -> list[dict[str, Any]]: + """Every step card the message is currently showing, oldest first.""" + return [task for page in _pages(client) for task in page["tasks"]] # ── Opening ────────────────────────────────────────────────────────────────── @@ -317,60 +307,38 @@ async def test_a_detail_takes_the_shape_of_the_place_it_is_sent_to() -> None: } -async def test_no_chunk_exceeds_what_an_append_will_carry() -> None: - """Slack measures a chunk serialised and rejects the append over 256 - characters, taking every other chunk in it down with it. - - Only the header and the session card are chunks. A step is a card inside a - block, which is not on this budget β€” which is why its detail survives here - where a chunk's would have been spent to keep the title.""" - client = FakeWebClient() - adapter = _adapter(client) - tool = _tool("t1", "R" * 400, status="completed", text="D" * 400) - - await adapter.post_rich(CHANNEL, "Agent", TurnActivity([tool], _turn()), THREAD) - - assert _sized(client) == [] - step = _steps(client)[0] - assert step["title"].startswith("RRR") - assert step["details"]["elements"][0]["elements"][0]["text"].startswith("DDD") - +@pytest.mark.parametrize("renders_custom_schemes", [True, False]) +async def test_a_real_console_link_arrives_whole(renders_custom_schemes: bool) -> None: + """Built by the code that builds it in production, not by hand. -async def test_the_budget_holds_for_text_that_is_not_one_byte_a_character() -> None: - """The budget is spent on the serialised form, where an emoji costs twelve - characters and not one. Counting characters asks for a cut longer than the - text, gives up, and hands Slack a chunk it refuses β€” with the whole append.""" - client = FakeWebClient() - adapter = _adapter(client) - tool = _tool("t1", "\U0001f680" * 200, status="completed", text="βœ“" * 200) - - await adapter.post_rich(CHANNEL, "Agent", TurnActivity([tool], _turn()), THREAD) - - assert _sized(client) == [] - header = _chunks(client)[0][0] - assert header["type"] == "plan_update" - assert "\U0001f680" in header["title"] - - -async def test_a_trim_never_leaves_half_an_escaped_character_on_screen() -> None: - """The value being cut has already been escaped, so a blind slice can leave - `&am` in front of the reader instead of an `&`. - - The header is the one place this can still happen. It is cut twice β€” once - to its own character budget, then again to fit the chunk β€” and the second - cut is made on text that has already been escaped, because by then the - escaping is part of what is being measured. + Both forms of the link run past two hundred characters β€” the raw + `switchdash://` one and the gateway redirect Slack gets instead, because + Slack will not render a custom scheme. A hand-written short url in a test + proves nothing about either. The whole url has to be in the card: a link cut + to fit goes somewhere that is not the session, or nowhere at all. """ client = FakeWebClient() adapter = _adapter(client) - tool = _tool("t1", "&βœ“" * 200, status="completed") + url = deeplink_for_platform( + session_console_url( + "https://switch.example", + "3f2b8c1e-5d47-4a19-9b6e-0c8a21d4f7b3", + "6e927fce-ef10-4248-afaa-34f660cd815d", + "a91c4d02-7e35-4f68-b2a1-8d5c93e07f4a", + ), + "https://switch.example/gateway", + renders_custom_schemes, + ) + assert url is not None and len(url) > 150 - await adapter.post_rich(CHANNEL, "Agent", TurnActivity([tool], _turn()), THREAD) + await adapter.post_rich( + CHANNEL, + "Agent", + TurnActivity([_tool("t1", "Read")], _turn(), session_url=url), + THREAD, + ) - title = _chunks(client)[0][0]["title"] - assert title.endswith("…") - assert "&" in title - assert not re.search(r"&(?!amp;|lt;|gt;)", title) + assert _cards(client)[0]["details"] == f"<{url}|Open in Console app>" async def test_the_console_link_goes_out_once() -> None: @@ -424,14 +392,47 @@ async def test_a_link_that_only_turns_up_later_is_still_sent() -> None: ] +async def test_a_redraw_without_the_link_does_not_make_the_stream_forget_it() -> None: + """What the stream remembers is what Slack was sent, not what was last drawn. + + A publish can arrive without the session url β€” the clock ticks on whatever + the caller happens to hold. Remembering that empty card as though it had + been sent loses the fact that the link already went out, and the next + publish appends the same link to a card that is already carrying it. + """ + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + url = "https://switch.example/session" + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0, session_url=url), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([tool], _turn(), 9.0), THREAD + ) + await adapter.update_rich( + CHANNEL, + "Agent", + ref, + TurnActivity([tool], _turn(), 14.0, session_url=url), + THREAD, + ) + + assert [card["details"] for card in _cards(client)] == [ + f"<{url}|Open in Console app>" + ] + + # ── Paging ─────────────────────────────────────────────────────────────────── -async def test_a_turn_of_any_length_draws_the_same_two_step_blocks() -> None: +async def test_a_turn_of_any_length_draws_the_same_three_step_blocks() -> None: """Slack keeps a block where it was first written and has no call that removes one, so a block per fifty steps would grow the message without - bound. Rotating the newest two pages through a fixed pair of ids keeps both - the length and the order of the message fixed however long the turn runs. + bound. Rotating a disclosure line and the newest two pages through a fixed + three ids keeps both the length and the order of the message fixed however + long the turn runs. """ client = FakeWebClient() adapter = _adapter(client) @@ -449,15 +450,44 @@ async def test_a_turn_of_any_length_draws_the_same_two_step_blocks() -> None: THREAD, ) - pages = _pages(client) - assert len(pages) == 2 - older, newer = pages.values() - assert older["title"] == "Steps 151–200 Β· 150 earlier not shown" + gone, older, newer = _drawn(client).values() + assert gone["elements"][0]["text"] == "_Steps 1–150 no longer shown_" + assert older["title"] == "Steps 151–200" assert [task["title"] for task in older["tasks"]][:1] == ["Tool 150"] assert newer["title"] == "Steps 201–240" assert [task["title"] for task in newer["tasks"]][-1:] == ["Tool 239"] +async def test_what_is_no_longer_shown_is_one_line_above_the_steps() -> None: + """One cumulative line naming the whole missing range, not a count tucked + into the title of a section. + + It is above the steps because it has to be created before them: Slack fixes + a block where it was first written, so a line added later would describe the + pages from underneath them. That is why the top block starts as the first + page of steps and is replaced in place β€” same id, same position β€” by the + line once there is something to disclose. + """ + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(101)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many[:100], _turn(), 1.0), THREAD + ) + before = list(_drawn(client).values()) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(many, _turn(), 9.0), THREAD + ) + + assert [block["title"] for block in before] == ["Steps 1–50", "Steps 51–100"] + after = list(_drawn(client).values()) + assert [block["type"] for block in after] == ["context", "plan", "plan"] + assert after[0]["block_id"] == before[0]["block_id"] + assert after[0]["elements"][0]["text"] == "_Steps 1–50 no longer shown_" + assert [block["title"] for block in after[1:]] == ["Steps 51–100", "Steps 101–101"] + + async def test_a_step_never_moves_between_pages_once_it_has_landed() -> None: """Pages are cut on fixed boundaries β€” the first fifty, the next fifty β€” rather than as a window on the newest hundred. A window would shuffle every @@ -476,7 +506,7 @@ async def test_a_step_never_moves_between_pages_once_it_has_landed() -> None: moved = [c for c in _chunks(client)[1] if c["type"] == "blocks"] assert [chunk["blocks"][0]["title"] for chunk in moved] == ["Steps 51–52"] - older, newer = _pages(client).values() + older, newer = _pages(client) assert [task["title"] for task in older["tasks"]][:1] == ["Tool 0"] assert [task["title"] for task in newer["tasks"]] == ["Tool 50", "Tool 51"] From 1d2408080789ad19570a96d8f23ec0b4522bf4ea Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 16:45:26 +0100 Subject: [PATCH 084/120] Mattermost: stop saying an option twice, and stop deleting an answered card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a reader sees, both raised from a live channel. The body was listing every option underneath buttons that already showed them. The renderer has carried the seam for this since Discord and Telegram used it: an option a control shows in full loses its line. Mattermost passed no width because the server documents no cap on an action's name, which mistook the constraint β€” what bounds a label is what still reads as a button, not what the API will accept. The suppression is a promise that a control is carrying the option, so it holds only where one will be. The plain text of a `RichContentFailed` is drawn without controls, and a card that earns none β€” a form too big to show faithfully β€” is drawn a second time, because the pass that discovered it had already dropped the lines. An answered card is now edited down to its outcome instead of being taken back. The settled rendering already existed; Mattermost never reached it. Mattermost is alone in leaving a "(message deleted)" line behind a post removed while somebody has the channel open, which is reason enough for it alone to keep its cards β€” the other four platforms are untouched. The seam below the capability is kept, and the test file says why: the flag is the whole of the decision, and reversing it should not also need an implementation written again. Co-Authored-By: Claude Opus 5 --- .../collaboration/mattermost/adapter.py | 51 +++++++--- .../collaboration/mattermost/callback.py | 20 +++- .../test_mattermost_card_buttons.py | 96 +++++++++++++++++-- .../test_mattermost_card_removal.py | 32 +++++-- .../sessions/test_answered_card_removal.py | 8 +- docs/old/bridges/MATTERMOST_SETUP.md | 24 +++-- 6 files changed, 185 insertions(+), 46 deletions(-) diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index d30df5c50..33ea53fbd 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -44,6 +44,7 @@ CallbackRefused, ) from switch_core.bridges.collaboration.mattermost.callback import ( + MAX_BUTTON_LABEL, answer_actions, read_press, ) @@ -266,11 +267,13 @@ class MattermostAdapter(CollaborationAdapter): #: handle of its own. carries_publication_marker: ClassVar[bool] = True - #: The bridge connects as a system admin, which may delete any post in the - #: team, so an answered card comes back whichever bot posted it. Mattermost - #: leaves a "(message deleted)" placeholder for clients with the channel - #: already open; it goes on the next load. - removes_answered_cards: ClassVar[bool] = True + #: An answered card is edited down to its outcome rather than taken back. + #: The bridge could delete it β€” it connects as a system admin, so it may + #: remove any post in the team β€” but Mattermost is alone in leaving a + #: "(message deleted)" placeholder behind one removed while a client has + #: the channel open. A settled card reads better than that tombstone and + #: keeps the channel a record of what was asked and what was decided. + removes_answered_cards: ClassVar[bool] = False def __init__(self, *, config: MattermostConnectionConfig) -> None: super().__init__() @@ -812,11 +815,21 @@ def rich_fallback_text(self, content: RichContent) -> str: Switch holds and a call to Mattermost to turn it into a name. This is the string that goes in a `RichContentFailed`, where a failed lookup on top of a failed post would say nothing useful anyway. + + Drawn without controls because nothing carries them here. A card that + dropped an option from its body on the promise of a button, and then + went out as the text of a failure, would ask for a choice it had + stopped printing. """ - return self._draw(content, mention=None, responder=None).text + return self._draw(content, mention=None, responder=None, controls=False).text def _draw( - self, content: RichContent, *, mention: str | None, responder: str | None + self, + content: RichContent, + *, + mention: str | None, + responder: str | None, + controls: bool, ) -> Drawn: escape = self._rich_escape limit = self.rich_fallback_limit() @@ -858,11 +871,7 @@ def _draw( markup=markup, responder=responder, unavailable_reason=content.unavailable_reason, - # The body prints every option even where buttons are drawn. - # Mattermost documents no budget for a button's label, so there is - # no width at which an option can be called fully shown by the - # control β€” and a numbered list is what a typed answer names. - control_label_limit=None, + control_label_limit=MAX_BUTTON_LABEL if controls else None, ) return replace(drawn, text=f"{lead}{drawn.text}{tail}") @@ -920,8 +929,22 @@ async def _render_rich( if isinstance(content, RequestCard) else None ) - drawn = self._draw(content, mention=mention, responder=responder) - return drawn.text, self._controls(content, drawn) + controls = ( + isinstance(content, RequestCard) and self._button_address() is not None + ) + drawn = self._draw( + content, mention=mention, responder=responder, controls=controls + ) + actions = self._controls(content, drawn) + if controls and not actions: + # The body drops an option only where a button carries it, and + # whether one does is not known until the card has been drawn: a + # form too big to show faithfully earns no controls, and the + # drawing that discovered that had already left the options out. + drawn = self._draw( + content, mention=mention, responder=responder, controls=False + ) + return drawn.text, actions async def post_rich( self, diff --git a/core/switch_core/bridges/collaboration/mattermost/callback.py b/core/switch_core/bridges/collaboration/mattermost/callback.py index 900173180..71f00dc2d 100644 --- a/core/switch_core/bridges/collaboration/mattermost/callback.py +++ b/core/switch_core/bridges/collaboration/mattermost/callback.py @@ -19,6 +19,12 @@ # never be replayed as another if a second kind of button is ever added. _PURPOSE = "answer" +# How much of an option a button shows. Not a server limit β€” Mattermost +# documents none β€” but a width past which a control stops reading as a control +# and starts reading as a paragraph with a border. The body keeps the line of +# any option too long to survive it, so nothing is lost by cutting here. +MAX_BUTTON_LABEL = 76 + @dataclass(frozen=True) class Press: @@ -92,12 +98,18 @@ def answer_actions( def _button_name(control: Control) -> str: """What the button says: the option's number, then the option. - Numbered because the body numbers it, and a reader looking at "2." in the - text and "Decline" on a button should not have to work out that they are - the same choice. Not cut to a width, because Mattermost documents no limit - on how long a name may be. + Numbered because the number is what a typed answer names, and the card + still invites one: where the buttons carry the options the body stops + listing them, so the controls become the only place the reader can see + which number means what. + + The number is therefore the part that must survive, and the label is cut to + fit around it. An option too long for `MAX_BUTTON_LABEL` keeps its line in + the body, where the whole of it is still readable. """ label = control.label.strip() or f"Option {control.position}" + if len(label) > MAX_BUTTON_LABEL: + label = label[: MAX_BUTTON_LABEL - 1].rstrip() + "…" return f"{control.position}. {label}" diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py index 6bbb50d8c..7fd511dc8 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py @@ -11,9 +11,11 @@ Mattermost posts, and both halves are resolved against the stored record rather than trusted. -The body still lists every option in full. A button's label has no documented -budget here, so there is no width at which an option could be called fully -shown by the control β€” and a numbered list is what a typed answer names. +An option a button already shows in full loses its line in the body: the same +choice printed twice is the second copy pushing the rest of the card off a +phone. Mattermost documents no budget for a button's name, so the width is a +legibility one, and an option too long for it keeps its line β€” as does every +option on a card that earned no buttons at all. """ from __future__ import annotations @@ -22,7 +24,10 @@ from typing import Any from switch_core.bridges.collaboration.mattermost.adapter import MattermostAdapter -from switch_core.bridges.collaboration.mattermost.callback import read_press +from switch_core.bridges.collaboration.mattermost.callback import ( + MAX_BUTTON_LABEL, + read_press, +) from switch_core.bridges.collaboration.session.form import ( posted_form, resolve_pressed_position, @@ -134,18 +139,91 @@ async def test_a_context_signed_for_one_option_does_not_verify_for_another() -> assert read_press(_key(), _body({"switch": forged})) is None -async def test_the_body_still_lists_every_option_the_buttons_offer() -> None: - """A button's label has no documented limit here, so nothing is gained by - dropping the option from the body β€” and a card is answerable by typing - whether or not the reader's client drew the buttons.""" +async def test_the_body_drops_the_options_the_buttons_already_show() -> None: + """The same choice twice is the second copy pushing the rest of the card + off a phone screen. The buttons carry the numbers, so the instruction to + answer by number still names something the reader can see.""" adapter, _ = _handled() await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") text = _created(adapter)["message"] + assert "1. Allow once" not in text + assert "2. Deny" not in text + assert "R7" in text + assert [button["name"] for button in _buttons(_created(adapter))] == [ + "1. Allow once", + "2. Deny", + ] + + +async def test_an_option_too_long_for_its_button_keeps_its_line_in_the_body() -> None: + """A button shows what it has room for. Where that is less than the whole + option, the body is the only place the rest of it is written, so the line + stays and the reader can still tell the two choices apart.""" + adapter, _ = _handled() + card = await _card() + long_label = "Allow once, " + "but only for the staging volume " * 4 + stretched = card.request.model_copy( + update={ + "content": card.request.content.model_copy( + update={ + "options": [ + card.request.content.options[0].model_copy( + update={"label": long_label} + ), + card.request.content.options[1], + ] + } + ) + } + ) + + await adapter.post_rich( + CHANNEL, "worker", replace(card, request=stretched), "root-1" + ) + + text = _created(adapter)["message"] + assert "1. Allow once, but only for the staging volume" in text + assert "2. Deny" not in text + first, second = _buttons(_created(adapter)) + assert first["name"].endswith("…") + assert len(first["name"]) <= MAX_BUTTON_LABEL + len("1. ") + assert second["name"] == "2. Deny" + + +async def test_a_card_that_earns_no_buttons_keeps_every_option_in_its_body() -> None: + """The suppression is a promise that a control is carrying the option. A + form too big to show faithfully earns no controls, and the drawing that + discovered that had already dropped the lines β€” so it is drawn again.""" + adapter, _ = _handled() + card = await _card() + clipped = card.request.model_copy( + update={ + "content": card.request.content.model_copy( + update={"detail": "Deletes the production volume. " * 2000} + ) + } + ) + + await adapter.post_rich(CHANNEL, "worker", replace(card, request=clipped), "root-1") + + text = _created(adapter)["message"] + assert _buttons(_created(adapter)) == [] + assert "1. Allow once" in text + assert "2. Deny" in text + + +async def test_the_failure_text_keeps_the_options_it_has_no_buttons_for() -> None: + """What goes in a `RichContentFailed` is posted as plain text with nothing + to press. A body that dropped its options on the promise of a button would + ask for a choice it had stopped printing.""" + adapter, _ = _handled() + + text = adapter.rich_fallback_text(await _card()) + assert "1. Allow once" in text assert "2. Deny" in text - assert "R7" in text async def test_a_button_keeps_its_id_across_a_redraw() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py index 55a3c5436..14bc684ab 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py @@ -1,13 +1,20 @@ """Taking a Mattermost card back, and being able to say whether it worked. The bridge connects as a system admin, so it may delete a post written by an -agent's bot β€” which every card is, and the reference does not say whose. What a -reader with the channel already open sees in its place is Mattermost's own -"(message deleted)" placeholder; it goes on the next load. - -The other half is that a caller acting on the result β€” writing down that a card -is gone β€” must not be told success where none was established. That is why this -is not `delete_message`, which logs and returns either way. +agent's bot β€” which every card is, and the reference does not say whose. + +Answering a request no longer removes its card: Mattermost is the one platform +that leaves a "(message deleted)" line behind a post taken down while a reader +has the channel open, and a card edited down to its outcome reads better than +that placeholder. So `removes_answered_cards` is off and nothing in the +publication loop reaches the seam below. It is kept, and kept honest here, +because the flag is the whole of the decision and the seam is what a reversal +would need β€” the port's default raises rather than pretending. + +The half that has always mattered is that a caller acting on the result β€” +writing down that a card is gone β€” must not be told success where none was +established. That is why this is not `delete_message`, which logs and returns +either way. """ from __future__ import annotations @@ -146,6 +153,11 @@ async def test_a_disconnected_adapter_does_not_claim_the_card_was_removed() -> N await adapter.remove_publication(CHANNEL, CARD) -def test_mattermost_is_a_platform_that_says_it_can_do_this() -> None: - """The capability is what routes an answered card here at all.""" - assert MattermostAdapter.removes_answered_cards is True +def test_an_answered_mattermost_card_is_not_taken_back() -> None: + """The capability is what routes an answered card into removal at all, and + Mattermost declines it: a deleted post leaves a "(message deleted)" line + for anyone with the channel open, and a card edited down to its outcome + says more than that tombstone does. The machinery above reads this flag, so + turning it off is the whole of what keeps an answered card on the screen. + """ + assert MattermostAdapter.removes_answered_cards is False diff --git a/core/tests/switch_core/sessions/test_answered_card_removal.py b/core/tests/switch_core/sessions/test_answered_card_removal.py index d9112a679..726ee6688 100644 --- a/core/tests/switch_core/sessions/test_answered_card_removal.py +++ b/core/tests/switch_core/sessions/test_answered_card_removal.py @@ -224,9 +224,11 @@ async def test_a_card_nobody_has_answered_is_left_alone(session_factory): async def test_a_platform_that_cannot_prove_a_removal_is_not_asked_to_try( session_factory, ): - """Every bridge platform claims the capability now, so what this holds is - the seam itself: a platform that cannot prove a card was taken away has its - cards settled by an edit, exactly as they were before any of this. + """A platform that does not take answered cards away has them settled by an + edit instead, exactly as they were before any of this. Mattermost is the + live case β€” it declines the capability rather than leave a + "(message deleted)" line where the card was β€” and the seam holds for any + platform that cannot prove a removal at all. """ platform = Platform() service, epoch, posts, cards, post = await _card(session_factory, platform) diff --git a/docs/old/bridges/MATTERMOST_SETUP.md b/docs/old/bridges/MATTERMOST_SETUP.md index d682bd599..be722f3df 100644 --- a/docs/old/bridges/MATTERMOST_SETUP.md +++ b/docs/old/bridges/MATTERMOST_SETUP.md @@ -130,12 +130,24 @@ Two consequences: by typing. New cards work immediately. **What a reader sees.** An open permission card gains one button per option, -numbered the way the card's own text numbers them, so pressing and typing name -the same choice. The buttons disappear when the request is answered, cancelled -or expires. A press by somebody who may not answer, or on a card that has -already settled, is explained to that person alone β€” nobody else in the channel -sees it. A card that says it is too long to answer from Mattermost carries no -buttons, because the reader has not been shown what they would be deciding. +numbered the way a typed answer numbers them, so pressing and typing name the +same choice. The card's body does not then list those options again β€” the +buttons are carrying them. An option too long to fit on a button keeps its line +in the body, and a card that carries no buttons at all lists everything, so the +choices are always written somewhere. + +The buttons disappear when the request is answered, cancelled or expires, and +the card is **edited down to its outcome** rather than deleted: it keeps the +question and gains the decision, so the channel stays a record of what was +asked and what was chosen. Mattermost is the only platform Switch bridges to +where an answered card behaves this way β€” the others take it off the screen β€” +because it is the only one that leaves a "(message deleted)" line behind a post +removed while somebody has the channel open. + +A press by somebody who may not answer, or on a card that has already settled, +is explained to that person alone β€” nobody else in the channel sees it. A card +that says it is too long to answer from Mattermost carries no buttons, because +the reader has not been shown what they would be deciding. Buttons ride in the post's props, which an edit replaces wholesale, so a redraw reads the post back and merges rather than overwriting what the Mattermost From 66ff26ea92b050a382aacbb5dcfd43f74bb5a355 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 17:02:49 +0100 Subject: [PATCH 085/120] Name the live step on the section running it, and stop escaping what Slack never parses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header carried the tool call β€” "Working… 40s Β· Running: Read" β€” which said what was happening but not where to look for it. Past fifty steps there is more than one section to open, and the header pointed at none of them. It moves onto the heading of the section holding the step instead: "Steps 51–75 Β· Running: Read". Said once, where it is useful, and it leaves the header to say only where the turn got to. The stream's own plan was sending its one card complete from the moment the stream opened, so the top of the message showed a check beside "Working…". Slack draws a block's glyph from the cards in it and that plan holds exactly one, so the card now carries the turn's status and turns over at the end. That is the only update it ever takes, and its shape is measured rather than guessed: an update of id and status alone stores an empty title, and one that repeats `details` appends the Console link a second time. So the adapter now works out which fields it still owes rather than deciding whether to resend the card whole. Card titles were escaped on the grounds that Slack asks for `&`, `<` and `>` escaped and that a tool call named `Ran ` would notify the channel. Measured against the live API, neither holds for this field: a card title parses no mrkdwn, so a title sent escaped is stored and shown escaped, and `` is stored as the text it is rather than as a broadcast. Every `&&` in a shell command was reaching the reader as `&&`. A field that parses nothing needs nothing escaped for it, so the plan and card titles are left as written. The mrkdwn surfaces β€” sections, contexts, the message fallback β€” still escape. Checked against the live API over 230 steps: the header ends free of the tool, each section names the step it is holding, the card ends complete with its title intact and the link in it once, and a title holding `echo "x" && ls 2>/dev/null` comes back with no entities in it. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 126 ++++++++++----- .../bridges/collaboration/slack/adapter.py | 34 ++-- .../collaboration/test_session_activity.py | 21 ++- .../test_session_slack_streaming.py | 146 ++++++++++++++++-- 4 files changed, 262 insertions(+), 65 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index b705bf45c..1af7df2da 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -147,9 +147,11 @@ # that line when there is something to disclose. _STEP_BLOCKS = ("switch-steps-top", "switch-steps-middle", "switch-steps-bottom") -# The single card in the stream's own plan. Sent once: `details` on a -# `task_update` appends to what the card already has rather than replacing it, -# so a card re-sent with the same link shows the link twice. +# The single card in the stream's own plan. Its `details` is sent once and +# never again: `details` on a `task_update` appends to what the card already +# has rather than replacing it, so a card re-sent with the same link shows the +# link twice. Title and status can be re-sent freely, and have to be together β€” +# an update that leaves the title out stores an empty one. _SESSION_CARD = "switch-session" # Slack's three task states against the contract's four. `declined` is not an @@ -895,7 +897,7 @@ def with_session_context( def render_attention(summary: str) -> SlackMessage: """One visible sentence for a turn or host error.""" - title = _fit(plain_text(summary), _MAX_PLAN_TASK_TITLE) + title = _truncate(plain_text(summary), _MAX_PLAN_TASK_TITLE) return SlackMessage( text=escape_mrkdwn(title), blocks=[ @@ -963,7 +965,7 @@ def render_activity( { "type": "task_card", "task_id": _task_id(turn.turn_id), - "title": _fit(state, _MAX_PLAN_TASK_TITLE), + "title": _truncate(state, _MAX_PLAN_TASK_TITLE), "status": "in_progress", } ], @@ -982,8 +984,10 @@ def render_activity( hidden = max(0, count - _MAX_PLAN_TASKS) if hidden: title += f" Β· {hidden} earlier not shown" - title += _running_step(did, turn) - plan["title"] = _fit(title, _MAX_PLAN_TITLE) + running = _running(did, turn) + if running: + title += f" Β· {running[1]}" + plan["title"] = _truncate(title, _MAX_PLAN_TITLE) for task, item in zip(plan["tasks"], did[-_MAX_PLAN_TASKS:]): _settled(task, item, turn) return SlackMessage( @@ -1151,7 +1155,7 @@ def render_activity_plan( blocks.append( { "type": "plan", - "title": _fit(title, _MAX_PLAN_TITLE), + "title": _truncate(title, _MAX_PLAN_TITLE), "tasks": [_settled(_plan_task(item), item, turn) for item in kept], } ) @@ -1160,7 +1164,7 @@ def render_activity_plan( { "type": "task_card", "task_id": _task_id(turn.turn_id), - "title": _fit(title, _MAX_PLAN_TASK_TITLE), + "title": _truncate(title, _MAX_PLAN_TASK_TITLE), "status": "in_progress", } ) @@ -1206,19 +1210,27 @@ def render_activity_stream( That is what lets a long turn stay one readable message. The stream's plan holds the status line and nothing that grows; the steps live in three blocks that rotate, so a turn of any length draws the same four blocks. + + The header says where the turn is, and the section holding the live step + says what it is doing. Naming the step in both would say it twice, and the + section is the useful half: it is the one a reader wants to open, and the + glyph beside it is already the thing that says work is happening there. """ did = [item for item in items if item.kind == "tool-activity"] return StreamedActivity( - title=_fit( - _activity_title(items, turn, elapsed_seconds=elapsed_seconds), + title=_truncate( + turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds), _MAX_PLAN_TITLE, ), - session=_session_card(session_url), - blocks=_step_blocks([_settled(_plan_task(item), item, turn) for item in did]), + session=_session_card(session_url, turn), + blocks=_step_blocks( + [_settled(_plan_task(item), item, turn) for item in did], + _running(did, turn), + ), ) -def _session_card(session_url: str | None) -> dict[str, Any]: +def _session_card(session_url: str | None, turn: TurnUpsert) -> dict[str, Any]: """The one card in the stream's own plan, and where the link lives. A streamed plan with no cards in it does not draw at all, so without this @@ -1226,6 +1238,10 @@ def _session_card(session_url: str | None) -> dict[str, Any]: Console link is asked to be: first row of the first block, visible in the same expansion that opens the plan. + Its status is the turn's, so the block it sits in shows a spinner while the + turn runs rather than the check a settled card would give it. Slack draws + that glyph from the cards, and this is the only card in there. + The whole url goes in or the card carries no link at all. A session url is built from a configured origin and three ids rather than written by an agent, so its length is the deployment's, not something to defend against β€” @@ -1236,7 +1252,7 @@ def _session_card(session_url: str | None) -> dict[str, Any]: "type": "task_update", "id": _SESSION_CARD, "title": "Switch session", - "status": "complete", + "status": "complete" if turn.status in TURN_ENDED else "in_progress", } if not session_url or urlsplit(session_url).scheme not in { "https", @@ -1247,7 +1263,9 @@ def _session_card(session_url: str | None) -> dict[str, Any]: return {**card, "details": f"<{session_url}|Open in Console app>"} -def _step_blocks(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: +def _step_blocks( + steps: list[dict[str, Any]], running: tuple[int, str] | None +) -> list[dict[str, Any]]: """The steps as the three blocks that hold them: what is gone, then two pages. Pages are cut on fixed boundaries β€” the first fifty, the next fifty β€” so a @@ -1272,7 +1290,9 @@ def _step_blocks(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: last = (len(steps) - 1) // _MAX_PLAN_TASKS if last < 2: pages = [top, middle] - return [_step_page(steps, page, pages[page]) for page in range(last + 1)] + return [ + _step_page(steps, page, pages[page], running) for page in range(last + 1) + ] gone = (last - 1) * _MAX_PLAN_TASKS return [ { @@ -1282,19 +1302,32 @@ def _step_blocks(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: {"type": "mrkdwn", "text": f"_Steps 1–{gone} no longer shown_"} ], }, - _step_page(steps, last - 1, middle), - _step_page(steps, last, bottom), + _step_page(steps, last - 1, middle, running), + _step_page(steps, last, bottom, running), ] -def _step_page(steps: list[dict[str, Any]], page: int, block_id: str) -> dict[str, Any]: - """One fifty-step page as the plan block that draws it.""" +def _step_page( + steps: list[dict[str, Any]], + page: int, + block_id: str, + running: tuple[int, str] | None, +) -> dict[str, Any]: + """One fifty-step page as the plan block that draws it. + + The page holding the live step names it, so the heading a reader is drawn + to is the one where something is happening. Only that page: the same + sentence on a settled page would be pointing somewhere the step is not. + """ start = page * _MAX_PLAN_TASKS shown = steps[start : start + _MAX_PLAN_TASKS] + title = f"Steps {start + 1}–{start + len(shown)}" + if running and start <= running[0] < start + len(shown): + title += f" Β· {running[1]}" return { "type": "plan", "block_id": block_id, - "title": _truncate(f"Steps {start + 1}–{start + len(shown)}", _MAX_PLAN_TITLE), + "title": _truncate(title, _MAX_PLAN_TITLE), "tasks": shown, } @@ -1311,7 +1344,7 @@ def _settled(task: dict[str, Any], item: Item, turn: TurnUpsert) -> dict[str, An if item.status != "in-progress" or turn.status in TURN_ENDED: task["status"] = "complete" if item.status == "in-progress" and turn.status in TURN_ENDED: - task["title"] = _fit("Unfinished: " + task["title"], _MAX_PLAN_TASK_TITLE) + task["title"] = _truncate("Unfinished: " + task["title"], _MAX_PLAN_TASK_TITLE) return task @@ -1336,23 +1369,39 @@ def _activity_title( nobody can see. """ title = turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds) - title += _running_step([i for i in items if i.kind == "tool-activity"], turn) + running = _running([i for i in items if i.kind == "tool-activity"], turn) + if running: + title += f" Β· {running[1]}" if omitted: step = "step" if omitted == 1 else "steps" title += f" Β· {omitted} earlier {step} not shown" return title -def _running_step(did: list[Item], turn: TurnUpsert) -> str: - """The step a live turn is on, as a suffix, or nothing for an ended one.""" +def _running(did: list[Item], turn: TurnUpsert) -> tuple[int, str] | None: + """Where the live step is and what to call it, or nothing for an ended turn. + + The index is what lets a streamed turn put the label on the page the step + is actually in, rather than on whichever page happens to be last. + + A turn with nothing open still names the step it finished most recently: + between two calls there is nothing running, and a heading that went blank + for that moment would flicker on every step. + """ if not did or turn.status in TURN_ENDED: - return "" + return None current = next( - (item for item in reversed(did) if item.status == "in-progress"), None + ( + index + for index in reversed(range(len(did))) + if did[index].status == "in-progress" + ), + None, ) - tool = current or did[-1] - label = "Running" if current else "Last" - return f" Β· {label}: {plain_text(tool.title) if tool.title else 'Tool'}" + index = len(did) - 1 if current is None else current + label = "Last" if current is None else "Running" + title = plain_text(did[index].title) if did[index].title else "Tool" + return index, f"{label}: {title}" def _plan( @@ -1392,9 +1441,14 @@ def _plan_task(item: Item) -> dict[str, Any]: Plain text, not mrkdwn: a card's title renders none, so markup passed into it arrives as literal underscores and backticks in front of the reader. - Escaped all the same β€” Slack asks for `&`, `<` and `>` escaped in anything - sent to the API, and a tool call titled `Ran ` is a host string that - would otherwise notify the channel. + + Not escaped, for the same reason. This used to escape on the grounds that + Slack asks for `&`, `<` and `>` escaped and that a tool call titled + `Ran ` would otherwise notify the channel. Measured, both are false + here: a title sent escaped is stored and shown escaped, so every `&&` in a + shell command reached the reader as `&&`, and `` is stored + as the text it is rather than as a broadcast. A field that parses nothing + needs nothing escaped for it. Slack has three states against the contract's four, and `declined` is not an error β€” the call did what it was told, and what it was told was no. The @@ -1408,12 +1462,12 @@ def _plan_task(item: Item) -> dict[str, Any]: title = f"{_ACTIVITY[item.status]} {title}".strip() task: dict[str, Any] = { "task_id": _task_id(item.item_id), - "title": _fit(title, _MAX_PLAN_TASK_TITLE) or "(untitled)", + "title": _truncate(title, _MAX_PLAN_TASK_TITLE) or "(untitled)", "status": _TASK_STATUS[item.status], } details = plain_text(item.text) if item.text else "" if details: - task["details"] = _rich_text(_fit(details, _MAX_PLAN_TASK_DETAILS)) + task["details"] = _rich_text(_truncate(details, _MAX_PLAN_TASK_DETAILS)) return task diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 40ba849c1..66eaaf985 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -828,7 +828,7 @@ async def _extend_stream( chunks.append({"type": "plan_update", "title": drawn.title}) owed = self._session_owed(stream.session, drawn.session) if owed: - chunks.append(drawn.session) + chunks.append(owed) moved = [ block for block in drawn.blocks @@ -848,25 +848,35 @@ async def _extend_stream( self._stream_failed(error, message_ref, drawn.title) stream.title = drawn.title if owed: - stream.session = drawn.session + stream.session = {**(stream.session or {}), **owed} for block in moved: stream.blocks[block["block_id"]] = block if content.turn.status in TURN_ENDED: await self._close_stream(client, stream, message_ref) @staticmethod - def _session_owed(sent: dict[str, Any] | None, drawn: dict[str, Any]) -> bool: - """Whether the session card still has something Slack has not been told. - - Once, when the stream opens, and once more if the Console link only - turned up later β€” a card sent without a detail can still be given one, - because appending to nothing leaves just the link. It is never sent a - third time: `details` on a `task_update` appends, so a card that - already carries the link would come back carrying it twice. + def _session_owed( + sent: dict[str, Any] | None, drawn: dict[str, Any] + ) -> dict[str, Any] | None: + """The part of the session card Slack has not been told, or nothing. + + The card is not written once and left. Its status follows the turn, so + it goes out spinning and comes back complete, and the link may only + turn up after the stream has opened. + + What can only happen once is `details`. It *appends* to what the card + already holds rather than replacing it, so the link is dropped from + every chunk after the one that carried it β€” otherwise the card comes + back holding the link twice. Everything else is compared against what + Slack was actually sent, which is why a redraw that happens to arrive + without the url cannot make the stream forget it already sent one. """ if sent is None: - return True - return "details" in drawn and "details" not in sent + return drawn + owed = { + k: v for k, v in drawn.items() if k != "details" or "details" not in sent + } + return owed if {**sent, **owed} != sent else None async def _close_stream( self, client: AsyncWebClient, stream: _ActivityStream, message_ref: str diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index cc6fbb152..9ec674d3b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -344,20 +344,33 @@ async def test_the_fallback_carries_the_turn_state_too() -> None: # ── What a host wrote, in somewhere Slack parses ───────────────────────────── -async def test_agent_written_text_cannot_forge_markup() -> None: - """Every value in a turn is the host's, and both surfaces parse mrkdwn.""" +async def test_agent_written_text_cannot_forge_markup_where_slack_parses_it() -> None: + """Every value in a turn is the host's, and a section block parses mrkdwn.""" items = [ _item(itemId="i1", kind="assistant-message", title="", text=" now"), - _item(itemId="i2", title="Ran --force"), ] rendered = _blocks(items) assert "" not in rendered - assert "" not in rendered assert "<!channel>" in rendered +async def test_a_card_title_is_left_as_written_because_it_is_not_parsed() -> None: + """Measured, not read: a task card's title renders no mrkdwn at all. + + It used to be escaped on the grounds that a tool call named `Ran ` + would otherwise notify the channel. It does not β€” the live API stores that + as the text it is β€” and escaping a field nothing parses only put the + entities themselves in front of the reader, so a shell `&&` arrived as + `&&`. + """ + written = "Ran --force && exit" + items = [_item(itemId="i1", title=written)] + + assert list(_cards(items)) == [written] + + async def test_a_long_message_is_cut_rather_than_taking_the_post_with_it() -> None: """Slack rejects a section over 3000 characters and rejects the whole post. diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py index 5b1942f07..abe0c79ff 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -202,7 +202,8 @@ async def test_only_the_header_and_the_pages_that_moved_are_appended() -> None: assert [c["type"] for c in opened] == ["plan_update", "task_update", "blocks"] later = _chunks(client)[1] assert [c["type"] for c in later] == ["plan_update", "blocks"] - assert later[0]["title"] == "Working… 9s Β· Running: Grep" + assert later[0]["title"] == "Working… 9s" + assert later[1]["blocks"][0]["title"] == "Steps 1–2 Β· Running: Grep" assert [(t["title"], t["status"]) for t in later[1]["blocks"][0]["tasks"]] == [ ("Read", "complete"), ("Grep", "in_progress"), @@ -229,19 +230,19 @@ async def test_a_page_that_did_not_move_is_not_sent_again() -> None: one it never moves to another, so a settled page is left where it is.""" client = FakeWebClient() adapter = _adapter(client) - many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(50)] + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(51)] ref = await adapter.post_rich( CHANNEL, "Agent", TurnActivity(many, _turn(), 1.0), THREAD ) - over = [*many, _tool("t50", "Tool 50", status="completed")] + over = [*many, _tool("t51", "Tool 51", status="completed")] await adapter.update_rich( CHANNEL, "Agent", ref, TurnActivity(over, _turn(), 9.0), THREAD ) later = [c for c in _chunks(client)[1] if c["type"] == "blocks"] assert len(later) == 1 - assert later[0]["blocks"][0]["title"] == "Steps 51–51" + assert later[0]["blocks"][0]["title"] == "Steps 51–52 Β· Last: Tool 51" async def test_a_publish_that_changed_nothing_appends_nothing() -> None: @@ -269,9 +270,7 @@ async def test_the_clock_moves_the_header_without_resending_a_card() -> None: CHANNEL, "Agent", ref, TurnActivity([tool], _turn(), 10.0), THREAD ) - assert _chunks(client)[1] == [ - {"type": "plan_update", "title": "Working… 10s Β· Running: Read"} - ] + assert _chunks(client)[1] == [{"type": "plan_update", "title": "Working… 10s"}] async def test_a_detail_takes_the_shape_of_the_place_it_is_sent_to() -> None: @@ -424,6 +423,44 @@ async def test_a_redraw_without_the_link_does_not_make_the_stream_forget_it() -> ] +async def test_the_status_card_spins_while_the_turn_runs_and_settles_with_it() -> None: + """Slack draws a block's glyph from the cards in it, and the stream's own + plan holds exactly one. Sent complete from the start it showed a check + beside "Working…", which is the one thing the top of the message should + never say while the turn is still going. + + Turning it over at the end is the one update that card ever takes, and it + has to carry the title with it: measured, an update of id and status alone + stores an empty title, and one that repeats `details` appends the link a + second time. + """ + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read") + url = "https://switch.example/session" + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0, session_url=url), THREAD + ) + done = tool.model_copy(update={"revision": 2, "status": "completed"}) + await adapter.update_rich( + CHANNEL, + "Agent", + ref, + TurnActivity([done], _turn("completed"), 9.0, session_url=url), + THREAD, + ) + + assert [(card["title"], card["status"]) for card in _cards(client)] == [ + ("Switch session", "in_progress"), + ("Switch session", "complete"), + ] + assert [card.get("details") for card in _cards(client)] == [ + f"<{url}|Open in Console app>", + None, + ] + + # ── Paging ─────────────────────────────────────────────────────────────────── @@ -454,7 +491,7 @@ async def test_a_turn_of_any_length_draws_the_same_three_step_blocks() -> None: assert gone["elements"][0]["text"] == "_Steps 1–150 no longer shown_" assert older["title"] == "Steps 151–200" assert [task["title"] for task in older["tasks"]][:1] == ["Tool 150"] - assert newer["title"] == "Steps 201–240" + assert newer["title"] == "Steps 201–240 Β· Last: Tool 239" assert [task["title"] for task in newer["tasks"]][-1:] == ["Tool 239"] @@ -480,12 +517,18 @@ async def test_what_is_no_longer_shown_is_one_line_above_the_steps() -> None: CHANNEL, "Agent", ref, TurnActivity(many, _turn(), 9.0), THREAD ) - assert [block["title"] for block in before] == ["Steps 1–50", "Steps 51–100"] + assert [block["title"] for block in before] == [ + "Steps 1–50", + "Steps 51–100 Β· Last: Tool 99", + ] after = list(_drawn(client).values()) assert [block["type"] for block in after] == ["context", "plan", "plan"] assert after[0]["block_id"] == before[0]["block_id"] assert after[0]["elements"][0]["text"] == "_Steps 1–50 no longer shown_" - assert [block["title"] for block in after[1:]] == ["Steps 51–100", "Steps 101–101"] + assert [block["title"] for block in after[1:]] == [ + "Steps 51–100", + "Steps 101–101 Β· Last: Tool 100", + ] async def test_a_step_never_moves_between_pages_once_it_has_landed() -> None: @@ -505,12 +548,89 @@ async def test_a_step_never_moves_between_pages_once_it_has_landed() -> None: ) moved = [c for c in _chunks(client)[1] if c["type"] == "blocks"] - assert [chunk["blocks"][0]["title"] for chunk in moved] == ["Steps 51–52"] + assert [chunk["blocks"][0]["title"] for chunk in moved] == [ + "Steps 51–52 Β· Last: Tool 51" + ] older, newer = _pages(client) assert [task["title"] for task in older["tasks"]][:1] == ["Tool 0"] assert [task["title"] for task in newer["tasks"]] == ["Tool 50", "Tool 51"] +# ── Where the live step is named ───────────────────────────────────────────── + + +async def test_the_live_step_is_named_on_its_own_section_and_not_in_the_header() -> ( + None +): + """Said once, where it is useful. + + The header is the whole of a collapsed message, so naming the tool there + told a reader what was running but not where to open to watch it. On the + section it does both: the heading that names the step is the one holding + it, and past fifty steps there is more than one heading to choose between. + """ + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(60)] + many[20] = _tool("t20", "Grep") + + await adapter.post_rich(CHANNEL, "Agent", TurnActivity(many, _turn(), 40.0), THREAD) + + assert _chunks(client)[0][0] == {"type": "plan_update", "title": "Working… 40s"} + assert [page["title"] for page in _pages(client)] == [ + "Steps 1–50 Β· Running: Grep", + "Steps 51–60", + ] + + +async def test_the_label_leaves_a_settled_section_when_the_live_step_moves_past_it() -> ( + None +): + """A heading saying what is running has to stop saying it once nothing is. + + Crossing a page boundary is the one moment a settled page is rewritten, and + it is rewritten to drop the label rather than to change its steps. Leaving + it would put "Running: …" on a section where that step has finished and the + reader would open the wrong one. + """ + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(51)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many[:50], _turn(), 1.0), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(many, _turn(), 9.0), THREAD + ) + + first = [c for c in _chunks(client)[0] if c["type"] == "blocks"] + assert [chunk["blocks"][0]["title"] for chunk in first] == [ + "Steps 1–50 Β· Last: Tool 49" + ] + later = [c for c in _chunks(client)[1] if c["type"] == "blocks"] + assert [chunk["blocks"][0]["title"] for chunk in later] == [ + "Steps 1–50", + "Steps 51–51 Β· Last: Tool 50", + ] + + +async def test_a_step_title_is_not_escaped_because_nothing_in_it_is_parsed() -> None: + """Measured, not read: a card's title parses no mrkdwn, so a title sent + escaped is stored and shown escaped. Escaping it put `&&` in front + of a reader wherever a tool call held a shell `&&`.""" + client = FakeWebClient() + adapter = _adapter(client) + shell = 'Bash echo "x" && ls 2>/dev/null' + + await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([_tool("t1", shell)], _turn(), 1.0), THREAD + ) + + assert _steps(client)[0]["title"] == shell + assert _pages(client)[0]["title"] == f"Steps 1–1 Β· Running: {shell}" + + # ── Ending ─────────────────────────────────────────────────────────────────── @@ -727,7 +847,7 @@ async def refuse(**kwargs: Any) -> None: assert refused.value.retry_after == 7.0 assert ref in adapter._streams - assert adapter._streams[ref].title == "Working… 1s Β· Running: Read" + assert adapter._streams[ref].title == "Working… 1s" # ── End to end, through the publisher ──────────────────────────────────────── @@ -761,5 +881,5 @@ async def test_a_turn_published_from_start_to_finish_is_one_streamed_message() - ] == [ ["plan_update", "task_update", "blocks"], ["plan_update"], - ["plan_update", "blocks"], + ["plan_update", "task_update", "blocks"], ] From 72f89a043a1fa43d0a6628a5e42433e9cd33187e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 17:19:31 +0100 Subject: [PATCH 086/120] Mattermost: report a failed card with the options it could not show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card whose options are carried by buttons stops listing them in its body. The text of a `RichContentFailed` has no buttons β€” the caller forwards it as an ordinary message β€” so reporting a refusal with that same drawing asks for a choice it has stopped printing. Wherever buttons were possible the card is now drawn twice, and both drawings travel together: the post gets the one with the options suppressed, every failure gets the one without buttons. That is also the drawing the post itself gets when the card turns out to earn no controls, which folds in the second pass that was already there for the too-big form. All four failure sites take it: a refused post, a refused edit, no bot for the agent, and a connection that has gone. Co-Authored-By: Claude Opus 5 --- .../collaboration/mattermost/adapter.py | 69 ++++++++++++------- .../test_mattermost_card_buttons.py | 63 +++++++++++++++++ 2 files changed, 106 insertions(+), 26 deletions(-) diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 33ea53fbd..ba31bfbc2 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -11,7 +11,7 @@ from collections import OrderedDict from collections.abc import Awaitable, Callable from contextvars import ContextVar -from dataclasses import replace +from dataclasses import dataclass, replace from datetime import datetime from typing import Any, ClassVar @@ -183,6 +183,22 @@ def _as_rich_failure( return None +@dataclass(frozen=True) +class _Rendered: + """A card drawn for Mattermost, and the same card drawn for anywhere else. + + `text` and `actions` go together on the post: where buttons carry the + options the body stops listing them, so neither half is complete alone. + `plain` is the drawing that needs no buttons, and it is what a failure is + reported with β€” a payload the caller forwards as an ordinary message, + which would otherwise ask for a choice it had stopped printing. + """ + + text: str + actions: list[dict[str, Any]] + plain: str + + class MattermostConnectionConfig(BridgeConnectionConfig): url: str admin_user: str @@ -920,9 +936,7 @@ def _controls(self, content: RichContent, drawn: Drawn) -> list[dict[str, Any]]: url, key = address return answer_actions(key, url, content.reference.token, controls) - async def _render_rich( - self, content: RichContent - ) -> tuple[str, list[dict[str, Any]]]: + async def _render_rich(self, content: RichContent) -> _Rendered: mention = await self._mention(content.notify_external_id) responder = ( await self._mention(content.responder_external_id) @@ -936,15 +950,20 @@ async def _render_rich( content, mention=mention, responder=responder, controls=controls ) actions = self._controls(content, drawn) - if controls and not actions: - # The body drops an option only where a button carries it, and - # whether one does is not known until the card has been drawn: a - # form too big to show faithfully earns no controls, and the - # drawing that discovered that had already left the options out. - drawn = self._draw( - content, mention=mention, responder=responder, controls=False - ) - return drawn.text, actions + if not controls: + return _Rendered(text=drawn.text, actions=actions, plain=drawn.text) + # The body drops an option only where a button carries it, so wherever + # buttons were possible the card is also drawn as if none were. That + # second drawing is what a failure is reported with, and it is what + # goes on the post itself when the card turned out to earn no controls + # β€” which is not known until it has been drawn, because a form too big + # to show faithfully is discovered by drawing it. + plain = self._draw( + content, mention=mention, responder=responder, controls=False + ).text + return _Rendered( + text=drawn.text if actions else plain, actions=actions, plain=plain + ) async def post_rich( self, @@ -967,13 +986,13 @@ async def post_rich( Mattermost actually gave. A send whose outcome nobody knows raises the transport's own error and keeps the reservation. """ - text, actions = await self._render_rich(content) + rendered = await self._render_rich(content) driver = self._bot_drivers.get(agent_name) if driver is None: raise RichContentFailed( f"No Mattermost bot for agent {agent_name!r}, so its activity " f"cannot be posted in channel {channel_id}.", - text=text, + text=rendered.plain, ) token = ( content.publication_token @@ -983,17 +1002,17 @@ async def post_rich( props: dict[str, Any] = {} if token: props[_PUBLICATION_PROP] = token - if actions: - props[_ATTACHMENTS_PROP] = [{"actions": actions}] + if rendered.actions: + props[_ATTACHMENTS_PROP] = [{"actions": rendered.actions}] try: ref = await self._post_or_raise( - driver, channel_id, text, thread_root_id, props or None + driver, channel_id, rendered.text, thread_root_id, props or None ) except Exception as error: failure = _as_rich_failure( error, description=f"Mattermost refused the post in channel {channel_id}", - text=text, + text=rendered.plain, ) if failure is None: raise @@ -1032,21 +1051,19 @@ async def update_rich( # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the channel that never resolves to # anything new for the person it names. - text, actions = await self._render_rich( - replace(content, notify_external_id=None) - ) + rendered = await self._render_rich(replace(content, notify_external_id=None)) driver = self._bot_drivers.get(agent_name) or self._admin_driver loop = self._main_loop if driver is None or loop is None: raise RichContentFailed( "Mattermost is not connected, so the post could not be updated.", - text=text, + text=rendered.plain, ) try: - patch: dict[str, Any] = {"message": text} + patch: dict[str, Any] = {"message": rendered.text} if isinstance(content, RequestCard) and self._button_address() is not None: patch["props"] = await self._props_with_actions( - driver, loop, message_ref, actions + driver, loop, message_ref, rendered.actions ) await loop.run_in_executor( None, driver.posts.patch_post, message_ref, patch @@ -1058,7 +1075,7 @@ async def update_rich( f"Mattermost refused the edit to post {message_ref} in " f"channel {channel_id}" ), - text=text, + text=rendered.plain, ) if failure is None: raise diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py index 7fd511dc8..07ffb8fde 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py @@ -23,6 +23,10 @@ from dataclasses import replace from typing import Any +import pytest +from mattermostdriver.exceptions import NotEnoughPermissions + +from switch_core.bridges.collaboration.adapter import RichContentFailed from switch_core.bridges.collaboration.mattermost.adapter import MattermostAdapter from switch_core.bridges.collaboration.mattermost.callback import ( MAX_BUTTON_LABEL, @@ -226,6 +230,65 @@ async def test_the_failure_text_keeps_the_options_it_has_no_buttons_for() -> Non assert "2. Deny" in text +async def test_a_refused_edit_reports_the_options_the_card_stopped_printing() -> None: + """The one that actually reaches a reader. A card whose redraw is refused + is reported with its own text, and the publisher sends that on as an + ordinary message β€” where there are no buttons to carry the options, so the + drawing that suppressed them is the wrong one to report with.""" + adapter, _ = _handled() + card = await _card() + ref = await adapter.post_rich(CHANNEL, "worker", card, "root-1") + _posts(adapter).patch_error = NotEnoughPermissions("403") + + with pytest.raises(RichContentFailed) as raised: + await adapter.update_rich(CHANNEL, "worker", ref, card, "root-1") + + assert "1. Allow once" in raised.value.text + assert "2. Deny" in raised.value.text + assert "1. Allow once" not in _created(adapter)["message"] + + +async def test_a_refused_post_reports_the_options_its_buttons_never_got() -> None: + """Nothing was posted, so the reported text is the only place the options + appear at all.""" + adapter, _ = _handled() + _posts(adapter).create_error = NotEnoughPermissions("403") + + with pytest.raises(RichContentFailed) as raised: + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + assert "1. Allow once" in raised.value.text + assert "2. Deny" in raised.value.text + + +async def test_a_card_that_lost_its_connection_still_says_what_it_was_asking() -> None: + """A dropped connection takes the driver and the loop with it and leaves + the callback address configured, so the drawing that suppressed the options + is still the one this path would otherwise report.""" + adapter, _ = _handled() + card = await _card() + ref = await adapter.post_rich(CHANNEL, "worker", card, "root-1") + adapter._main_loop = None + + with pytest.raises(RichContentFailed) as raised: + await adapter.update_rich(CHANNEL, "worker", ref, card, "root-1") + + assert "1. Allow once" in raised.value.text + assert "2. Deny" in raised.value.text + + +async def test_a_card_with_no_bot_to_post_it_still_says_what_it_was_asking() -> None: + """Refused before Mattermost is reached, and the same reasoning applies: + the buttons that were to carry the options were never posted either.""" + adapter, _ = _handled() + + with pytest.raises(RichContentFailed) as raised: + await adapter.post_rich(CHANNEL, "stranger", await _card(), "root-1") + + assert "1. Allow once" in raised.value.text + assert "2. Deny" in raised.value.text + + async def test_a_button_keeps_its_id_across_a_redraw() -> None: """Mattermost mints an id for an action that arrives without one, and only on the create path. A card is redrawn many times, and an id that changed From 08498d3283013171f8d4cfb814cc0f0fe739af83 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 18:20:45 +0100 Subject: [PATCH 087/120] Slack: save the anchor when the activity is one message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn's activity became a single message when `separate_activity_log` went to False, but `_save_anchor` after `_edit` stayed inside the branch that draws a second one. With no log message the anchor was never persisted, so the state the message was already showing was unknown to the next process to pick the turn up, and every publisher sweep or restart re-edited a message that had not changed β€” collapsing an expanded plan each time. The tests that counted a status message and a tool log separately are updated to the one message they now get, including the durability suites that assert on `channel-demo:N` by position. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/session/outbound.py | 4 +- .../test_session_card_posting.py | 26 +++---- .../sessions/test_activity_durability.py | 71 ++++++++++--------- .../sessions/test_attention_durability.py | 20 +++--- 4 files changed, 62 insertions(+), 59 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 53bdbba58..48f7dfe7f 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -764,7 +764,9 @@ async def _publish( if self._separate_activity_log: drawn = await self._draw_log(anchor, items, turn) and drawn - await self._save_anchor(anchor) + # What the messages are now showing, so the next process to pick this + # turn up can tell a redraw it owes from one nobody would see. + await self._save_anchor(anchor) if ended: record = self._record.get() if record and drawn: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py index 724e444d6..b8c5576bf 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py @@ -583,19 +583,19 @@ async def test_the_trigger_posts_the_recorded_turn_and_then_its_card( assert await demo.handle(TRIGGER, CHANNEL, room_id) is True - assert len(client.posted) == 3 - turn, log, card = (json.dumps(post["blocks"]) for post in client.posted) + assert len(client.posted) == 2 + turn, card = (json.dumps(post["blocks"]) for post in client.posted) assert "Working" in turn assert "same fixture user" not in turn - assert "Ran tests/auth/test_login.py" in log + assert "Ran tests/auth/test_login.py" in turn assert "Edit tests/auth/conftest.py?" in card - assert [post.get("thread_ts") for post in client.posted] == [None, None, None] + assert [post.get("thread_ts") for post in client.posted] == [None, None] async def test_running_the_recording_to_the_end_edits_what_is_already_there( session_factory: async_sessionmaker[AsyncSession], ) -> None: - """Separate status, tool log, and request messages are edited in place. + """The activity and request messages are edited in place. The variant exists to make the anchor visible in a real channel: without it, a turn only ever gets one publish and nothing shows that the second @@ -606,11 +606,11 @@ async def test_running_the_recording_to_the_end_edits_what_is_already_there( assert await demo.handle(f"{TRIGGER} end", CHANNEL, room_id) is True - assert len(client.posted) == 3 - turn, log, card = ( + assert len(client.posted) == 2 + turn, card = ( json.dumps(call["blocks"], ensure_ascii=False) for call in client.updated ) - assert "9 tool calls" in log + assert "Ran tests/auth/test_login.py" in turn assert not client.deleted assert "Turn interrupted. 1 step left unfinished." in turn assert "Permission request closed" in card @@ -635,10 +635,10 @@ async def test_ending_carries_on_the_demo_already_in_the_channel( assert await demo.handle(f"{TRIGGER} end", CHANNEL, room_id) is True assert len(client.posted) == posted - turn, log, card = ( + turn, card = ( json.dumps(call["blocks"], ensure_ascii=False) for call in client.updated ) - assert "9 tool calls" in log + assert "Ran tests/auth/test_login.py" in turn assert not client.deleted assert "Turn interrupted. 1 step left unfinished." in turn assert "Permission request closed" in card @@ -659,7 +659,7 @@ async def test_ending_a_channel_with_no_demo_in_it_runs_one_through( assert await demo.handle(f"{TRIGGER} end", CHANNEL, room_id) is True - assert len(client.posted) == 3 + assert len(client.posted) == 2 assert "Permission request closed" in json.dumps( client.updated[-1]["blocks"], ensure_ascii=False ) @@ -682,7 +682,7 @@ async def test_a_demo_can_only_be_ended_once( posted = len(client.posted) await demo.handle(f"{TRIGGER} end", CHANNEL, room_id) - assert len(client.posted) == posted + 3 + assert len(client.posted) == posted + 2 async def test_each_channel_ends_its_own_demo( @@ -722,7 +722,7 @@ async def test_the_trigger_is_case_insensitive_and_forgives_spacing( assert await demo.handle(f" {TRIGGER.upper()} ", CHANNEL, room_id) - assert len(client.posted) == 3 + assert len(client.posted) == 2 async def test_the_demo_can_be_shown_more_than_once( diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 8dfd81c75..0aa5493cc 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -175,34 +175,32 @@ async def publish( ) -async def test_restart_reuses_status_and_log_and_does_not_repost_completion( +async def test_restart_reuses_the_activity_post_and_does_not_repost_completion( session_factory, ): await setup(session_factory) platform = ActivitySlack() await publish(activity(session_factory, platform)) - assert platform.post_count == 2 + assert platform.post_count == 1 before = len(platform.edit_refs) await publish(activity(session_factory, platform)) - assert platform.post_count == 2 - # Restart/timer-only refresh must not collapse either unchanged disclosure. + assert platform.post_count == 1 + # Restart/timer-only refresh must not collapse the unchanged disclosure. assert platform.edit_refs[before:] == [] await publish(activity(session_factory, platform), "completed") - assert set(platform.messages) == {"channel-demo:1", "channel-demo:2"} + assert set(platform.messages) == {"channel-demo:1"} assert not platform.reactions await publish(activity(session_factory, platform), "completed") - assert platform.post_count == 2 + assert platform.post_count == 1 -@pytest.mark.parametrize("slot", ["status", "log"]) -async def test_lost_post_response_is_recovered_without_duplicate(session_factory, slot): +async def test_lost_post_response_is_recovered_without_duplicate(session_factory): await setup(session_factory) platform = ActivitySlack() original_post = platform.post_rich async def lose_response(channel, agent, content, thread): - if content.tool_log == (slot == "log"): - platform.fail_after_post = True + platform.fail_after_post = True return await original_post(channel, agent, content, thread) platform.post_rich = lose_response @@ -210,11 +208,11 @@ async def lose_response(channel, agent, content, thread): await publish(activity(session_factory, platform)) platform.post_rich = original_post await publish(activity(session_factory, platform)) - assert platform.post_count == 2 - assert len(platform.messages) == 2 + assert platform.post_count == 1 + assert len(platform.messages) == 1 -async def test_final_log_edit_failure_is_retried_after_restart( +async def test_a_final_edit_failure_is_retried_after_restart( session_factory, monkeypatch ): await setup(session_factory) @@ -222,18 +220,18 @@ async def test_final_log_edit_failure_is_retried_after_restart( await publish(activity(session_factory, platform)) original = platform.update_rich - async def fail_log(channel, agent, ref, content, thread): - if content.tool_log: - raise TimeoutError("Final log edit failed") + async def fail_ending(channel, agent, ref, content, thread): + if content.turn.status == "completed": + raise TimeoutError("Final edit failed") return await original(channel, agent, ref, content, thread) with monkeypatch.context() as patch: - patch.setattr(platform, "update_rich", fail_log) + patch.setattr(platform, "update_rich", fail_ending) with pytest.raises(TimeoutError): await publish(activity(session_factory, platform), "completed") await publish(activity(session_factory, platform), "completed") - assert set(platform.messages) == {"channel-demo:1", "channel-demo:2"} - assert platform.post_count == 2 + assert set(platform.messages) == {"channel-demo:1"} + assert platform.post_count == 1 assert not platform.reactions @@ -263,7 +261,7 @@ async def test_competing_publishers_share_one_durable_anchor(session_factory): publish(activity(session_factory, platform)), publish(activity(session_factory, platform)), ) - assert platform.post_count == 2 + assert platform.post_count == 1 async def test_lease_expiry_hides_buttons_without_new_events_and_reconnect_restores_them( @@ -362,7 +360,9 @@ async def test_existing_older_turn_is_finished_after_restart_without_replaying_h await publisher.publish_pending() assert "channel-demo:1" in platform.messages assert "channel-demo:2" in platform.messages - assert platform.post_count == 4 # New turn creates a status and reserved tool log. + assert ( + platform.post_count == 2 + ) # The new turn posts an activity message of its own. publisher = SessionPublisher( session_factory, "bridge", @@ -370,7 +370,7 @@ async def test_existing_older_turn_is_finished_after_restart_without_replaying_h activity(session_factory, platform), ) await publisher.publish_pending() - assert platform.post_count == 4 + assert platform.post_count == 2 async def test_unknown_delivery_without_a_match_never_blindly_reposts(session_factory): @@ -757,7 +757,7 @@ async def test_definite_post_rejection_can_be_retried(session_factory, monkeypat ) assert not await publish(activity(session_factory, platform)) assert await publish(activity(session_factory, platform)) - assert platform.post_count == 2 + assert platform.post_count == 1 @pytest.mark.parametrize("restart", [False, True]) @@ -784,8 +784,8 @@ async def test_provisional_error_receipt_becomes_real_turn_in_place( if restart: renderer = activity(session_factory, platform) await publish(renderer, real_status, tools=False) - assert platform.post_count == 2 - assert set(platform.messages) == {"channel-demo:1", "channel-demo:2"} + assert platform.post_count == 1 + assert set(platform.messages) == {"channel-demo:1"} assert "errored" not in platform.messages["channel-demo:1"].text.lower() assert "channel-demo:1" in platform.edit_refs @@ -850,13 +850,15 @@ async def test_pending_command_cannot_hide_recorded_completion_or_replay_stale_e ) await publisher.publish_pending() if durable: + # One activity message per turn, and an accepted command is a turn of + # its own on top of the recorded completion. assert "channel-demo:1" in platform.messages - assert "channel-demo:2" in platform.messages - assert platform.post_count == (4 if pending_status == "accepted" else 2) + assert platform.post_count == (2 if pending_status == "accepted" else 1) + assert ("channel-demo:2" in platform.messages) == (pending_status == "accepted") else: # Each fresh demo publisher redraws the latest real completion. Pending # errors are not replayed, and a queued receipt cannot hide that completion. - assert platform.post_count == (10 if pending_status == "accepted" else 6) + assert platform.post_count == (5 if pending_status == "accepted" else 3) @pytest.mark.parametrize( @@ -922,7 +924,7 @@ async def test_an_unacknowledged_command_is_not_reported_as_one_the_agent_failed assert not_says not in said -async def test_reaction_failure_retries_without_blocking_log(session_factory): +async def test_reaction_failure_retries_without_blocking_the_activity(session_factory): from unittest.mock import AsyncMock await setup(session_factory) @@ -931,7 +933,7 @@ async def test_reaction_failure_retries_without_blocking_log(session_factory): platform.mark_activity = AsyncMock(side_effect=TimeoutError("reaction failed")) renderer = activity(session_factory, platform) assert await publish(renderer) - assert platform.post_count == 2 + assert platform.post_count == 1 platform.mark_activity = original assert await publish(renderer) assert platform.reactions == {"channel-demo:question"} @@ -1348,7 +1350,7 @@ async def test_throttled_initial_post_retries_without_uncertain_reservation( await publish(renderer) platform.post_rich = original assert await publish(renderer) - assert platform.post_count == 2 + assert platform.post_count == 1 async def test_completed_journal_discards_anchors_but_keeps_replay_receipt( @@ -1368,7 +1370,7 @@ async def test_completed_journal_discards_anchors_but_keeps_replay_receipt( "completed": True, } await publish(activity(session_factory, platform), "completed") - assert platform.post_count == 2 + assert platform.post_count == 1 async def test_publisher_reserves_activity_before_an_early_request(session_factory): @@ -1383,10 +1385,9 @@ async def test_publisher_reserves_activity_before_an_early_request(session_facto ) await publisher.publish_pending() messages = list(platform.messages.values()) - assert len(messages) == 3 + assert len(messages) == 2 assert "Working" in messages[0].text - assert "No tool calls yet" in messages[1].text - assert any(block["type"] == "actions" for block in messages[2].blocks) + assert any(block["type"] == "actions" for block in messages[1].blocks) # ── The real Mattermost adapter, not a stand-in ────────────────────────────── diff --git a/core/tests/switch_core/sessions/test_attention_durability.py b/core/tests/switch_core/sessions/test_attention_durability.py index 929079acf..9896af0f1 100644 --- a/core/tests/switch_core/sessions/test_attention_durability.py +++ b/core/tests/switch_core/sessions/test_attention_durability.py @@ -28,16 +28,16 @@ async def test_restart_reuses_attention_and_updates_it_when_host_returns( await setup(session_factory) platform = ActivitySlack() await publish(activity(session_factory, platform)) - assert platform.post_count == 3 + assert platform.post_count == 2 await publish(activity(session_factory, platform)) - assert platform.post_count == 3 + assert platform.post_count == 2 await publish(activity(session_factory, platform), error=None) - assert platform.post_count == 3 - assert "offline" not in platform.messages["channel-demo:3"].text + assert platform.post_count == 2 + assert "offline" not in platform.messages["channel-demo:2"].text await publish(activity(session_factory, platform), status="completed", error=None) await publish(activity(session_factory, platform), status="completed", error=None) - assert platform.post_count == 3 - assert "complete" in platform.messages["channel-demo:3"].text.lower() + assert platform.post_count == 2 + assert "complete" in platform.messages["channel-demo:2"].text.lower() class LostAttentionResponse(ActivitySlack): @@ -56,13 +56,13 @@ async def test_lost_attention_response_is_recovered_without_reposting( platform = LostAttentionResponse() with pytest.raises(TimeoutError): await publish(activity(session_factory, platform)) - assert platform.post_count == 3 + assert platform.post_count == 2 await publish( activity(session_factory, platform), error=None if recovered else "The host is offline.", ) - assert platform.post_count == 3 - assert ("offline" in platform.messages["channel-demo:3"].text) is not recovered + assert platform.post_count == 2 + assert ("offline" in platform.messages["channel-demo:2"].text) is not recovered async def test_terminal_error_receipt_prevents_attention_replay(session_factory): @@ -74,4 +74,4 @@ async def test_terminal_error_receipt_prevents_attention_replay(session_factory) await publish( activity(session_factory, platform), status="error", error="The request failed." ) - assert platform.post_count == 3 + assert platform.post_count == 2 From a0ff988ab7144f9221c31c1ad1b138210916eb48 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 18:28:34 +0100 Subject: [PATCH 088/120] Discord: show a turn's tool calls privately, on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack posts the per-call log into the channel beside the status. Discord showed only the status: state, what it is doing now, and a tally. The calls were rendered nowhere, so the detail existed in the code and not on the platform. The status now carries a View activity button. Pressing it opens a message only the presser can see, with the calls oldest-first, when the read was taken, a Refresh and a link into Console. Nothing is added to the channel's history, and a second reader pressing the same button gets their own copy. Three pieces make that possible. `activity_log` in the neutral renderer draws the list for any platform with somewhere to put one, against a single budget for the whole message. When the budget runs out it cuts the oldest end, and it pays for the line saying how many it cut out of the calls rather than out of the leftover space: a log that quietly showed its tail would read as a turn that only made those calls. The address of a status message now outlives the turn it is showing. The anchor a publisher keeps is a delivery reservation and is discarded the moment an ordinary turn ends, while the message stays in the channel with its button on it, so `_save_anchor` writes the address separately and the compaction keeps it. `activity_shown_at` reads a turn back from that address, re-running every check the publisher makes before it may draw one: tenant, bridge, the room the command came from, the channel that room points at now, and the agent's membership of it. Not the surface the command arrived on β€” a turn is published to the room whatever asked for it. Who may read it is the adapter's to decide, because only it knows who pressed. A press is authorised against the conversation the reference names, never against the one it arrived from: the initial view has those two the same, but Refresh sits on a private message with no journal row and so carries an address, and an address a presser supplied is treated as though it had been typed. A private thread is checked by membership rather than by the parent's permissions, and a refresh arriving on a public message is refused outright, since an update would rewrite that message into somebody's tool log. Whether an ephemeral message survives a client reload, and whether a component on one yields a fresh interaction token past fifteen minutes, are not documented and need a live check. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 39 + .../bridges/collaboration/bridge_core.py | 27 + .../bridges/collaboration/discord/adapter.py | 339 +++++++- .../collaboration/session/activity_journal.py | 28 + .../bridges/collaboration/session/outbound.py | 29 +- .../session/renderers/neutral.py | 84 +- core/switch_core/sessions/publication.py | 95 +++ .../test_bridge_identity_backfill.py | 3 + .../test_discord_activity_view.py | 748 ++++++++++++++++++ .../test_session_activity_log.py | 210 +++++ .../sessions/test_activity_durability.py | 3 + .../sessions/test_activity_readback.py | 240 ++++++ 12 files changed, 1839 insertions(+), 6 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_session_activity_log.py create mode 100644 core/tests/switch_core/sessions/test_activity_readback.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index a32091360..4f688a3f4 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -105,6 +105,25 @@ class TurnActivity: error_summary: str | None = None +@dataclass(frozen=True) +class ActivitySnapshot: + """The same turn as `TurnActivity`, read back because a reader asked. + + `TurnActivity` is pushed at an adapter when a turn changes; this is pulled + by one because somebody operated a control on the message a turn is being + shown in, and wants what is behind it. What that reader is shown must + therefore say when it was read: a view opened ten minutes ago and never + refreshed is not wrong, but it is not current either, and it has no way of + knowing that unless it is told. + """ + + items: list[Item] + turn: TurnUpsert + elapsed_seconds: float | None + session_url: str | None + read_at: datetime + + @dataclass(frozen=True) class RequestCard: """A request and how a platform refers back to it, as `post_rich` / @@ -387,6 +406,14 @@ def __init__(self) -> None: self._on_interaction: Callable[[InboundInteraction], Awaitable[None]] | None = ( None ) + # Set by set_activity_resolver. Asked what turn is being shown in the + # message at (channel, reference), for a platform that offers a reader + # the activity behind a status rather than printing it. None is the + # answer for a message this bridge is not showing a turn in, which + # includes every message once the session or the bridge is gone. + self._resolve_activity: ( + Callable[[str, str], Awaitable[ActivitySnapshot | None]] | None + ) = None # Set by set_channel_migration_handler. Called with (old_id, new_id) # when the platform reissues a channel's id. self._on_channel_migrated: Callable[[str, str], Awaitable[None]] | None = None @@ -1169,6 +1196,18 @@ def set_interaction_handler( that never does needs no change to go on working.""" self._on_interaction = handler + def set_activity_resolver( + self, resolver: Callable[[str, str], Awaitable[ActivitySnapshot | None]] + ) -> None: + """Install the read-back for the turn behind a status message. + + Separate from the interaction handler because the two answer different + questions. That one carries an answer inwards and is told nothing + back; this one is a read, made because somebody is waiting on the + platform for what it returns, and the platform is holding an + acknowledgement open until it does.""" + self._resolve_activity = resolver + async def is_first_reply( self, channel_id: str, root_ref: str, message_ref: str ) -> bool: diff --git a/core/switch_core/bridges/collaboration/bridge_core.py b/core/switch_core/bridges/collaboration/bridge_core.py index d35d2f5a1..b58ace9e0 100644 --- a/core/switch_core/bridges/collaboration/bridge_core.py +++ b/core/switch_core/bridges/collaboration/bridge_core.py @@ -14,6 +14,7 @@ from switch_core.aliases import AliasError, validate_alias_format from switch_core.attachments import parse_attachment_group from switch_core.bridges.collaboration.adapter import ( + ActivitySnapshot, AgentPresentation, CollaborationAdapter, ) @@ -358,6 +359,8 @@ async def start(self) -> None: self._adapter.set_interaction_handler( self._traced(self._handle_inbound_interaction) ) + if self._session_publisher is not None: + self._adapter.set_activity_resolver(self._activity_shown_at) await self._adapter.start( on_message=self._traced(self._handle_inbound_message), on_command=self._traced(self._handle_inbound_command), @@ -1307,6 +1310,30 @@ async def _handle_inbound_interaction( # person's rejected approval in front of everybody in it. await self._submit_session_command(outcome, interaction, thread_ref=None) + async def _activity_shown_at( + self, channel_id: str, ref: str + ) -> ActivitySnapshot | None: + """What turn a message of ours is showing, for an adapter that is asked. + + Tenant-bound here for the same reason every inbound path is bound in + `_traced`: the platform calls this from wherever its own event loop + happens to be, and nothing about a Discord press says which tenant its + channel belongs to. A channel with no room is not one this bridge has + published a turn into, so the read is scoped to the bridge's own + tenant and finds nothing, rather than guessing at another's. + """ + publisher = self._session_publisher + if publisher is None: + return None + room_ids = self._channel_to_room.get(channel_id) + tenant_id = ( + self._bridge_tenant_id + if room_ids is None + else await self._room_tenant(room_ids[0]) + ) + with tenant_scope(tenant_id): + return await publisher.activity_shown_at(channel_id, ref) + async def _handle_text_answer(self, msg: InboundMessage) -> None: """The same answer, typed rather than pressed. diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 53a368a30..1582748fe 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -21,6 +21,7 @@ from switch_core.bridges.collaboration.adapter import ( ActivityMark, ActivityMarkRefused, + ActivitySnapshot, CollaborationAdapter, RemovalFailed, RequestCard, @@ -59,6 +60,7 @@ position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + activity_log, render_request, turn_status, ) @@ -133,10 +135,80 @@ ) +# The two buttons that are about a turn rather than about a card, and the one +# thing each of them has to say to be recognised on the way back. +# +# Neither carries an identifier that has to be known before the message exists. +# `View activity` sits on the status message and says only what kind of press +# it is: which turn it is about is the message it arrived on, which Discord +# fills in and a client cannot write. `Refresh` sits on a private copy that no +# journal has a row for, so it carries the address of the public message it was +# opened from β€” a locator, resolved and re-authorised from scratch on every +# press, never taken as evidence of anything. +_ACTIVITY_PREFIX = "swact" +_ACTIVITY_VIEW_ID = f"{_ACTIVITY_PREFIX}:v" +_ACTIVITY_REFRESH_ID = f"{_ACTIVITY_PREFIX}:r" +_ACTIVITY_LABEL = "View activity" +_REFRESH_LABEL = "Refresh" +_CONSOLE_LABEL = "Open in Switch Console" + +# What a reader is told when the press cannot be answered, privately and in +# place of the log. Said rather than left silent: a button that does nothing +# reads as Discord having dropped the press. +_ACTIVITY_GONE = ( + "There is no activity behind this message any more. It may belong to a " + "session that has since been removed." +) +_ACTIVITY_UNREADABLE = ( + "You can no longer read the conversation this turn was published into, so " + "its activity is not shown." +) +_ACTIVITY_FAILED = ( + "Switch could not read this turn's activity just now. Try again, or open " + "the session in Switch Console." +) + + def _custom_id(token: str, position: int) -> str: return f"{_CUSTOM_ID_PREFIX}:{token}:{position}" +def _refresh_id(ref: str) -> str: + return f"{_ACTIVITY_REFRESH_ID}:{ref}" + + +def _parse_refresh_id(custom_id: str) -> str | None: + """The public status message a refresh is about, or None if it is not one. + + Read as strictly as `_parse_custom_id`, and trusted no further: what comes + back is an address, and every check the first view passed is made again + against it. A reference to a message showing nothing, or to one this reader + may not read, answers exactly as it would have on the way in. + """ + parts = custom_id.split(":", 2) + if len(parts) != 3: + return None + prefix, kind, ref = parts + if prefix != _ACTIVITY_PREFIX or kind != "r" or not ref: + return None + return ref + + +def _conversation_in(ref: str) -> int | None: + """The channel or thread half of a `:` address. + + Which conversation a reference names, rather than which message in it: the + message is the journal's business, and where it is showing is what decides + whether the reader in front of us is entitled to any of it. Refuses + anything that is not a pair of Discord ids, because this one is read off a + press rather than out of our own records. + """ + location, _, message = ref.partition(":") + if not location.isdigit() or not message.isdigit(): + return None + return int(location) + + def _parse_custom_id(custom_id: str) -> tuple[str, int] | None: """The card and the option a press names, or None if it is not ours. @@ -1023,7 +1095,7 @@ def _render_rich( prefix=prefix, controls=bool(offered), ) - return drawn.text, self._controls(content, drawn, offered) + return drawn.text, self._controls(content, drawn, offered, controls=controls) def _offered(self, content: RichContent) -> list[Control]: """The options this card would put on buttons, before it is drawn. @@ -1050,7 +1122,12 @@ def _offered(self, content: RichContent) -> list[Control]: return offered def _controls( - self, content: RichContent, drawn: Drawn, offered: list[Control] + self, + content: RichContent, + drawn: Drawn, + offered: list[Control], + *, + controls: bool, ) -> discord.ui.View | None: """The card's options as buttons, or nothing where a press cannot land. @@ -1079,6 +1156,8 @@ def _controls( a restart β€” and an unstopped view is filed in the client's view store for the life of the process, one per card ever posted. """ + if isinstance(content, TurnActivity): + return self._activity_control(content) if controls else None if not isinstance(content, RequestCard) or not offered or not drawn.answerable: return None view = discord.ui.View(timeout=None) @@ -1101,6 +1180,38 @@ def _controls( view.stop() return view + def _activity_control(self, content: TurnActivity) -> discord.ui.View | None: + """The way into a turn's tool log, on the status message that hides it. + + Discord's status is three lines: where the turn got to, what it is + doing, and how the calls went. Slack posts the calls themselves into + the channel beside it; here they are a press away and private to + whoever presses, which is a placement decision rather than an access + one β€” the same list, read by one person instead of by a channel. + + Offered only where this bridge can actually answer it. A publisher is + what knows which turn a message is showing, and an adapter running + without one β€” a demo, a test, a bridge whose sessions are not + published β€” would be drawing a button onto a question nobody can + resolve. + + Nothing is offered beside the attention slot: that message is one + sentence saying somebody has to act, and a control under it about tool + calls is an invitation away from the thing it is asking for. + """ + if self._resolve_activity is None or content.error_summary: + return None + view = discord.ui.View(timeout=None) + view.add_item( + discord.ui.Button( + label=_ACTIVITY_LABEL, + custom_id=_ACTIVITY_VIEW_ID, + style=discord.ButtonStyle.secondary, + ) + ) + view.stop() + return view + def _mention(self, external_user_id: str | None) -> str | None: """`<@id>` for a Discord user id, or None where there is nothing to name. @@ -2438,7 +2549,11 @@ async def _handle_interaction(self, interaction: discord.Interaction) -> None: if interaction.guild_id is not None and interaction.guild_id != self._guild_id: return data: dict[str, Any] = dict(interaction.data or {}) - press = _parse_custom_id(str(data.get("custom_id") or "")) + custom_id = str(data.get("custom_id") or "") + if custom_id.split(":", 1)[0] == _ACTIVITY_PREFIX: + await self._handle_activity(interaction, custom_id) + return + press = _parse_custom_id(custom_id) if press is None: return channel = interaction.channel @@ -2502,6 +2617,224 @@ async def _handle_interaction(self, interaction: discord.Interaction) -> None: if notices: await self._tell_presser(interaction, notices[0]) + async def _handle_activity( + self, interaction: discord.Interaction, custom_id: str + ) -> None: + """Show one reader the tool calls behind a turn's status message. + + Two presses arrive here and they are answered differently. The one on + the public status opens a private message that did not exist a moment + ago, so it defers a new response; the one on that private message + rewrites it, so it defers the update to the message it is on. Both + acknowledge before any read, because Discord allows three seconds and + neither the journal nor the permission check is bounded by them. + + A refresh is refused unless the message it arrived on is itself + private. That is the guard that matters here: an update answers + whatever message the component was attached to, so a press carrying + this id from anywhere else would rewrite a channel's status message + into its tool log. Read off the message Discord names rather than off + the id, which is the half a client could have chosen. + """ + channel = interaction.channel + message = interaction.message + if channel is None or message is None: + return + refreshing = custom_id != _ACTIVITY_VIEW_ID + if refreshing: + ref = _parse_refresh_id(custom_id) + if ref is None: + return + if not message.flags.ephemeral: + logger.warning( + "Refusing a refresh of a Switch activity view that arrived " + "on public message %s in channel %s: an update would " + "rewrite that message.", + message.id, + channel.id, + ) + return + else: + ref = f"{channel.id}:{message.id}" + + try: + if refreshing: + await interaction.response.defer() + else: + await interaction.response.defer(ephemeral=True, thinking=True) + except discord.HTTPException: + logger.exception( + "Discord would not accept the acknowledgement of a press for " + "activity in channel %s, so nothing is shown: a press that is " + "not acknowledged in time is one the presser is told failed.", + channel.id, + ) + return + await self._show_activity(interaction, ref) + + async def _show_activity(self, interaction: discord.Interaction, ref: str) -> None: + """Read the turn behind `ref` and put it in front of this reader alone. + + The reader is authorised against the conversation `ref` names, never + against the one the press arrived from. Only the initial view has those + two the same; a refresh carries an address, and an address the presser + supplied is authorised as though it had been typed β€” otherwise a + reference to a private thread, pressed from a public one beside it, + would be read with the public thread's permissions. + + Made again on every press, not once. A private message stays on the + screen after the reader has lost the conversation it came from, and a + refresh is a fresh read rather than the continuation of an older one. + + Everything that can go wrong is said rather than left silent. A button + that answers with nothing reads as Discord having dropped the press, + and the reader would go on pressing it. + """ + resolve = self._resolve_activity + location_id = _conversation_in(ref) + if resolve is None or location_id is None: + await self._privately(interaction, _ACTIVITY_GONE, ref) + return + try: + location = await self._get_channel(location_id) + except (discord.HTTPException, RuntimeError): + logger.warning( + "Discord would not say what channel %s is, so the activity " + "behind message %s is not shown.", + location_id, + ref, + ) + await self._privately(interaction, _ACTIVITY_GONE, ref) + return + if not await self._still_reads(location, interaction.user): + await self._privately(interaction, _ACTIVITY_UNREADABLE, ref) + return + parent_id = getattr(location, "parent_id", None) + channel_id = str(parent_id if parent_id is not None else location.id) + try: + snapshot = await resolve(channel_id, ref) + except Exception: + logger.exception( + "Reading the activity behind message %s in Discord channel %s " + "failed, so the reader is told rather than left waiting.", + ref, + channel_id, + ) + await self._privately(interaction, _ACTIVITY_FAILED, ref) + return + if snapshot is None: + await self._privately(interaction, _ACTIVITY_GONE, ref) + return + await self._privately( + interaction, + self._activity_text(snapshot), + ref, + session_url=snapshot.session_url, + ) + + def _activity_text(self, snapshot: ActivitySnapshot) -> str: + """The tool log, and when it was read. + + The time is Discord's own relative stamp, which the client rewrites as + it ages: a view left open says "20 minutes ago" without anything here + having to refresh it, so a reader can tell a stale snapshot from a + current one before deciding whether to press. + """ + stamp = f"Read " + body = activity_log( + snapshot.items, + snapshot.turn, + escape=self._rich_escape, + limit=max(1, MAX_MESSAGE - len(stamp) - 1), + markup=self.rich_markup(), + elapsed_seconds=snapshot.elapsed_seconds, + session_url=None, + ) + return f"{body}\n{stamp}" + + async def _privately( + self, + interaction: discord.Interaction, + text: str, + ref: str, + *, + session_url: str | None = None, + ) -> None: + """Answer the press in the private message it has already deferred. + + `edit_original_response` rather than a follow-up, for both kinds of + press: after the initial view's deferral the original response is the + empty private message Discord is already showing, and after a + refresh's it is the private message the button sits on. A follow-up + would leave the first standing and stack a second copy under it. + """ + view = discord.ui.View(timeout=None) + view.add_item( + discord.ui.Button( + label=_REFRESH_LABEL, + custom_id=_refresh_id(ref), + style=discord.ButtonStyle.secondary, + ) + ) + if session_url and session_url.startswith(("https://", "http://")): + view.add_item( + discord.ui.Button(label=_CONSOLE_LABEL, url=session_url), + ) + view.stop() + try: + await interaction.edit_original_response(content=text, view=view) + except discord.HTTPException as error: + logger.warning( + "Discord would not carry the activity view for message %s (%s).", + ref, + error, + ) + + async def _still_reads(self, channel: Any, user: Any) -> bool: + """Whether this reader can still read the conversation a turn is in. + + A direct message is the reader's own channel and there is nobody else + in it to ask about. A private thread is the case a channel's + permissions cannot answer on their own: everyone who can see the + parent passes that check, and only membership of the thread says who + is actually in it. + + Refused where the answer cannot be established. A destination this + cannot ask about is one nothing here can say a reader may see, and the + reader is told that rather than shown the log on the strength of not + having been able to check. + """ + guild = getattr(channel, "guild", None) + if guild is None: + return True + permissions_for = getattr(channel, "permissions_for", None) + if permissions_for is None: + logger.warning( + "Cannot establish who may read Discord channel %s, so an " + "activity view of it is refused.", + getattr(channel, "id", "?"), + ) + return False + member = guild.get_member(user.id) + if member is None: + try: + member = await guild.fetch_member(user.id) + except discord.HTTPException: + return False + allowed = permissions_for(member) + if not (allowed.view_channel and allowed.read_message_history): + return False + is_private = getattr(channel, "is_private", None) + if is_private is None or not is_private(): + return True + if allowed.manage_threads: + return True + try: + await channel.fetch_member(user.id) + except discord.HTTPException: + return False + return True + async def _tell_presser( self, interaction: discord.Interaction, notice: str ) -> None: diff --git a/core/switch_core/bridges/collaboration/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index 27fa13fa1..13790d4fe 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -224,6 +224,34 @@ async def recorded_commands(self, session_id: str) -> set[str]: ) ) + async def shown_at(self, channel_id: str, ref: str) -> tuple[str, str] | None: + """The session and command whose turn is being shown in this message. + + The address outlives everything else the row holds: a completed turn + keeps only a receipt, and that receipt is written while its message is + still on the screen and can still be asked what the turn did. + + Scoped to this bridge and this tenant, and matched on the pair rather + than on the reference alone β€” a platform numbering its messages per + channel would otherwise answer for a message in a different one. + """ + async with self.sessions() as db: + found = ( + await db.execute( + select( + SessionActivityPost.session_id, + SessionActivityPost.command_id, + ).where( + SessionActivityPost.tenant_id == require_tenant_id(), + SessionActivityPost.bridge_id == self.bridge_id, + SessionActivityPost.data.contains( + {"shown": {"channel_id": channel_id, "ref": ref}} + ), + ) + ) + ).one_or_none() + return (found.session_id, found.command_id) if found else None + async def reaction_held( self, key: tuple[str, str], diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 53bdbba58..013cdccbe 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -267,6 +267,19 @@ def __init__( def durable(self) -> bool: return self._journal is not None + async def shown_at(self, channel_id: str, ref: str) -> tuple[str, str] | None: + """The session and command whose turn this message is showing. + + Answered from the journal rather than from the anchors held here: the + message outlives both the turn and the process, and a reader operating + a control on one long afterwards is the ordinary case rather than the + exceptional one. A publisher with no journal has nothing to answer + from and says so. + """ + if self._journal is None: + return None + return await self._journal.shown_at(channel_id, ref) + @property def notifies_only_by_mention(self) -> bool: """Whether naming someone is the only way this platform reaches them. @@ -451,13 +464,16 @@ async def draw() -> bool: # and it needs to know the mark is there. The row keeps # it either way β€” a save cannot write the claim β€” so # this carries it across to keep the copy here honest - # about what the row still says. + # about what the row still says. `shown` survives for a + # different reason: the message is still in the channel + # after the turn it is showing has ended, and it is the + # only thing left that says which turn that was. record.data = { "turn_id": turn.turn_id, "ended": True, **{ field: record.data[field] - for field in ("mark", "mark_attempt") + for field in ("mark", "mark_attempt", "shown") if field in record.data }, } @@ -566,6 +582,15 @@ async def _save_anchor(self, anchor: _Anchor) -> None: record = self._record.get() if record: record.data["anchor"] = asdict(anchor) + # Written beside the anchor rather than read out of it, because + # the two do not live the same length of time: the anchor is a + # delivery reservation and is discarded when the turn ends, while + # the message it named stays in the channel and can still be asked + # what the turn did. + record.data["shown"] = { + "channel_id": anchor.channel_id, + "ref": anchor.message_ref, + } await record.save() async def _post_activity( diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 7c154141e..cbf8a6614 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -7,7 +7,7 @@ looking at a paragraph rather than at a card, and for a writer who has one message to say everything in. -Three renderings live here: +Four renderings live here: - `turn_summary` β€” the oldest and the least: the last thing the agent said and whether the turn is still going. What a platform falls back to with no @@ -16,6 +16,10 @@ where the turn got to, how long it has been going, what it is doing now, how the tool calls went, and one link to the Console. One message, edited, never a second one. +- `activity_log` β€” the calls behind that status, one to a line. Not part of the + status and not posted beside it: what a platform draws where it has somewhere + to put a list, which on Discord is a message only the reader who asked for it + can see. - `request_summary` β€” the text form of a request, in every state it can be in, with the typed-answer grammar the card is asking for spelled out against this particular form. @@ -95,6 +99,19 @@ "declined": "⊘", } +# How much of one call's name and result the log prints. The same two ceilings +# `slack.py` uses, so the same call does not read as a different length +# depending on which platform showed it. How many lines there is room for is +# left to the message budget, which is the only real bound on a platform whose +# log is body text. +_LOG_TITLE = 200 +_LOG_DETAIL = 120 + +_LOG_UNTITLED = "(untitled)" +_LOG_EMPTY = "No tool calls." +_LOG_EMPTY_YET = "No tool calls yet." +_LOG_CUT = "…{left} earlier in this turn, not shown." + _OUTCOME_WORDS = { "in-progress": "running", "completed": "done", @@ -311,6 +328,71 @@ def _doing( return lines +def activity_log( + items: list[Item], + turn: TurnUpsert, + *, + escape: Callable[[str], str], + limit: int, + markup: Markup, + elapsed_seconds: float | None, + session_url: str | None, +) -> str: + """The tool calls behind a turn, one to a line, oldest cut first. + + `turn_status` is the line that sits beside a running turn and says what it + is doing. This is the list behind that line and says what it did. Slack + already posts the same list into the conversation as a message of its own, + so a platform drawing it somewhere narrower is deciding where it is read, + not what is in it. + + The cut is at the front because the newest end is what a reader came for, + and it says how many it took: a log that quietly showed its tail reads as + a turn that only made those calls. + """ + head = markup.bold( + turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds) + ) + link = _link(_CONSOLE, session_url, markup) + if link and len(head) + 3 + len(link) <= limit: + head = f"{head} Β· {link}" + + did = [item for item in items if item.kind == "tool-activity"] + if not did: + nothing = _LOG_EMPTY if turn.status in TURN_ENDED else _LOG_EMPTY_YET + return _truncate(f"{head}\n{nothing}", limit) + + head = _truncate(head, limit) + spent = len(head) + lines: list[str] = [] + omitted = 0 + for position, item in enumerate(reversed(did), start=1): + title = ( + _fit(item.title, _LOG_TITLE, escape=escape) if item.title else _LOG_UNTITLED + ) + line = f"{_OUTCOME[item.status]} {title}" + if item.text: + line += f" β€” {_fit(item.text, _LOG_DETAIL, escape=escape)}" + if spent + len(line) + 1 > limit: + omitted = len(did) - position + 1 + break + lines.append(line) + spent += len(line) + 1 + if omitted: + # The note is paid for out of the calls, not out of what is left over: + # a log that ran out of room for the line saying so is a log claiming + # the turn made only the calls it had room to print. + note = _LOG_CUT.format(left=omitted) + while lines and spent + len(note) + 1 > limit: + spent -= len(lines.pop()) + 1 + omitted += 1 + note = _LOG_CUT.format(left=omitted) + if spent + len(note) + 1 <= limit: + lines.append(note) + lines.reverse() + return "\n".join([head, *lines]) + + def request_summary( request: SnapshotRequest, reference: RequestReference, diff --git a/core/switch_core/sessions/publication.py b/core/switch_core/sessions/publication.py index f2142b903..733f412e0 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from switch_core.bridges.collaboration.adapter import ( + ActivitySnapshot, RemovalFailed, RichContentThrottled, ) @@ -871,6 +872,85 @@ async def refresh_activity( return any(turn.status == "running" for turn in turns) +async def activity_shown_at( + session_factory: async_sessionmaker[AsyncSession], + bridge_id: str, + activity: SessionTurnActivity, + channel_id: str, + ref: str, + *, + gateway_public_url: str | None, +) -> ActivitySnapshot | None: + """The turn a message in this channel is showing, read on demand. + + `refresh_activity` pushes a turn out because it changed. This pulls one + back because a reader operated a control on the message it is being shown + in, and everything about the two is different: nobody is publishing, one + turn is wanted rather than all of them, and somebody is waiting. + + Every check the publisher makes before it may draw a turn in a channel is + made again here, against the same sources, because the answer can have + changed since the drawing: a command reattributed, a room moved to another + bridge, an agent removed from it. Each of them is None, and so is a message + showing nothing β€” this is a read on a reference a caller got off a + platform, so "no such thing" is an ordinary answer rather than a fault. + + The surface a command arrived on is deliberately not among them. A turn is + published to the room whatever asked for it, so a session driven from the + console shows in the channel exactly as one driven from the channel does, + and refusing to read back what was published would refuse the common case. + + What it does not decide at all is whether this particular reader may see + it. The platform holds that: only it knows who pressed, and whether they + can still see the conversation the turn was published into. + """ + found = await activity.shown_at(channel_id, ref) + if found is None: + return None + session_id, command_id = found + async with session_factory() as db: + row = await db.get(SdkSession, (require_tenant_id(), session_id)) + if row is None: + return None + stored = await db.get( + SdkSessionCommand, (require_tenant_id(), session_id, command_id) + ) + if stored is None: + return None + origin = Command.model_validate(stored.command).origin + if origin.room_id is None: + return None + room = await db.get(Room, origin.room_id) + if room is None or room.bridge_id != bridge_id: + return None + if room.external_channel_id != channel_id: + return None + agent = await db.get(Agent, row.agent_id) + if agent is None: + return None + if await db.get(ClientRoom, (agent.client_id, room.id)) is None: + return None + snapshot = Snapshot.model_validate(row.snapshot) + turn = next( + (turn for turn in snapshot.turns if turn.command_id == command_id), None + ) + if turn is None: + return None + return ActivitySnapshot( + items=[item for item in snapshot.items if item.turn_id == turn.turn_id], + turn=turn, + elapsed_seconds=await _turn_elapsed_seconds( + db, session_id, turn.turn_id, running=turn.status == "running" + ), + session_url=deeplink_for_platform( + session_console_url(gateway_public_url, agent.id, room.id, row.id), + gateway_public_url, + activity.renders_custom_url_schemes, + ), + read_at=(await db.execute(select(func.now()))).scalar_one(), + ) + + class _RecoveryBackoff: """Bounds how often one card's platform call is attempted again. @@ -1059,6 +1139,21 @@ def __init__( def wake(self) -> None: self._wake.set() + async def activity_shown_at( + self, channel_id: str, ref: str + ) -> ActivitySnapshot | None: + """The turn behind a status message, for an adapter that offers it.""" + if self._activity is None: + return None + return await activity_shown_at( + self._sessions, + self._bridge_id, + self._activity, + channel_id, + ref, + gateway_public_url=self._gateway_public_url, + ) + async def publish_pending(self) -> None: async with self._sessions() as db: rows = ( diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_identity_backfill.py b/core/tests/switch_core/bridges/collaboration/test_bridge_identity_backfill.py index 86e861c77..f46c8209d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_identity_backfill.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_identity_backfill.py @@ -27,6 +27,9 @@ def set_channel_migration_handler(self, handler: Any) -> None: def set_agent_presentation_resolver(self, resolver: Any) -> None: pass + def set_activity_resolver(self, resolver: Any) -> None: + pass + async def start(self, **kwargs: Any) -> None: self.started = True diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py new file mode 100644 index 000000000..fe3c47ab7 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py @@ -0,0 +1,748 @@ +"""Reading a Discord turn's tool calls without posting them to the channel. + +Slack prints the per-call log beside the status. Discord's status is three +lines and has nowhere to put one, so the log sits behind a button and opens as +an ephemeral message: the same content, read by one person instead of by a +channel. + +What that costs is an authority question the public post never had to ask. +A press names a message; the log behind it belongs to whatever conversation +that message is in; and the only party who knows whether this particular +account can still read that conversation is Discord. So every press β€” the first +and every refresh β€” resolves the conversation the reference names and +re-authorises the presser against it, never against the one the press happened +to arrive from. + +The other half is that a refresh must not be able to reach the public message. +Discord's update callback rewrites whatever message the component was attached +to, so the guard is the message, not the id: a refresh is answered only where +it arrived on an ephemeral one. +""" + +from __future__ import annotations + +import logging +from dataclasses import replace +from datetime import UTC, datetime +from typing import Any + +import discord +import pytest + +from switch_core.bridges.collaboration.adapter import ActivitySnapshot +from switch_core.bridges.collaboration.discord.adapter import ( + _ACTIVITY_FAILED, + _ACTIVITY_GONE, + _ACTIVITY_LABEL, + _ACTIVITY_UNREADABLE, + _ACTIVITY_VIEW_ID, + _CONSOLE_LABEL, + _MAX_BUTTON_LABEL, + _MAX_CUSTOM_ID, + _REFRESH_LABEL, + DiscordAdapter, + _refresh_id, +) + +from .test_discord_sdk_only import ( + CHANNEL_ID, + DM_CHANNEL_ID, + GUILD_ID, + ROOT_MESSAGE_ID, + _activity, + _adapter, + _Channel, + _DMChannel, + _Guild, + _guild_setup, + _http_error, + _Thread, +) +from .test_session_activity import _item, _turn + +READER_ID = 8181 +OTHER_READER_ID = 8282 +STATUS_MESSAGE_ID = 4004 +PRIVATE_THREAD_ID = 700 +CONSOLE_URL = "https://console.example.test/sessions/s-1" + + +# ── Fakes ──────────────────────────────────────────────────────────────────── + + +class _Permissions: + def __init__( + self, + *, + view_channel: bool = True, + read_message_history: bool = True, + manage_threads: bool = False, + ) -> None: + self.view_channel = view_channel + self.read_message_history = read_message_history + self.manage_threads = manage_threads + + +class _Member: + def __init__(self, user_id: int) -> None: + self.id = user_id + + +class _Reader: + def __init__(self, user_id: int = READER_ID) -> None: + self.id = user_id + self.name = "kim" + + +class _PeopledGuild(_Guild): + """A guild that can be asked who somebody is, which is the whole of what + the permission check needs from it.""" + + def __init__(self, members: set[int]) -> None: + super().__init__() + self.members = members + self.fetched: list[int] = [] + + def get_member(self, user_id: int) -> _Member | None: + return _Member(user_id) if user_id in self.members else None + + async def fetch_member(self, user_id: int) -> _Member: + self.fetched.append(user_id) + if user_id in self.members: + return _Member(user_id) + raise discord.NotFound(_HTTPResponse(), "no such member") # type: ignore[arg-type] + + +class _HTTPResponse: + status = 404 + reason = "Not Found" + headers: dict[str, str] = {} + + +class _ReadableChannel(_Channel): + """A guild channel that answers what a given member may do in it.""" + + def __init__( + self, + channel_id: int, + guild: _PeopledGuild, + permissions: _Permissions | None = None, + ) -> None: + super().__init__(channel_id, guild=guild) + self.permissions = permissions if permissions is not None else _Permissions() + + def permissions_for(self, member: Any) -> _Permissions: + return self.permissions + + +class _ReadableThread(_Thread): + """A public thread: everyone who may read the parent may read this.""" + + def __init__( + self, + parent: _ReadableChannel, + thread_id: int = ROOT_MESSAGE_ID, + *, + permissions: _Permissions | None = None, + ) -> None: + super().__init__(parent, thread_id) + self.permissions = permissions if permissions is not None else _Permissions() + + def permissions_for(self, member: Any) -> _Permissions: + return self.permissions + + +class _PrivateThread(_ReadableThread): + """A private thread: visible to everyone who can see the parent, readable + only by the accounts actually added to it.""" + + def __init__( + self, + parent: _ReadableChannel, + thread_id: int = PRIVATE_THREAD_ID, + *, + members: set[int] | None = None, + permissions: _Permissions | None = None, + ) -> None: + super().__init__(parent, thread_id, permissions=permissions) + self.thread_members = members if members is not None else set() + + def is_private(self) -> bool: + return True + + async def fetch_member(self, user_id: int) -> object: + if user_id not in self.thread_members: + raise discord.NotFound(_HTTPResponse(), "not in this thread") # type: ignore[arg-type] + return object() + + +class _Flags: + def __init__(self, ephemeral: bool) -> None: + self.ephemeral = ephemeral + + +class _PressedMessage: + def __init__(self, message_id: int, *, ephemeral: bool = False) -> None: + self.id = message_id + self.flags = _Flags(ephemeral) + + +class _InteractionResponse: + def __init__(self) -> None: + self.defers: list[dict[str, Any]] = [] + self.error: Exception | None = None + + async def defer(self, **kwargs: Any) -> None: + if self.error is not None: + raise self.error + self.defers.append(kwargs) + + +class _Press: + """What the gateway hands a listener when an activity button is operated.""" + + def __init__( + self, + custom_id: str, + *, + channel: Any, + message: Any, + user: _Reader | None = None, + ) -> None: + self.type = discord.InteractionType.component + self.guild_id = None if getattr(channel, "guild", None) is None else GUILD_ID + self.data: dict[str, Any] = {"custom_id": custom_id, "component_type": 2} + self.channel = channel + self.message = message + self.user = user if user is not None else _Reader() + self.response = _InteractionResponse() + self.shown: list[dict[str, Any]] = [] + self.edit_error: Exception | None = None + + async def edit_original_response(self, **kwargs: Any) -> None: + if self.edit_error is not None: + raise self.edit_error + self.shown.append(kwargs) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _snapshot(**fields: Any) -> ActivitySnapshot: + items = [ + _item(itemId="a", title="Read config.toml", status="completed"), + _item(itemId="b", title="Ran the tests", text="42 passed", status="completed"), + ] + defaults: dict[str, Any] = { + "items": items, + "turn": _turn("completed"), + "elapsed_seconds": 12.0, + "session_url": CONSOLE_URL, + "read_at": datetime(2026, 9, 16, 12, 0, tzinfo=UTC), + } + return ActivitySnapshot(**{**defaults, **fields}) + + +def _resolving(adapter: DiscordAdapter, answer: Any = None) -> list[tuple[str, str]]: + """Give `adapter` something to resolve a press against, and record the asks. + + `answer` is what every read returns β€” a snapshot, None for a message showing + nothing, or an exception instance to raise. + """ + asked: list[tuple[str, str]] = [] + + async def resolve(channel_id: str, ref: str) -> ActivitySnapshot | None: + asked.append((channel_id, ref)) + if isinstance(answer, Exception): + raise answer + return answer # type: ignore[no-any-return] + + adapter.set_activity_resolver(resolve) + return asked + + +def _buttons(payload: dict[str, Any]) -> list[tuple[str, str | None, str | None]]: + """Every button in a send, edit or private view, as (label, id, url).""" + view = payload.get("view") + if view is None: + return [] + return [(item.label, item.custom_id, item.url) for item in view.children] + + +def _shown(press: _Press) -> str: + assert len(press.shown) == 1 + return str(press.shown[0]["content"]) + + +def _guild_with(members: set[int]) -> tuple[DiscordAdapter, _ReadableChannel]: + """An adapter whose one channel `members` can read, with a resolver wired.""" + guild = _PeopledGuild(members) + channel = _ReadableChannel(CHANNEL_ID, guild) + adapter = _adapter({CHANNEL_ID: channel}) + return adapter, channel + + +def _status_press(channel: Any) -> _Press: + return _Press( + _ACTIVITY_VIEW_ID, + channel=channel, + message=_PressedMessage(STATUS_MESSAGE_ID), + ) + + +def _refresh_press(channel: Any, ref: str, *, ephemeral: bool = True) -> _Press: + return _Press( + f"swact:r:{ref}", + channel=channel, + message=_PressedMessage(9999, ephemeral=ephemeral), + ) + + +# ── What a status offers ───────────────────────────────────────────────────── + + +async def test_a_status_offers_the_way_into_the_log_it_does_not_print() -> None: + """The button carries no identifier. Which turn it is about is the message + it arrives on, which Discord fills in and a client cannot write.""" + adapter, _channel, _thread, webhook = _guild_setup() + _resolving(adapter, _snapshot()) + + await adapter.post_rich( + str(CHANNEL_ID), "my-agent", _activity(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert _buttons(webhook.sent[0]) == [(_ACTIVITY_LABEL, _ACTIVITY_VIEW_ID, None)] + + +async def test_no_button_where_nothing_can_answer_the_press() -> None: + """A bridge publishing no sessions has nothing that knows which turn a + message is showing, so a button on it would be a question with no reader.""" + adapter, _channel, _thread, webhook = _guild_setup() + + await adapter.post_rich( + str(CHANNEL_ID), "my-agent", _activity(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert _buttons(webhook.sent[0]) == [] + + +async def test_the_attention_slot_offers_nothing_but_the_thing_it_asks_for() -> None: + """That message is one sentence saying somebody has to act. A control under + it about tool calls is an invitation away from it.""" + adapter, _channel, _thread, webhook = _guild_setup() + _resolving(adapter, _snapshot()) + + await adapter.post_rich( + str(CHANNEL_ID), + "my-agent", + replace(_activity(), error_summary="The session needs attention."), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + ) + + assert _buttons(webhook.sent[0]) == [] + + +async def test_the_button_survives_a_redraw_without_being_remembered() -> None: + """Every redraw builds the view again from the content, so a button after a + restart is the same button β€” nothing in this process is holding it.""" + adapter, _channel, _thread, webhook = _guild_setup() + _resolving(adapter, _snapshot()) + + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", _activity(), None + ) + + assert _buttons(webhook.edits[0]) == [(_ACTIVITY_LABEL, _ACTIVITY_VIEW_ID, None)] + + +# ── The private copy ───────────────────────────────────────────────────────── + + +async def test_a_press_opens_a_private_copy_nobody_else_is_shown() -> None: + adapter, channel = _guild_with({READER_ID}) + asked = _resolving(adapter, _snapshot()) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert press.response.defers == [{"ephemeral": True, "thinking": True}] + assert asked == [(str(CHANNEL_ID), f"{CHANNEL_ID}:{STATUS_MESSAGE_ID}")] + assert "Ran the tests" in _shown(press) + assert channel.sent == [] + + +async def test_two_readers_get_their_own_snapshot_of_the_same_message() -> None: + """Independent private copies, not a shared one: each press is answered in + the response Discord opened for it.""" + adapter, channel = _guild_with({READER_ID, OTHER_READER_ID}) + _resolving(adapter, _snapshot()) + first = _status_press(channel) + second = _Press( + _ACTIVITY_VIEW_ID, + channel=channel, + message=_PressedMessage(STATUS_MESSAGE_ID), + user=_Reader(OTHER_READER_ID), + ) + + await adapter._handle_interaction(first) # type: ignore[arg-type] + await adapter._handle_interaction(second) # type: ignore[arg-type] + + assert len(first.shown) == 1 + assert len(second.shown) == 1 + assert channel.sent == [] + + +async def test_the_private_copy_carries_refresh_and_the_console_link() -> None: + adapter, channel = _guild_with({READER_ID}) + _resolving(adapter, _snapshot()) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _buttons(press.shown[0]) == [ + (_REFRESH_LABEL, f"swact:r:{CHANNEL_ID}:{STATUS_MESSAGE_ID}", None), + (_CONSOLE_LABEL, None, CONSOLE_URL), + ] + + +async def test_the_copy_says_when_it_was_read_so_a_stale_one_admits_it() -> None: + """Discord's own relative stamp, which the client ages without anything + here refreshing it.""" + adapter, channel = _guild_with({READER_ID}) + read_at = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + _resolving(adapter, _snapshot(read_at=read_at)) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert f"Read " in _shown(press) + + +# ── Refresh ────────────────────────────────────────────────────────────────── + + +async def test_a_refresh_rereads_and_rewrites_the_same_private_copy() -> None: + adapter, channel = _guild_with({READER_ID}) + asked = _resolving(adapter, _snapshot()) + ref = f"{CHANNEL_ID}:{STATUS_MESSAGE_ID}" + press = _refresh_press(channel, ref) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert press.response.defers == [{}] + assert asked == [(str(CHANNEL_ID), ref)] + assert len(press.shown) == 1 + assert channel.sent == [] + + +async def test_a_refresh_on_a_public_message_is_refused_before_it_is_answered( + caplog: pytest.LogCaptureFixture, +) -> None: + """An update rewrites whatever message the component was on, so a press + carrying this id from a channel message would turn a status into a log.""" + adapter, channel = _guild_with({READER_ID}) + asked = _resolving(adapter, _snapshot()) + press = _refresh_press( + channel, f"{CHANNEL_ID}:{STATUS_MESSAGE_ID}", ephemeral=False + ) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert press.response.defers == [] + assert press.shown == [] + assert asked == [] + assert any("would rewrite that message" in r.getMessage() for r in caplog.records) + + +async def test_a_refresh_naming_nothing_readable_is_ignored() -> None: + adapter, channel = _guild_with({READER_ID}) + asked = _resolving(adapter, _snapshot()) + press = _Press( + "swact:r:", channel=channel, message=_PressedMessage(9999, ephemeral=True) + ) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert press.response.defers == [] + assert asked == [] + + +async def test_a_reference_that_is_not_an_address_is_told_there_is_nothing() -> None: + """The reference comes off a client, so a malformed one is an ordinary + answer rather than a fault β€” but it is still an answer.""" + adapter, channel = _guild_with({READER_ID}) + asked = _resolving(adapter, _snapshot()) + press = _refresh_press(channel, "not-an-address") + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == _ACTIVITY_GONE + + +# ── Who may read it ────────────────────────────────────────────────────────── + + +async def test_a_reader_who_has_lost_the_channel_is_told_rather_than_shown() -> None: + adapter, channel = _guild_with({READER_ID}) + channel.permissions = _Permissions(view_channel=False) + asked = _resolving(adapter, _snapshot()) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == _ACTIVITY_UNREADABLE + + +async def test_a_reader_who_cannot_read_the_history_is_refused_too() -> None: + """A turn's log is history. Seeing the channel exist is not reading it.""" + adapter, channel = _guild_with({READER_ID}) + channel.permissions = _Permissions(read_message_history=False) + _resolving(adapter, _snapshot()) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _shown(press) == _ACTIVITY_UNREADABLE + + +async def test_someone_who_has_left_the_guild_is_refused() -> None: + adapter, channel = _guild_with(set()) + _resolving(adapter, _snapshot()) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _shown(press) == _ACTIVITY_UNREADABLE + + +async def test_a_private_thread_asks_for_membership_not_visibility() -> None: + """Everyone who can see the parent passes the channel check. Only the + thread's own membership says who is actually in it.""" + guild = _PeopledGuild({READER_ID}) + parent = _ReadableChannel(CHANNEL_ID, guild) + thread = _PrivateThread(parent, members=set()) + adapter = _adapter({CHANNEL_ID: parent, PRIVATE_THREAD_ID: thread}) + asked = _resolving(adapter, _snapshot()) + press = _Press( + _ACTIVITY_VIEW_ID, channel=thread, message=_PressedMessage(STATUS_MESSAGE_ID) + ) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == _ACTIVITY_UNREADABLE + + +async def test_a_member_of_that_thread_is_shown_it() -> None: + guild = _PeopledGuild({READER_ID}) + parent = _ReadableChannel(CHANNEL_ID, guild) + thread = _PrivateThread(parent, members={READER_ID}) + adapter = _adapter({CHANNEL_ID: parent, PRIVATE_THREAD_ID: thread}) + asked = _resolving(adapter, _snapshot()) + press = _Press( + _ACTIVITY_VIEW_ID, channel=thread, message=_PressedMessage(STATUS_MESSAGE_ID) + ) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [(str(CHANNEL_ID), f"{PRIVATE_THREAD_ID}:{STATUS_MESSAGE_ID}")] + assert "Ran the tests" in _shown(press) + + +async def test_a_refresh_is_authorised_against_the_thread_its_reference_names() -> None: + """The hole this closes: a reference is a locator the presser supplied, so + authorising it against the conversation the press arrived from would let a + public thread's permissions open the private thread beside it.""" + guild = _PeopledGuild({READER_ID}) + parent = _ReadableChannel(CHANNEL_ID, guild) + public = _ReadableThread(parent, ROOT_MESSAGE_ID) + private = _PrivateThread(parent, PRIVATE_THREAD_ID, members=set()) + adapter = _adapter( + {CHANNEL_ID: parent, ROOT_MESSAGE_ID: public, PRIVATE_THREAD_ID: private} + ) + asked = _resolving(adapter, _snapshot()) + press = _refresh_press(public, f"{PRIVATE_THREAD_ID}:{STATUS_MESSAGE_ID}") + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == _ACTIVITY_UNREADABLE + + +async def test_a_destination_nobody_can_ask_about_is_refused_not_assumed( + caplog: pytest.LogCaptureFixture, +) -> None: + """Not having been able to check is not the same as having checked.""" + guild = _PeopledGuild({READER_ID}) + channel = _Channel(CHANNEL_ID, guild=guild) + adapter = _adapter({CHANNEL_ID: channel}) + _resolving(adapter, _snapshot()) + press = _status_press(channel) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _shown(press) == _ACTIVITY_UNREADABLE + assert any( + "Cannot establish who may read" in r.getMessage() for r in caplog.records + ) + + +async def test_a_direct_message_has_nobody_else_in_it_to_check() -> None: + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + asked = _resolving(adapter, _snapshot()) + press = _status_press(dm) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [(str(DM_CHANNEL_ID), f"{DM_CHANNEL_ID}:{STATUS_MESSAGE_ID}")] + assert "Ran the tests" in _shown(press) + + +# ── When there is nothing to show ──────────────────────────────────────────── + + +async def test_a_message_showing_no_turn_says_so_rather_than_nothing() -> None: + """A button that answers silently reads as Discord having dropped the + press, and the reader goes on pressing it.""" + adapter, channel = _guild_with({READER_ID}) + _resolving(adapter, None) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _shown(press) == _ACTIVITY_GONE + + +async def test_a_reference_to_a_channel_discord_will_not_name_says_so() -> None: + adapter, channel = _guild_with({READER_ID}) + asked = _resolving(adapter, _snapshot()) + press = _refresh_press(channel, f"{CHANNEL_ID + 1}:{STATUS_MESSAGE_ID}") + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == _ACTIVITY_GONE + + +async def test_a_read_that_fails_is_reported_to_the_reader_and_the_log( + caplog: pytest.LogCaptureFixture, +) -> None: + adapter, channel = _guild_with({READER_ID}) + _resolving(adapter, RuntimeError("the database went away")) + press = _status_press(channel) + + with caplog.at_level(logging.ERROR): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _shown(press) == _ACTIVITY_FAILED + assert any( + "failed, so the reader is told" in r.getMessage() for r in caplog.records + ) + + +async def test_a_press_discord_will_not_let_us_acknowledge_reads_nothing( + caplog: pytest.LogCaptureFixture, +) -> None: + """Three seconds is the whole budget, and a press that misses it is one + Discord has already told the presser failed.""" + adapter, channel = _guild_with({READER_ID}) + asked = _resolving(adapter, _snapshot()) + press = _status_press(channel) + press.response.error = _http_error(404) + + with caplog.at_level(logging.ERROR): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert press.shown == [] + assert any("not acknowledged in time" in r.getMessage() for r in caplog.records) + + +async def test_an_edit_discord_refuses_is_logged_rather_than_raised( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing downstream is waiting on this: the press is already + acknowledged, and an expired token cannot be retried into.""" + adapter, channel = _guild_with({READER_ID}) + _resolving(adapter, _snapshot()) + press = _status_press(channel) + press.edit_error = _http_error(404) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert any( + "would not carry the activity view" in r.getMessage() for r in caplog.records + ) + + +# ── What the copy contains ─────────────────────────────────────────────────── + + +async def test_a_long_log_says_how_much_of_itself_it_is_not_showing() -> None: + """The cut is at the newest end's expense last: a log that quietly showed + its tail reads as a turn that only made those calls.""" + adapter, channel = _guild_with({READER_ID}) + items = [ + _item(itemId=f"i{index}", title=f"Call {index} " + "x" * 300) + for index in range(60) + ] + _resolving(adapter, _snapshot(items=items)) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + text = _shown(press) + assert "earlier in this turn, not shown." in text + assert "Call 59" in text + assert len(text) <= 2000 + + +async def test_a_turn_with_no_tool_calls_says_that_too() -> None: + adapter, channel = _guild_with({READER_ID}) + _resolving(adapter, _snapshot(items=[])) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert "No tool calls." in _shown(press) + + +async def test_a_turn_with_no_console_link_offers_only_refresh() -> None: + adapter, channel = _guild_with({READER_ID}) + _resolving(adapter, _snapshot(session_url=None)) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _buttons(press.shown[0]) == [ + (_REFRESH_LABEL, f"swact:r:{CHANNEL_ID}:{STATUS_MESSAGE_ID}", None) + ] + + +async def test_an_unpublished_bridge_answers_a_stale_button_rather_than_hanging() -> ( + None +): + """Old buttons outlive the process that drew them, and a restart that no + longer publishes sessions must not leave them pressing into silence.""" + adapter, channel = _guild_with({READER_ID}) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _shown(press) == _ACTIVITY_GONE + + +def test_the_activity_ids_fit_what_discord_carries() -> None: + """A custom id Discord will not accept is a button that never arrives, and + a snowflake is as long as a snowflake gets.""" + assert len(_ACTIVITY_VIEW_ID) <= _MAX_CUSTOM_ID + assert len(_refresh_id(f"{2**64 - 1}:{2**64 - 1}")) <= _MAX_CUSTOM_ID + assert len(_ACTIVITY_LABEL) <= _MAX_BUTTON_LABEL + assert len(_REFRESH_LABEL) <= _MAX_BUTTON_LABEL + assert len(_CONSOLE_LABEL) <= _MAX_BUTTON_LABEL diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py new file mode 100644 index 000000000..46aae2f3a --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py @@ -0,0 +1,210 @@ +"""The per-call log behind a turn's status, for a platform with room for one. + +`test_session_turn_status.py` covers the line that sits beside a running turn +and says what it is doing. This is the list behind that line and says what it +did β€” the same calls Slack already posts into the channel as a message of its +own, so a platform drawing it somewhere narrower is deciding where it is read, +not what is in it. + +Two things it has to get right. It has to fit: the caller gives it one budget +for the whole message and it cannot spend more. And when it does not fit it has +to say so, and say how much β€” a log that quietly showed its tail reads as a +turn that only made those calls, which is a claim about what the agent did +rather than about how much room there was. +""" + +from __future__ import annotations + +from switch_core.bridges.collaboration.session.renderers import MARKDOWN +from switch_core.bridges.collaboration.session.renderers.neutral import activity_log +from switch_core.sessions.contract import Item + +from .test_session_activity import _item, _turn + + +def _identity(text: str) -> str: + return text + + +def _call( + status: str = "completed", title: str = "Did a thing", text: str = "" +) -> Item: + return _item(kind="tool-activity", status=status, title=title, text=text) + + +def _log( + items: list[Item], + turn_state: str = "completed", + *, + limit: int = 10_000, +) -> list[str]: + return activity_log( + items, + _turn(turn_state), + escape=_identity, + limit=limit, + markup=MARKDOWN, + elapsed_seconds=None, + session_url=None, + ).splitlines() + + +# ── What it shows ──────────────────────────────────────────────────────────── + + +def test_every_call_is_there_oldest_first_under_the_state_line() -> None: + """Reading order, not recency order: the log is the turn's story, and a + story told backwards is one a reader has to reassemble.""" + items = [_call(title="First"), _call(title="Second"), _call(title="Third")] + + lines = _log(items) + + assert lines[1:] == ["βœ“ First", "βœ“ Second", "βœ“ Third"] + + +def test_a_call_that_said_something_says_it_beside_the_name() -> None: + lines = _log([_call(title="Ran the tests", text="42 passed")]) + + assert lines[1] == "βœ“ Ran the tests β€” 42 passed" + + +def test_how_a_call_went_is_on_the_line_rather_than_left_to_the_tally() -> None: + """The status line counts failures. The log says which ones.""" + items = [_call(title="Read it"), _call("failed", title="Wrote it")] + + lines = _log(items) + + assert lines[1:] == ["βœ“ Read it", "βœ— Wrote it"] + + +def test_a_call_with_no_name_is_shown_rather_than_dropped() -> None: + """A call the host named nothing is still a call the agent made, and a log + that silently omitted it would undercount the turn.""" + lines = _log([_call(title="")]) + + assert lines[1] == "βœ“ (untitled)" + + +def test_only_tool_calls_are_in_the_tool_log() -> None: + """What the agent said belongs to the conversation, not to this.""" + items = [ + _item(kind="assistant-message", title="", text="Looking now."), + _call(title="Searched"), + ] + + lines = _log(items) + + assert lines[1:] == ["βœ“ Searched"] + + +def test_a_turn_that_has_ended_with_no_calls_says_it_made_none() -> None: + assert _log([], "completed")[1] == "No tool calls." + + +def test_a_turn_still_running_says_it_has_made_none_yet() -> None: + """The difference matters: one is a finding about the turn, the other is a + report about right now.""" + assert _log([], "running")[1] == "No tool calls yet." + + +# ── What it does when it will not fit ──────────────────────────────────────── + + +def test_a_log_too_long_for_the_budget_is_cut_at_the_oldest_end() -> None: + """The newest end is what a reader pressed for.""" + items = [_call(title=f"Call {index}") for index in range(20)] + + lines = _log(items, limit=120) + + assert lines[-1] == "βœ“ Call 19" + + +def test_a_cut_log_says_how_many_calls_it_is_not_showing() -> None: + items = [_call(title=f"Call {index}") for index in range(20)] + + lines = _log(items, limit=120) + shown = [line for line in lines[1:] if line.startswith("βœ“")] + + assert lines[1] == f"…{20 - len(shown)} earlier in this turn, not shown." + + +def test_the_whole_thing_stays_inside_the_budget_it_was_given() -> None: + """One message is all the platform has. A log that overran it would be a + post the platform refuses, which is a button that does nothing.""" + items = [_call(title=f"Call {index} " + "x" * 400) for index in range(40)] + + for limit in (80, 200, 1000, 2000): + assert len("\n".join(_log(items, limit=limit))) <= limit + + +def test_a_budget_too_small_for_even_one_call_still_says_there_were_calls() -> None: + """Better a line saying the log did not fit than a log claiming the turn + made no calls.""" + items = [_call(title="Call " + "x" * 400) for _ in range(3)] + + lines = _log(items, limit=60) + + assert lines[-1] == "…3 earlier in this turn, not shown." + + +def test_one_long_call_is_shortened_rather_than_dropped() -> None: + """A single call longer than the whole budget is still the thing the reader + came for.""" + lines = _log([_call(title="Grepped for " + "x" * 5000)], limit=300) + + assert len("\n".join(lines)) <= 300 + assert "Grepped for" in lines[-1] + + +# ── What it is drawn with ──────────────────────────────────────────────────── + + +def test_host_text_is_put_through_the_platforms_own_escape() -> None: + """Every title and detail came from a host and is host text.""" + seen: list[str] = [] + + def _record(text: str) -> str: + seen.append(text) + return text.replace("*", "\\*") + + drawn = activity_log( + [_call(title="*not bold*", text="_nor this_")], + _turn("completed"), + escape=_record, + limit=10_000, + markup=MARKDOWN, + elapsed_seconds=None, + session_url=None, + ) + + assert "*not bold*" in seen + assert "\\*not bold\\*" in drawn + + +def test_the_console_link_rides_on_the_state_line_when_there_is_room() -> None: + drawn = activity_log( + [_call()], + _turn("completed"), + escape=_identity, + limit=10_000, + markup=MARKDOWN, + elapsed_seconds=None, + session_url="https://console.example.test/s/1", + ) + + assert "https://console.example.test/s/1" in drawn.splitlines()[0] + + +def test_a_link_that_would_not_fit_is_left_off_rather_than_cut_in_half() -> None: + """Half a URL is not a link, and the state line is what the reader needs.""" + drawn = activity_log( + [_call()], + _turn("completed"), + escape=_identity, + limit=40, + markup=MARKDOWN, + elapsed_seconds=None, + session_url="https://console.example.test/" + "s" * 200, + ) + + assert "console.example.test" not in drawn diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 8dfd81c75..a82d0c5d0 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -1366,6 +1366,9 @@ async def test_completed_journal_discards_anchors_but_keeps_replay_receipt( "turn_id": _turn("completed").turn_id, "ended": True, "completed": True, + # Not a reservation: the message is still in the channel, and this + # is the only thing left saying which turn it is showing. + "shown": {"channel_id": "channel-demo", "ref": "channel-demo:1"}, } await publish(activity(session_factory, platform), "completed") assert platform.post_count == 2 diff --git a/core/tests/switch_core/sessions/test_activity_readback.py b/core/tests/switch_core/sessions/test_activity_readback.py new file mode 100644 index 000000000..2485f43f7 --- /dev/null +++ b/core/tests/switch_core/sessions/test_activity_readback.py @@ -0,0 +1,240 @@ +"""Asking, long afterwards, what turn a message in a channel was showing. + +Publishing pushes a turn at a channel because it changed. This is the other +direction: a reader operates a control on a status that has been sitting there +for an hour, and something has to say which turn that message is, and then +whether it may still be read out. + +Two things make that hard. The anchor a publisher keeps is a delivery +reservation and is thrown away the moment an ordinary turn ends β€” while the +message it named is still on the screen, still carrying its button. And the +answer can have gone stale in every direction since it was drawn: the room can +have moved to another bridge, the agent can have been taken out of it, the +session can be gone. + +So the address is written down separately from the anchor and survives the +compaction that discards it, and every check the publisher makes before it may +draw a turn in a channel is made again on the way back out. + +What is deliberately not re-checked is the surface the command came in on. A +turn is published to the room whatever asked for it, so a session driven from +the console draws in the channel exactly as one driven from the channel does. +""" + +from __future__ import annotations + +from switch_core.bridges.collaboration.session.activity_journal import ActivityJournal +from switch_core.bridges.collaboration.session.outbound import SessionTurnActivity +from switch_core.db.models import ( + Agent, + Client, + ClientRoom, + CollaborationBridge, + Room, + SdkSession, + require_tenant_id, +) +from switch_core.sessions.publication import activity_shown_at + +from .test_activity_durability import ActivitySlack, activity, publish +from .test_authority import opened, setup + +STATUS_REF = "channel-demo:1" + + +async def read_back(session_factory, renderer, channel="channel-demo", ref=STATUS_REF): + return await activity_shown_at( + session_factory, + "bridge", + renderer, + channel, + ref, + gateway_public_url="https://switch.example.test", + ) + + +async def published(session_factory, *, status="completed"): + """A turn drawn into channel-demo and then taken to `status`. + + Ended by default, because that is the case the address exists for: a + running turn still has its anchor, and a finished one has only this. + """ + service, epoch = await setup(session_factory) + await opened(service, epoch) + platform = ActivitySlack() + renderer = activity(session_factory, platform) + await publish(renderer, "running") + if status != "running": + await publish(renderer, status) + return renderer + + +# ── The address outlives the turn ──────────────────────────────────────────── + + +async def test_a_finished_turns_status_still_says_which_turn_it_is(session_factory): + """The anchor is gone by now. This is what is left, and it is enough.""" + renderer = await published(session_factory) + + assert await renderer.shown_at("channel-demo", STATUS_REF) == ( + "session-demo", + "message-demo", + ) + + +async def test_the_address_survives_the_compaction_that_discards_the_anchor( + session_factory, +): + await published(session_factory) + + async with ActivityJournal(session_factory, "bridge").open( + "session-demo", "message-demo" + ) as record: + assert record is not None + assert "anchor" not in record.data + assert record.data["shown"] == { + "channel_id": "channel-demo", + "ref": STATUS_REF, + } + + +async def test_a_running_turn_can_be_asked_too(session_factory): + renderer = await published(session_factory, status="running") + + assert await renderer.shown_at("channel-demo", STATUS_REF) is not None + + +async def test_a_message_this_bridge_posted_nothing_in_says_nothing(session_factory): + renderer = await published(session_factory) + + assert await renderer.shown_at("channel-demo", "channel-demo:9999") is None + + +async def test_the_channel_and_the_reference_have_to_agree(session_factory): + """Matched as a pair, not on the reference alone: a platform numbering its + messages per channel would otherwise answer for a different channel's.""" + renderer = await published(session_factory) + + assert await renderer.shown_at("channel-elsewhere", STATUS_REF) is None + + +async def test_a_publisher_with_no_journal_has_nothing_to_answer_from( + session_factory, +): + renderer = SessionTurnActivity(ActivitySlack()) + + assert await renderer.shown_at("channel-demo", STATUS_REF) is None + + +# ── What the read gives back ───────────────────────────────────────────────── + + +async def test_the_read_gives_back_the_turn_that_message_is_showing(session_factory): + renderer = await published(session_factory) + + snapshot = await read_back(session_factory, renderer) + + assert snapshot is not None + assert snapshot.turn.turn_id == "turn-demo" + assert all(item.turn_id == "turn-demo" for item in snapshot.items) + + +async def test_a_console_driven_turn_is_read_back_like_any_other(session_factory): + """`opened` submits from the console, which is the ordinary way a session + is driven. Its turn is published to the channel, so it reads back.""" + renderer = await published(session_factory) + + assert await read_back(session_factory, renderer) is not None + + +async def test_the_read_says_when_it_happened(session_factory): + """A view left open ages. It can only say so if it was told the time it + was taken.""" + renderer = await published(session_factory) + + snapshot = await read_back(session_factory, renderer) + + assert snapshot is not None + assert snapshot.read_at is not None + + +async def test_the_read_carries_the_console_link_for_the_session(session_factory): + renderer = await published(session_factory) + + snapshot = await read_back(session_factory, renderer) + + assert snapshot is not None + assert snapshot.session_url is not None + assert "switch.example.test" in snapshot.session_url + + +# ── Every check the publisher makes, made again ────────────────────────────── + + +async def test_a_room_that_has_moved_to_another_bridge_is_not_read_back( + session_factory, +): + renderer = await published(session_factory) + async with session_factory() as db, db.begin(): + db.add( + Client( + id="other-bridge-client", + matrix_user_id="@other-bridge:example.test", + display_name="Other bridge", + type="bridge", + ) + ) + await db.flush() + db.add( + CollaborationBridge( + id="other-bridge", + type="slack", + display_name="Elsewhere", + client_id="other-bridge-client", + status="active", + ) + ) + await db.flush() + room = await db.get(Room, "room-demo") + assert room is not None + room.bridge_id = "other-bridge" + + assert await read_back(session_factory, renderer) is None + + +async def test_an_agent_taken_out_of_the_room_is_not_read_back(session_factory): + """Losing the room is how an agent stops publishing into it. A status it + left behind must stop answering for the same reason.""" + renderer = await published(session_factory) + async with session_factory() as db, db.begin(): + agent = await db.get(Agent, "agent-demo") + assert agent is not None + membership = await db.get(ClientRoom, (agent.client_id, "room-demo")) + assert membership is not None + await db.delete(membership) + + assert await read_back(session_factory, renderer) is None + + +async def test_a_room_now_pointing_at_another_channel_is_not_read_back( + session_factory, +): + """The reference is a locator a platform handed us. It is only answered + where the room it resolves to is still the channel it names.""" + renderer = await published(session_factory) + async with session_factory() as db, db.begin(): + room = await db.get(Room, "room-demo") + assert room is not None + room.external_channel_id = "channel-moved" + + assert await read_back(session_factory, renderer) is None + + +async def test_a_session_that_is_gone_is_not_read_back(session_factory): + renderer = await published(session_factory) + async with session_factory() as db, db.begin(): + row = await db.get(SdkSession, (require_tenant_id(), "session-demo")) + assert row is not None + await db.delete(row) + + assert await read_back(session_factory, renderer) is None From 8d8c1663d44c512d119dc492e9a3fe3452f3490e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 18:32:32 +0100 Subject: [PATCH 089/120] Discord: ask who is in a guildless channel before showing its log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_still_reads` refused a reader who had lost a channel, and checked a private thread by membership rather than by the parent's permissions, but allowed a channel outside any guild unconditionally β€” there were no permissions to consult, so it consulted nothing. That branch was the one place the destination check could be steered into. Every other path here treats the address as something that arrived on a press and is not evidence of anything; this one rested on the address having been generated by us, which is the inference the rest of the design refuses to make. Who may read a channel with no guild is exactly who is in it, so that is what is now asked, via `recipients` or the single `recipient`. A channel that can say neither is refused and logged, like any other destination whose audience cannot be established. This bridge does not in practice publish into real DMs β€” `create_dm_channel` makes a private guild channel, which `permissions_for` already answers for β€” so the change closes a reachable-only-by-forgery gap rather than a live one. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 34 ++++++++++--- .../test_discord_activity_view.py | 48 ++++++++++++++++++- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 1582748fe..0d4495228 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -2793,11 +2793,11 @@ async def _privately( async def _still_reads(self, channel: Any, user: Any) -> bool: """Whether this reader can still read the conversation a turn is in. - A direct message is the reader's own channel and there is nobody else - in it to ask about. A private thread is the case a channel's - permissions cannot answer on their own: everyone who can see the - parent passes that check, and only membership of the thread says who - is actually in it. + A channel outside any guild has no permissions to consult: who may + read it is exactly who is in it, so that is what is asked. A private + thread is the case a channel's permissions cannot answer on their own: + everyone who can see the parent passes that check, and only membership + of the thread says who is actually in it. Refused where the answer cannot be established. A destination this cannot ask about is one nothing here can say a reader may see, and the @@ -2806,7 +2806,7 @@ async def _still_reads(self, channel: Any, user: Any) -> bool: """ guild = getattr(channel, "guild", None) if guild is None: - return True + return self._is_recipient(channel, user) permissions_for = getattr(channel, "permissions_for", None) if permissions_for is None: logger.warning( @@ -2835,6 +2835,28 @@ async def _still_reads(self, channel: Any, user: Any) -> bool: return False return True + def _is_recipient(self, channel: Any, user: Any) -> bool: + """Whether this reader is one of the people a guildless channel is between. + + Asked rather than taken as read. The address that named this channel + came off a press, and the whole point of checking here is that an + address is not evidence of anything β€” a branch that answered "yes" + because there were no permissions to consult would be the one place + the check could be steered into. + """ + recipients = getattr(channel, "recipients", None) + if recipients is None: + sole = getattr(channel, "recipient", None) + recipients = [sole] if sole is not None else None + if recipients is None: + logger.warning( + "Cannot establish who is in Discord channel %s, so an " + "activity view of it is refused.", + getattr(channel, "id", "?"), + ) + return False + return any(getattr(person, "id", None) == user.id for person in recipients) + async def _tell_presser( self, interaction: discord.Interaction, notice: str ) -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py index fe3c47ab7..0b608729e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py @@ -119,6 +119,20 @@ class _HTTPResponse: headers: dict[str, str] = {} +class _PeopledDM(_DMChannel): + """A channel outside any guild that can say who is in it. + + The real thing carries `recipient` for a direct channel and `recipients` + for a group one; the bare `_DMChannel` the other Discord tests share + carries neither, which is the third case β€” a channel nothing can be + established about. + """ + + def __init__(self, *recipients: int) -> None: + super().__init__() + self.recipients = [_Member(user_id) for user_id in recipients] + + class _ReadableChannel(_Channel): """A guild channel that answers what a given member may do in it.""" @@ -591,8 +605,8 @@ async def test_a_destination_nobody_can_ask_about_is_refused_not_assumed( ) -async def test_a_direct_message_has_nobody_else_in_it_to_check() -> None: - dm = _DMChannel() +async def test_a_channel_outside_a_guild_is_read_by_whoever_is_in_it() -> None: + dm = _PeopledDM(READER_ID) adapter = _adapter({DM_CHANNEL_ID: dm}) asked = _resolving(adapter, _snapshot()) press = _status_press(dm) @@ -603,6 +617,36 @@ async def test_a_direct_message_has_nobody_else_in_it_to_check() -> None: assert "Ran the tests" in _shown(press) +async def test_someone_not_in_that_channel_is_refused_it() -> None: + """There are no permissions to consult outside a guild, so membership is + the whole of the check. A branch that answered yes for want of anything to + ask would be the one place an address could be steered into.""" + dm = _PeopledDM(OTHER_READER_ID) + adapter = _adapter({DM_CHANNEL_ID: dm}) + asked = _resolving(adapter, _snapshot()) + press = _status_press(dm) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == _ACTIVITY_UNREADABLE + + +async def test_a_channel_that_cannot_say_who_is_in_it_is_refused( + caplog: pytest.LogCaptureFixture, +) -> None: + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + _resolving(adapter, _snapshot()) + press = _status_press(dm) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert _shown(press) == _ACTIVITY_UNREADABLE + assert any("Cannot establish who is in" in r.getMessage() for r in caplog.records) + + # ── When there is nothing to show ──────────────────────────────────────────── From 7fccc3943f61d1437f724ad6f8de1f4abca7f45c Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 18:57:37 +0100 Subject: [PATCH 090/120] Count the tool calls the demo's activity message carries Matching one step title says that step is drawn and nothing about the eight that should be beside it. Now that the turn's state and its tool calls share a message, the count is what tells a merged message from one that lost most of its steps. Co-Authored-By: Claude Opus 5 --- .../test_session_card_posting.py | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py index b8c5576bf..804d96adf 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py @@ -157,6 +157,21 @@ async def _post_one( ) +def _steps(message: dict[str, Any]) -> list[str]: + """The tool calls drawn on an activity message, in the order they are in. + + The recording has nine of them, and the count is the claim: one message now + carries both the turn's state and its tool calls, so a title matching says + a step is drawn and nothing about the eight that should be beside it. + """ + return [ + task["title"] + for block in message["blocks"] + if block.get("type") == "plan" + for task in block["tasks"] + ] + + def _interactions( session_factory: async_sessionmaker[AsyncSession], bridge_id: str ) -> SessionInteractions: @@ -584,10 +599,12 @@ async def test_the_trigger_posts_the_recorded_turn_and_then_its_card( assert await demo.handle(TRIGGER, CHANNEL, room_id) is True assert len(client.posted) == 2 + steps = _steps(client.posted[0]) turn, card = (json.dumps(post["blocks"]) for post in client.posted) assert "Working" in turn assert "same fixture user" not in turn - assert "Ran tests/auth/test_login.py" in turn + assert len(steps) == 9 + assert "Ran tests/auth/test_login.py" in "\n".join(steps) assert "Edit tests/auth/conftest.py?" in card assert [post.get("thread_ts") for post in client.posted] == [None, None] @@ -607,10 +624,10 @@ async def test_running_the_recording_to_the_end_edits_what_is_already_there( assert await demo.handle(f"{TRIGGER} end", CHANNEL, room_id) is True assert len(client.posted) == 2 + assert len(_steps(client.updated[0])) == 9 turn, card = ( json.dumps(call["blocks"], ensure_ascii=False) for call in client.updated ) - assert "Ran tests/auth/test_login.py" in turn assert not client.deleted assert "Turn interrupted. 1 step left unfinished." in turn assert "Permission request closed" in card @@ -635,10 +652,10 @@ async def test_ending_carries_on_the_demo_already_in_the_channel( assert await demo.handle(f"{TRIGGER} end", CHANNEL, room_id) is True assert len(client.posted) == posted + assert len(_steps(client.updated[0])) == 9 turn, card = ( json.dumps(call["blocks"], ensure_ascii=False) for call in client.updated ) - assert "Ran tests/auth/test_login.py" in turn assert not client.deleted assert "Turn interrupted. 1 step left unfinished." in turn assert "Permission request closed" in card From bebdc9eb974e8a1068e79f1c069edeb68d76fb99 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 19:13:38 +0100 Subject: [PATCH 091/120] Teams: fold a finished turn's tool calls under its status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams has no message a bot can post that only one reader sees, so the Discord answer β€” a private copy fetched on demand β€” has nowhere to land here without a universal action and a card rewrite everyone can see. Action.ToggleVisibility is drawn by the reader's own client instead: the log travels in the card, hidden, and opening it changes nothing for anyone else reading the same message. Offered on an ended turn only. Every change to a running turn rewrites the card, and a rewritten card arrives folded shut, so a reader who opened the log mid-turn would have it closed in their face by the next tool call. A turn that called no tools is offered nothing to open. The card's schema version now follows what is actually in it rather than whether anything is under the body at all. Hiding elements predates the base version, so a fold costs no old client the card; Action.Execute still asks for 1.5, as it must. activity_log grows a required `heading`, because the Teams fold sits directly under a status line already carrying the turn's state and its Console link, and printing both again as the log's first line is the card showing one sentence twice. A caller that declines the heading and hands over a session_url is refused rather than having the link quietly dropped. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 1 + .../session/renderers/neutral.py | 36 ++- .../bridges/collaboration/teams/adapter.py | 74 ++++- .../bridges/collaboration/teams/cards.py | 103 +++++- .../test_session_activity_log.py | 62 ++++ .../collaboration/test_teams_activity_fold.py | 292 ++++++++++++++++++ 6 files changed, 546 insertions(+), 22 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 0d4495228..1c7a755ab 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -2749,6 +2749,7 @@ def _activity_text(self, snapshot: ActivitySnapshot) -> str: markup=self.rich_markup(), elapsed_seconds=snapshot.elapsed_seconds, session_url=None, + heading=True, ) return f"{body}\n{stamp}" diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index cbf8a6614..a0089caf9 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -337,6 +337,7 @@ def activity_log( markup: Markup, elapsed_seconds: float | None, session_url: str | None, + heading: bool, ) -> str: """The tool calls behind a turn, one to a line, oldest cut first. @@ -349,21 +350,36 @@ def activity_log( The cut is at the front because the newest end is what a reader came for, and it says how many it took: a log that quietly showed its tail reads as a turn that only made those calls. + + `heading` says whether the log has to name the turn it belongs to. It does + wherever it is read apart from that turn's own message. The Console link + rides on that heading, so a caller declining it is saying the status + directly above already carries both β€” and one that declines the heading and + hands over a link is asking for the link to be dropped without being told. """ - head = markup.bold( - turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds) - ) - link = _link(_CONSOLE, session_url, markup) - if link and len(head) + 3 + len(link) <= limit: - head = f"{head} Β· {link}" + if session_url is not None and not heading: + raise ValueError( + "An activity log with no heading has nowhere to put the Console " + "link, and the status it sits under is what carries it." + ) + head = None + if heading: + head = markup.bold( + turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds) + ) + link = _link(_CONSOLE, session_url, markup) + if link and len(head) + 3 + len(link) <= limit: + head = f"{head} Β· {link}" did = [item for item in items if item.kind == "tool-activity"] if not did: nothing = _LOG_EMPTY if turn.status in TURN_ENDED else _LOG_EMPTY_YET - return _truncate(f"{head}\n{nothing}", limit) + return _truncate(nothing if head is None else f"{head}\n{nothing}", limit) - head = _truncate(head, limit) - spent = len(head) + spent = 0 + if head is not None: + head = _truncate(head, limit) + spent = len(head) lines: list[str] = [] omitted = 0 for position, item in enumerate(reversed(did), start=1): @@ -390,7 +406,7 @@ def activity_log( if spent + len(note) + 1 <= limit: lines.append(note) lines.reverse() - return "\n".join([head, *lines]) + return "\n".join(lines if head is None else [head, *lines]) def request_summary( diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index bcd0d14cb..2a25881da 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -51,6 +51,7 @@ position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + activity_log, render_request, turn_status, ) @@ -59,6 +60,7 @@ TeamsTokenProvider, ) from switch_core.bridges.collaboration.teams.cards import ( + activity_detail, agent_message_card, answer_actions, card_attachment, @@ -77,6 +79,7 @@ load_certificate_der_b64, ) from switch_core.bridges.collaboration.teams.graph import GraphClient +from switch_core.sessions.contract import TURN_ENDED logger = logging.getLogger(__name__) @@ -104,6 +107,18 @@ rather than because it was inherited. """ +_DETAIL_LIMIT = 2000 +"""How many characters the folded-away tool log may spend. + +The same figure as the status it hides behind, for the same reason: this is +about how much a reader wants in front of them, not about what Teams accepts. +It is also what keeps the fold rendering as one block to a line β€” past roughly +this much, split into lines this short, `cards.body_blocks` runs out of its +structural budget and sets the whole log as one double-spaced block instead. +The log says how many calls it left out, and the status above it links Console, +which has all of them. +""" + _THROTTLE_BACKOFF = 5.0 """How long to wait when Teams throttles without saying how long. @@ -1155,7 +1170,7 @@ async def _post_to_answer_in( return self._last_post.get(channel_id) async def _message_activity( - self, sender_name: str, body: str, actions: list[dict[str, Any]] + self, sender_name: str, body: str, below: list[dict[str, Any]] ) -> dict[str, Any]: agent = await self.agent_rendering(sender_name) mentions = self._mention_entities(body) @@ -1166,7 +1181,7 @@ async def _message_activity( # Plain text, rendered by no markup engine, so the label goes in raw. "summary": f"{agent.field_label}: {body}", "attachments": [ - card_attachment(agent_message_card(agent, body, mentions, actions)) + card_attachment(agent_message_card(agent, body, mentions, below)) ], } @@ -1453,6 +1468,53 @@ def _unmentionable_notice(self) -> str: f"this {self.platform_name} team is what failed." ) + def _below(self, content: RichContent, drawn: Drawn) -> list[dict[str, Any]]: + """What the card carries under its body. + + Never both of them: a request card is asking the reader for an answer, + and a turn's log folded under the options competes for the press that + the card exists to collect. A publication is one or the other anyway β€” + only a `TurnActivity` has a log, and only a `RequestCard` has options. + """ + if isinstance(content, TurnActivity): + return self._activity_detail(content) + return self._controls(content, drawn) + + def _activity_detail(self, content: TurnActivity) -> list[dict[str, Any]]: + """An ended turn's tool calls, folded away under its status. + + Offered on an ended turn only, and that restriction is the design + rather than a simplification. Every change to a running turn rewrites + the card, and a rewritten card arrives folded shut: a reader who opened + the log while the agent worked would have it closed in their face by + the next tool call. An ended turn has no further changes to redraw for, + so what a reader opens stays open. + + A turn that called no tools is offered nothing. The fold is a promise + that there is something behind it, and "No tool calls." under a status + line that already says the same is not something anyone pressed for. + + The status above carries the Console link, so the log does not repeat + it, and it does not repeat the state line either β€” it would sit + directly under the copy of itself the card already shows. + """ + if content.turn.status not in TURN_ENDED: + return [] + if not any(item.kind == "tool-activity" for item in content.items): + return [] + return activity_detail( + activity_log( + content.items, + content.turn, + escape=self._rich_escape, + limit=_DETAIL_LIMIT, + markup=self.rich_markup(), + elapsed_seconds=content.elapsed_seconds, + session_url=None, + heading=False, + ) + ) + def _controls(self, content: RichContent, drawn: Drawn) -> list[dict[str, Any]]: """The card's options as buttons, or nothing where a press cannot land. @@ -1662,7 +1724,7 @@ async def post_rich( carried = _read_publication_ref(thread_root_id) if thread_root_id else None service_url = carried[0] if carried else self._service_url_for(channel_id) activity = await self._message_activity( - agent_name, text, self._controls(content, drawn) + agent_name, text, self._below(content, drawn) ) opening = self._is_channel(channel_id) and thread_root_id is None # A new post has no conversation to queue behind yet, so its writes are @@ -1740,7 +1802,7 @@ async def update_rich( ) address = self._publication_address(channel_id, message_ref, thread_root_id) await self._edit_rich( - connector, agent_name, address, text, self._controls(content, drawn) + connector, agent_name, address, text, self._below(content, drawn) ) async def _edit_rich( @@ -1749,7 +1811,7 @@ async def _edit_rich( agent_name: str, address: _Publication, text: str, - actions: list[dict[str, Any]], + below: list[dict[str, Any]], ) -> None: try: async with self._writes_to(address.conversation_id): @@ -1757,7 +1819,7 @@ async def _edit_rich( service_url=address.service_url, conversation_id=address.conversation_id, activity_id=address.activity_id, - activity=await self._message_activity(agent_name, text, actions), + activity=await self._message_activity(agent_name, text, below), ) except BotConnectorThrottled as error: raise self._throttled(error, text) from error diff --git a/core/switch_core/bridges/collaboration/teams/cards.py b/core/switch_core/bridges/collaboration/teams/cards.py index d2fdaa9bb..2821879b3 100644 --- a/core/switch_core/bridges/collaboration/teams/cards.py +++ b/core/switch_core/bridges/collaboration/teams/cards.py @@ -26,6 +26,15 @@ _ACTION_VERSION = "1.5" _BASE_VERSION = "1.4" +# The elements a fold is made of. Named rather than generated because the +# buttons and the container have to refer to each other by id, and all three +# live in one card at a time: a Switch card carries at most one turn. +_DETAIL_ID = "switchActivityDetail" +_SHOW_ID = "switchActivityShow" +_HIDE_ID = "switchActivityHide" +_SHOW_TITLE = "Show tool calls" +_HIDE_TITLE = "Hide tool calls" + # How much of an option's label a button shows. Not a documented Teams limit β€” # it is where a row of buttons stops being readable. Safe to impose because the # body above lists every option in full, so the button only has to say which. @@ -158,6 +167,71 @@ def _action_title(control: Control) -> str: return f"{control.position}. {label}" +def activity_detail(log: str) -> list[dict[str, Any]]: + """A turn's tool calls, folded away under its status with a button to open. + + `Action.ToggleVisibility` is drawn entirely by the reader's own client: + nothing reaches Switch when it is pressed, so opening the log is local to + whoever opened it and changes nothing for anyone else reading the same + message. That is the whole reason to prefer it here β€” the alternative, a + button that asks the bot for the log, either rewrites the card everyone can + see or needs a private reply channel this platform only offers off a + universal action. + + It costs no schema version either. Hiding and showing elements predates the + base version by two releases, so a card that only folds still draws on a + client too old for `Action.Execute`. + + Three elements, because an Adaptive Card action's title is fixed: the open + button hides itself and reveals the log and the close button, and the close + button puts all three back. A reader therefore always sees exactly one of + them, saying what pressing it will do. + """ + return [ + { + "type": "ActionSet", + "id": _SHOW_ID, + "spacing": "Small", + "actions": [ + { + "type": "Action.ToggleVisibility", + "title": _SHOW_TITLE, + "targetElements": [ + {"elementId": _SHOW_ID, "isVisible": False}, + {"elementId": _DETAIL_ID, "isVisible": True}, + {"elementId": _HIDE_ID, "isVisible": True}, + ], + } + ], + }, + { + "type": "Container", + "id": _DETAIL_ID, + "isVisible": False, + "spacing": "Small", + "style": "emphasis", + "items": body_blocks(log), + }, + { + "type": "ActionSet", + "id": _HIDE_ID, + "isVisible": False, + "spacing": "Small", + "actions": [ + { + "type": "Action.ToggleVisibility", + "title": _HIDE_TITLE, + "targetElements": [ + {"elementId": _SHOW_ID, "isVisible": True}, + {"elementId": _DETAIL_ID, "isVisible": False}, + {"elementId": _HIDE_ID, "isVisible": False}, + ], + } + ], + }, + ] + + def read_answer_action(value: dict[str, Any]) -> tuple[str, int] | None: """The card and the option a press names, or None if it is not ours. @@ -182,11 +256,27 @@ def read_answer_action(value: dict[str, Any]) -> tuple[str, int] | None: return token, position +def _schema_version(below: list[dict[str, Any]]) -> str: + """The oldest schema that can draw this card. + + Worth working out rather than assuming, because a client too old for the + version a card names drops the whole card and shows `fallbackText`. + `Action.Execute` is the only thing built here that needs the newer schema, + so a card that merely folds its log away asks for no more than a plain + message does. + """ + for element in below: + for action in element.get("actions", ()): + if action.get("type") == "Action.Execute": + return _ACTION_VERSION + return _BASE_VERSION + + def agent_message_card( agent: AgentRendering, body: str, mentions: list[dict[str, Any]], - actions: list[dict[str, Any]], + below: list[dict[str, Any]], ) -> dict[str, Any]: """An Adaptive Card that labels a message with the sending agent's identity. @@ -211,13 +301,14 @@ def agent_message_card( and without them the markup renders as inert text and the person is never notified. - ``actions`` are card elements appended under the body β€” an empty list for - everything but an open request card. They set the schema version, because - the action model they use is the reason to ask for the newer one.""" + ``below`` are card elements appended under the body β€” an open request + card's option buttons, or an ended turn's folded-away log, and an empty list + for everything else. They set the schema version between them, because what + a card needs to be drawn is decided by what is in it.""" card: dict[str, Any] = { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", - "version": _ACTION_VERSION if actions else _BASE_VERSION, + "version": _schema_version(below), # Plain-text representation for surfaces that can't render the card # inline (mobile, notification toasts, copy-link/search previews); its # absence is what makes Teams show the "cards.unsupported" placeholder. @@ -256,7 +347,7 @@ def agent_message_card( ], }, *body_blocks(body), - *actions, + *below, ], } if mentions: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py index 46aae2f3a..d301d976a 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py @@ -15,6 +15,8 @@ from __future__ import annotations +import pytest + from switch_core.bridges.collaboration.session.renderers import MARKDOWN from switch_core.bridges.collaboration.session.renderers.neutral import activity_log from switch_core.sessions.contract import Item @@ -37,6 +39,7 @@ def _log( turn_state: str = "completed", *, limit: int = 10_000, + heading: bool = True, ) -> list[str]: return activity_log( items, @@ -46,6 +49,7 @@ def _log( markup=MARKDOWN, elapsed_seconds=None, session_url=None, + heading=heading, ).splitlines() @@ -156,6 +160,61 @@ def test_one_long_call_is_shortened_rather_than_dropped() -> None: assert "Grepped for" in lines[-1] +# ── Where something above it already names the turn ────────────────────────── + + +def test_a_log_that_declines_the_heading_starts_at_the_first_call() -> None: + """Teams folds the log away directly under the status line, which already + carries the state and the Console link. Printing them again as the log's + first line is the card showing one sentence twice.""" + items = [_call(title="First"), _call(title="Second")] + + assert _log(items, heading=False) == ["βœ“ First", "βœ“ Second"] + + +def test_declining_the_heading_gives_its_room_back_to_the_calls() -> None: + """The budget is the whole of what may be spent, so a log with no state + line to pay for fits more of the turn into the same space.""" + items = [_call(title=f"Call {index}") for index in range(20)] + + with_head = [line for line in _log(items, limit=120)[1:] if line.startswith("βœ“")] + without = [ + line for line in _log(items, limit=120, heading=False) if line.startswith("βœ“") + ] + + assert len(without) > len(with_head) + + +def test_a_headless_log_with_no_calls_is_still_the_sentence_saying_so() -> None: + """An empty log is not an empty string. A fold opening onto nothing reads + as a card that failed to draw.""" + assert _log([], "completed", heading=False) == ["No tool calls."] + + +def test_a_link_handed_to_a_headless_log_is_refused_rather_than_dropped() -> None: + """There is nowhere to put it once the state line is gone, and a caller who + thinks they published a Console link and did not is worse off than one told + they asked for something impossible.""" + with pytest.raises(ValueError, match="nowhere to put the Console link"): + activity_log( + [_call()], + _turn("completed"), + escape=_identity, + limit=10_000, + markup=MARKDOWN, + elapsed_seconds=None, + session_url="https://console.example.test/s/1", + heading=False, + ) + + +def test_a_headless_log_stays_inside_the_budget_it_was_given() -> None: + items = [_call(title=f"Call {index} " + "x" * 400) for index in range(40)] + + for limit in (80, 200, 1000, 2000): + assert len("\n".join(_log(items, limit=limit, heading=False))) <= limit + + # ── What it is drawn with ──────────────────────────────────────────────────── @@ -175,6 +234,7 @@ def _record(text: str) -> str: markup=MARKDOWN, elapsed_seconds=None, session_url=None, + heading=True, ) assert "*not bold*" in seen @@ -190,6 +250,7 @@ def test_the_console_link_rides_on_the_state_line_when_there_is_room() -> None: markup=MARKDOWN, elapsed_seconds=None, session_url="https://console.example.test/s/1", + heading=True, ) assert "https://console.example.test/s/1" in drawn.splitlines()[0] @@ -205,6 +266,7 @@ def test_a_link_that_would_not_fit_is_left_off_rather_than_cut_in_half() -> None markup=MARKDOWN, elapsed_seconds=None, session_url="https://console.example.test/" + "s" * 200, + heading=True, ) assert "console.example.test" not in drawn diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py b/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py new file mode 100644 index 000000000..2220ba1d3 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py @@ -0,0 +1,292 @@ +"""A Teams turn's tool calls, folded away under the status that summarises them. + +Teams is the platform with no private reply channel outside a universal action +and no message a bot can post that only one reader sees. So the log is not +fetched on demand the way Discord's is β€” it travels in the card, hidden, and +`Action.ToggleVisibility` unhides it in the reader's own client. Nothing about +that press reaches Switch, which is the point: one reader opening the log +changes nothing for anyone else reading the same message. + +The two things that fall out of drawing it that way, and are tested here: + +- the fold is offered on an *ended* turn only. A running turn's card is + rewritten on every tool call, and a rewritten card arrives folded shut. +- the card asks for no newer schema than a plain message does, because hiding + and showing elements is two releases older than the base version. A fold + costs no client the card. +""" + +from __future__ import annotations + +from typing import Any + +from switch_core.bridges.collaboration.adapter import TurnActivity + +from .test_session_activity import _item, _turn +from .test_teams_adapter import _card_text, _run +from .test_teams_sdk_only import ( + AGENT, + CHANNEL, + ROOT, + _activity, + _card, + _Connector, + _teams, +) + +SHOW_ID = "switchActivityShow" +HIDE_ID = "switchActivityHide" +DETAIL_ID = "switchActivityDetail" + + +def _ended(*titles: str) -> TurnActivity: + """A finished turn that made the named calls, in the order named.""" + items = [ + _item(itemId=f"item-{position}", title=title) + for position, title in enumerate(titles, start=1) + ] + return TurnActivity(items, _turn("completed")) + + +def _posted(connector: _Connector) -> dict[str, Any]: + return dict(connector.sends[0]["activity"]["attachments"][0]["content"]) + + +def _elements(card: dict[str, Any]) -> dict[str, dict[str, Any]]: + """The fold's three elements, by id, or as many of them as are there.""" + return { + str(block["id"]): block for block in card["body"] if block.get("id") is not None + } + + +def _log_lines(card: dict[str, Any]) -> list[str]: + """What the hidden container shows once a reader opens it.""" + detail = _elements(card)[DETAIL_ID] + return [ + line + for block in detail["items"] + for line in str(block["text"]).split("\n") + if line + ] + + +def _fold(connector: _Connector) -> dict[str, dict[str, Any]]: + return _elements(_posted(connector)) + + +# ── When the fold is offered ───────────────────────────────────────────────── + + +def test_an_ended_turn_carries_its_tool_calls_folded_under_the_status() -> None: + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _ended("Read a file", "Ran a test"), ROOT)) + + assert _log_lines(_posted(connector)) == ["βœ“ Read a file", "βœ“ Ran a test"] + + +def test_a_running_turn_is_offered_no_fold_because_the_next_call_would_shut_it() -> ( + None +): + """Every change rewrites the card and a rewritten card arrives collapsed. A + fold that snapped shut under a reader mid-read would be worse than the + status line they already have.""" + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + assert _fold(connector) == {} + + +def test_a_turn_that_called_no_tools_is_offered_nothing_to_open() -> None: + """The fold promises something is behind it. "No tool calls." under a + status line already saying so is not what anybody pressed for.""" + adapter, connector = _teams() + + _run( + adapter.post_rich( + CHANNEL, + AGENT, + TurnActivity([_item(kind="assistant-message")], _turn("completed")), + ROOT, + ) + ) + + assert _fold(connector) == {} + + +def test_the_fold_arrives_on_the_redraw_that_ends_the_turn() -> None: + """The status is posted while the turn runs and rewritten when it stops, so + the edit is where the log has to appear β€” nothing posts the card again.""" + adapter, connector = _teams() + ref = _run(adapter.post_rich(CHANNEL, AGENT, _activity(), ROOT)) + + _run(adapter.update_rich(CHANNEL, AGENT, ref, _ended("Ran a test"), ROOT)) + + edited = connector.updates[0]["activity"]["attachments"][0]["content"] + assert _log_lines(edited) == ["βœ“ Ran a test"] + + +# ── What opening it does ───────────────────────────────────────────────────── + + +def test_the_log_starts_hidden_and_so_does_the_button_that_closes_it() -> None: + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _ended("Ran a test"), ROOT)) + + fold = _fold(connector) + assert fold[DETAIL_ID]["isVisible"] is False + assert fold[HIDE_ID]["isVisible"] is False + assert "isVisible" not in fold[SHOW_ID] + + +def test_opening_and_closing_are_exact_opposites_of_each_other() -> None: + """An Adaptive Card action's title is fixed, so "show" and "hide" have to + be two buttons that swap places. A reader sees exactly one of them, and it + says what pressing it will do.""" + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _ended("Ran a test"), ROOT)) + + fold = _fold(connector) + show = fold[SHOW_ID]["actions"][0] + hide = fold[HIDE_ID]["actions"][0] + assert show["title"] == "Show tool calls" + assert hide["title"] == "Hide tool calls" + assert show["targetElements"] == [ + {"elementId": SHOW_ID, "isVisible": False}, + {"elementId": DETAIL_ID, "isVisible": True}, + {"elementId": HIDE_ID, "isVisible": True}, + ] + assert hide["targetElements"] == [ + {"elementId": SHOW_ID, "isVisible": True}, + {"elementId": DETAIL_ID, "isVisible": False}, + {"elementId": HIDE_ID, "isVisible": False}, + ] + + +def test_a_press_on_the_fold_reaches_switch_in_no_way_at_all() -> None: + """`ToggleVisibility` is drawn by the client and carries no verb and no + data. That is what makes opening the log local to one reader, and it is + also why an adapter with no interaction handler can still offer it.""" + adapter, connector = _teams() + assert adapter._on_interaction is None + + _run(adapter.post_rich(CHANNEL, AGENT, _ended("Ran a test"), ROOT)) + + for element in _fold(connector).values(): + for action in element.get("actions", []): + assert action["type"] == "Action.ToggleVisibility" + assert "verb" not in action + assert "data" not in action + + +# ── What it costs the card ─────────────────────────────────────────────────── + + +def test_a_folded_card_asks_for_no_newer_schema_than_a_plain_message() -> None: + """A client too old for the version a card names drops the whole card and + shows `fallbackText`. Hiding elements predates the base version, so the + fold is free β€” unlike `Action.Execute`, which is not.""" + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _ended("Ran a test"), ROOT)) + + assert _posted(connector)["version"] == "1.4" + + +def test_a_request_card_still_asks_for_the_schema_its_buttons_need() -> None: + adapter, connector = _teams() + + async def _ignore(interaction: Any) -> None: + return None + + adapter.set_interaction_handler(_ignore) + + _run(adapter.post_rich(CHANNEL, AGENT, _run(_card()), ROOT)) + + assert _posted(connector)["version"] == "1.5" + + +def test_a_request_card_is_offered_options_rather_than_a_fold() -> None: + """One card asks one thing. A collapsed log under the options competes for + the press the card exists to collect.""" + adapter, connector = _teams() + + async def _ignore(interaction: Any) -> None: + return None + + adapter.set_interaction_handler(_ignore) + + _run(adapter.post_rich(CHANNEL, AGENT, _run(_card()), ROOT)) + + assert DETAIL_ID not in _elements(_posted(connector)) + + +def test_the_folded_log_stays_out_of_the_notification_and_the_fallback() -> None: + """`summary` is the toast and `fallbackText` is what a client that cannot + draw the card shows. Both are the status: a log a reader has not opened is + not something to push at them, or to unfold on their behalf.""" + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _ended("Read a secret file"), ROOT)) + + activity = connector.sends[0]["activity"] + assert "Read a secret file" not in activity["summary"] + assert "Read a secret file" not in _posted(connector)["fallbackText"] + assert "Read a secret file" not in _card_text(activity) + + +def test_the_log_does_not_repeat_the_state_line_it_is_folded_under() -> None: + """The status sits immediately above and carries the turn's state and its + Console link. Printing both again as the log's first line is the card + showing the same sentence twice, a centimetre apart.""" + adapter, connector = _teams() + + _run( + adapter.post_rich( + CHANNEL, + AGENT, + TurnActivity( + [_item(title="Ran a test")], + _turn("completed"), + session_url="https://console.example.test/s/1", + ), + ROOT, + ) + ) + + card = _posted(connector) + assert "console.example.test" in _card_text(connector.sends[0]["activity"]) + assert _log_lines(card) == ["βœ“ Ran a test"] + + +def test_host_text_in_the_log_goes_through_the_platforms_own_escape() -> None: + """Every title in there came from a host, and a TextBlock renders markdown + β€” including a `` tag that `_mention_entities` would then pair with a + real person.""" + adapter, connector = _teams() + + _run(adapter.post_rich(CHANNEL, AGENT, _ended("alice"), ROOT)) + + assert "alice" not in "\n".join(_log_lines(_posted(connector))) + + +def test_a_log_too_long_for_the_card_is_cut_and_says_how_much_it_cut() -> None: + """A turn with hundreds of calls must not grow the card without bound, and + a log that quietly showed its tail reads as a turn that made only those + calls.""" + adapter, connector = _teams() + + _run( + adapter.post_rich( + CHANNEL, AGENT, _ended(*[f"Call {n}" for n in range(400)]), ROOT + ) + ) + + lines = _log_lines(_posted(connector)) + assert lines[0].startswith("…") + assert "not shown" in lines[0] + assert len("\n".join(lines)) <= 2000 + assert lines[-1] == "βœ“ Call 399" From aefc8acfa6c5f5ffa978356be3d104b825c02d77 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 19:44:08 +0100 Subject: [PATCH 092/120] Mattermost: show a turn's tool calls privately, on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack prints the per-call log beside the status. Mattermost's status is one message with nowhere to put one, so the log sits behind a button on that message and comes back as ephemeral_text: the same content, read by one person, never added to the channel's history. Mattermost Blocks has a collapsible block, which would have matched Teams' shape more closely. It is behind the MmBlocksEnabled deployment flag with no documented minimum server version, and where the flag is off a block payload does not render at all β€” a silent failure on somebody else's server. The interactive-message route works on every server that can reach the callback, and reuses the signing and refusal machinery the request cards already have. There is no Refresh, because there is nothing to refresh: the log is drawn when the press arrives, so pressing again is the refresh and nothing on the screen can be older than the press that put it there. The button is signed over the channel rather than over the post β€” a post has no id at the moment its own button is built β€” and a press is refused unless the channel Mattermost says it came from is the signed one. The two kinds of button share a route and a key and are told apart by disjoint exact key sets, with the purpose in the signature so neither can be relabelled as the other. Who may read the log is asked of Mattermost on every press, against channel membership. That is narrower than readability on an open channel, where a team member may read without joining: such a reader is refused and told so, which is the side to be wrong on for a disclosure the status does not already make. A lookup that cannot answer refuses too. The three refusal sentences move to the neutral renderer from Discord, so a reader on both platforms is told the same thing the same way, and lose the "no longer" that was only true of access taken away. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 29 +- .../collaboration/mattermost/adapter.py | 190 ++++++- .../collaboration/mattermost/callback.py | 150 ++++- .../session/renderers/neutral.py | 26 + .../test_discord_activity_view.py | 34 +- .../test_mattermost_activity_view.py | 519 ++++++++++++++++++ .../collaboration/test_mattermost_press.py | 8 +- .../collaboration/test_mattermost_sdk_only.py | 23 + 8 files changed, 901 insertions(+), 78 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 1c7a755ab..8e92398dd 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -60,6 +60,9 @@ position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_FAILED, + ACTIVITY_GONE, + ACTIVITY_UNREADABLE, activity_log, render_request, turn_status, @@ -152,22 +155,6 @@ _REFRESH_LABEL = "Refresh" _CONSOLE_LABEL = "Open in Switch Console" -# What a reader is told when the press cannot be answered, privately and in -# place of the log. Said rather than left silent: a button that does nothing -# reads as Discord having dropped the press. -_ACTIVITY_GONE = ( - "There is no activity behind this message any more. It may belong to a " - "session that has since been removed." -) -_ACTIVITY_UNREADABLE = ( - "You can no longer read the conversation this turn was published into, so " - "its activity is not shown." -) -_ACTIVITY_FAILED = ( - "Switch could not read this turn's activity just now. Try again, or open " - "the session in Switch Console." -) - def _custom_id(token: str, position: int) -> str: return f"{_CUSTOM_ID_PREFIX}:{token}:{position}" @@ -2693,7 +2680,7 @@ async def _show_activity(self, interaction: discord.Interaction, ref: str) -> No resolve = self._resolve_activity location_id = _conversation_in(ref) if resolve is None or location_id is None: - await self._privately(interaction, _ACTIVITY_GONE, ref) + await self._privately(interaction, ACTIVITY_GONE, ref) return try: location = await self._get_channel(location_id) @@ -2704,10 +2691,10 @@ async def _show_activity(self, interaction: discord.Interaction, ref: str) -> No location_id, ref, ) - await self._privately(interaction, _ACTIVITY_GONE, ref) + await self._privately(interaction, ACTIVITY_GONE, ref) return if not await self._still_reads(location, interaction.user): - await self._privately(interaction, _ACTIVITY_UNREADABLE, ref) + await self._privately(interaction, ACTIVITY_UNREADABLE, ref) return parent_id = getattr(location, "parent_id", None) channel_id = str(parent_id if parent_id is not None else location.id) @@ -2720,10 +2707,10 @@ async def _show_activity(self, interaction: discord.Interaction, ref: str) -> No ref, channel_id, ) - await self._privately(interaction, _ACTIVITY_FAILED, ref) + await self._privately(interaction, ACTIVITY_FAILED, ref) return if snapshot is None: - await self._privately(interaction, _ACTIVITY_GONE, ref) + await self._privately(interaction, ACTIVITY_GONE, ref) return await self._privately( interaction, diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index ba31bfbc2..f7997cf89 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -12,7 +12,7 @@ from collections.abc import Awaitable, Callable from contextvars import ContextVar from dataclasses import dataclass, replace -from datetime import datetime +from datetime import UTC, datetime from typing import Any, ClassVar import httpx @@ -31,6 +31,7 @@ from switch_core.agent_icon import default_icon_url from switch_core.bridges.collaboration.adapter import ( ActivityMark, + ActivitySnapshot, CollaborationAdapter, RemovalFailed, RequestCard, @@ -45,6 +46,8 @@ ) from switch_core.bridges.collaboration.mattermost.callback import ( MAX_BUTTON_LABEL, + ActivityPress, + activity_action, answer_actions, read_press, ) @@ -68,6 +71,10 @@ position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_FAILED, + ACTIVITY_GONE, + ACTIVITY_UNREADABLE, + activity_log, render_request, turn_status, ) @@ -240,6 +247,19 @@ class MattermostConnectionConfig(BridgeConnectionConfig): ) +def _ephemeral(text: str) -> dict[str, Any]: + """A callback reply Mattermost shows to the presser and to nobody else. + + `skip_slack_parsing` because what is in here is already Mattermost + markdown, written by the neutral renderer. Without it the server runs the + text through its Slack-to-Mattermost conversion first, which is a second + pass of markup rules over something that has already been marked up β€” and + the one case where that is visibly wrong is a log line whose emphasis comes + back doubled. + """ + return {"ephemeral_text": text, "skip_slack_parsing": True} + + class MattermostAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True @@ -460,12 +480,19 @@ async def _handle_callback(self, body: dict[str, Any]) -> dict[str, Any]: Mattermost shows `ephemeral_text` to them and nobody else. A refusal raised while the answer is being judged reaches `tell_actor`, which leaves it here rather than posting it where the channel would read it. + + Two kinds of button arrive here. An answer to a request card goes + inwards as an interaction and is judged by the shared layer; a request + to see a turn's tool calls is a read, answered in the reply itself and + going no further than the person who asked. """ if self._callback is None: raise CallbackRefused("This bridge takes no callbacks.", status=404) press = read_press(self._callback.key, body) if press is None: raise CallbackRefused("Not a press this bridge will act on.", status=401) + if isinstance(press, ActivityPress): + return await self._show_activity(press) if self._on_interaction is None: logger.warning( "A press on a Switch card in Mattermost channel %s has nowhere " @@ -506,9 +533,113 @@ async def _handle_callback(self, body: dict[str, Any]) -> dict[str, Any]: _PRESS_NOTICE.reset(held) if notices: - return {"ephemeral_text": notices[0]} + return _ephemeral(notices[0]) return {} + async def _show_activity(self, press: ActivityPress) -> dict[str, Any]: + """Put a turn's tool calls in front of the one person who asked. + + The reply is the whole of the answer. Mattermost shows `ephemeral_text` + to the presser and nobody else, the channel's history gains nothing, + and a second reader pressing the same button gets a read of their own. + It is also why there is no Refresh here: the log is drawn when the + press arrives, so pressing again is the refresh, and nothing on the + screen can be older than the press that put it there. + + Two separate questions, both asked. Whether this bridge published that + turn into that channel is `_resolve_activity`'s, answered against the + record. Whether this reader may see the channel is Mattermost's, asked + on every press β€” a press establishes that the server accepted it, which + is not a statement about what the presser may read now. + + Every way it can fail says something. A button that answers with + nothing reads as the press having been dropped, and the reader would go + on pressing it. + """ + resolve = self._resolve_activity + if resolve is None: + return _ephemeral(ACTIVITY_GONE) + if not await self._reads_channel(press.channel_id, press.user_id): + return _ephemeral(ACTIVITY_UNREADABLE) + try: + snapshot = await resolve(press.channel_id, press.post_id) + except Exception: + logger.exception( + "Reading the activity behind post %s in Mattermost channel %s " + "failed, so the reader is told rather than left waiting.", + press.post_id, + press.channel_id, + ) + return _ephemeral(ACTIVITY_FAILED) + if snapshot is None: + return _ephemeral(ACTIVITY_GONE) + return _ephemeral(self._activity_text(snapshot)) + + async def _reads_channel(self, channel_id: str, user_id: str) -> bool: + """Whether this person is in the channel a turn was published into. + + Membership, because that is the audience Mattermost actually holds, and + it holds it the same way for an open channel, a private one and a + direct message. It is narrower than readability on an open channel, + where any member of the team may read without having joined; a reader + in that position is refused and told so, which is the side to be wrong + on for a disclosure this message does not already make. + + A lookup that cannot answer refuses too. "Mattermost did not say" is + not "yes", and an audience that cannot be established is one nothing + should be disclosed to. + """ + driver = self._admin_driver + loop = self._main_loop + if driver is None or loop is None: + logger.warning( + "Cannot establish who is in Mattermost channel %s: the bridge " + "is not connected, so no activity is shown.", + channel_id, + ) + return False + try: + member = await loop.run_in_executor( + None, driver.channels.get_channel_member, channel_id, user_id + ) + except (ResourceNotFound, NotEnoughPermissions): + # Both are answers rather than failures: Mattermost says "not a + # member" with a 404, and a channel this bridge may not inspect is + # one whose audience it cannot vouch for either. + return False + except Exception as error: + logger.warning( + "Mattermost would not say whether user %s is in channel %s " + "(%s), so no activity is shown.", + user_id, + channel_id, + error, + ) + return False + return bool(member) and member.get("user_id") == user_id + + def _activity_text(self, snapshot: ActivitySnapshot) -> str: + """The tool calls, and the time the read behind them was taken. + + Stamped because an ephemeral reply stays on the screen until the reader + dismisses it or reloads, and one left open for twenty minutes is not + wrong but is not current either. An absolute time rather than a + relative one: Mattermost renders no clock of its own in a message, so a + "moments ago" written here would still say that an hour later. + """ + stamp = f"_Read at {snapshot.read_at.astimezone(UTC):%H:%M:%S} UTC_" + body = activity_log( + snapshot.items, + snapshot.turn, + escape=self._rich_escape, + limit=max(1, self.rich_fallback_limit() - len(stamp) - 1), + markup=self.rich_markup(), + elapsed_seconds=snapshot.elapsed_seconds, + session_url=snapshot.session_url, + heading=True, + ) + return f"{body}\n{stamp}" + async def tell_actor( self, channel_id: str, @@ -907,36 +1038,47 @@ def _button_address(self) -> tuple[str, str] | None: return None return url, endpoint.key - def _controls(self, content: RichContent, drawn: Drawn) -> list[dict[str, Any]]: - """The card's options as buttons, or nothing where a press cannot land. - - Nothing at all is the ordinary answer: a status has no options, a - settled card has none left, a bridge with no callback address has - nowhere for a press to go, and a card that cannot be answered where it - is showing says so β€” a live control under that sentence invites the - refusal the sentence just explained. Because every redraw builds this - again, the buttons come off a card at the moment it stops being - pressable, without anything having to remember that it once had them. - - Whether the drawing earned them comes from `drawn` rather than from - reading the request a second time. A body cut short of the difference - between two options is one a reader cannot decide from, and only the - renderer that cut it knows that. A press would still resolve against - the stored record and settle the request, so the whole of the + def _controls( + self, channel_id: str, content: RichContent, drawn: Drawn + ) -> list[dict[str, Any]]: + """A post's buttons: a card's options, or a turn's way into its log. + + Nothing at all is an ordinary answer: a settled card has no options + left, a bridge with no callback address has nowhere for a press to go, + and a card that cannot be answered where it is showing says so β€” a live + control under that sentence invites the refusal the sentence just + explained. Because every redraw builds this again, the buttons come off + a card at the moment it stops being pressable, without anything having + to remember that it once had them. + + A turn's status earns one button whatever state it is in. The log it + opens is read when the press arrives rather than drawn into the post, + so a running turn's is as current as an ended turn's and neither goes + stale on the channel. + + Whether the drawing earned the option buttons comes from `drawn` rather + than from reading the request a second time. A body cut short of the + difference between two options is one a reader cannot decide from, and + only the renderer that cut it knows that. A press would still resolve + against the stored record and settle the request, so the whole of the protection is not offering the button. """ address = self._button_address() if address is None: return [] + url, key = address + if isinstance(content, TurnActivity): + if self._resolve_activity is None or content.error_summary: + return [] + return [activity_action(key, url, channel_id)] if not isinstance(content, RequestCard) or not drawn.answerable: return [] controls = offered_controls(content.request) if not controls: return [] - url, key = address return answer_actions(key, url, content.reference.token, controls) - async def _render_rich(self, content: RichContent) -> _Rendered: + async def _render_rich(self, channel_id: str, content: RichContent) -> _Rendered: mention = await self._mention(content.notify_external_id) responder = ( await self._mention(content.responder_external_id) @@ -949,7 +1091,7 @@ async def _render_rich(self, content: RichContent) -> _Rendered: drawn = self._draw( content, mention=mention, responder=responder, controls=controls ) - actions = self._controls(content, drawn) + actions = self._controls(channel_id, content, drawn) if not controls: return _Rendered(text=drawn.text, actions=actions, plain=drawn.text) # The body drops an option only where a button carries it, so wherever @@ -986,7 +1128,7 @@ async def post_rich( Mattermost actually gave. A send whose outcome nobody knows raises the transport's own error and keeps the reservation. """ - rendered = await self._render_rich(content) + rendered = await self._render_rich(channel_id, content) driver = self._bot_drivers.get(agent_name) if driver is None: raise RichContentFailed( @@ -1051,7 +1193,9 @@ async def update_rich( # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the channel that never resolves to # anything new for the person it names. - rendered = await self._render_rich(replace(content, notify_external_id=None)) + rendered = await self._render_rich( + channel_id, replace(content, notify_external_id=None) + ) driver = self._bot_drivers.get(agent_name) or self._admin_driver loop = self._main_loop if driver is None or loop is None: diff --git a/core/switch_core/bridges/collaboration/mattermost/callback.py b/core/switch_core/bridges/collaboration/mattermost/callback.py index 71f00dc2d..78fe519c1 100644 --- a/core/switch_core/bridges/collaboration/mattermost/callback.py +++ b/core/switch_core/bridges/collaboration/mattermost/callback.py @@ -15,9 +15,19 @@ # collision waiting for the first card here to want a second kind of button. CONTEXT_KEY = "switch" -# What the signature is computed over, so a value minted for one purpose can -# never be replayed as another if a second kind of button is ever added. -_PURPOSE = "answer" +# What each signature is computed over, so a context minted for one purpose can +# never be posted back as another. The two kinds of button here are told apart +# by the shape of what they carry β€” the key sets are disjoint β€” and the purpose +# is what stops a signature from surviving being relabelled. +_ANSWER_PURPOSE = "answer" +_ACTIVITY_PURPOSE = "activity" + +# The button that opens a turn's tool calls, and what it says. One per status +# post, and the same on every status post in a channel: which turn is being +# asked about is the post the press arrives on, which the Mattermost server +# fills in and a client cannot write. +ACTIVITY_ACTION_ID = "switchactivity" +ACTIVITY_LABEL = "Show tool calls" # How much of an option a button shows. Not a server limit β€” Mattermost # documents none β€” but a width past which a control stops reading as a control @@ -43,6 +53,21 @@ class Press: position: int +@dataclass(frozen=True) +class ActivityPress: + """A press asking to be shown the tool calls behind a turn's status post. + + Carries no subject of its own, unlike `Press`. Which turn is being asked + about is the post the button sits on, and Mattermost names that post + itself β€” the button could not, because a post's id does not exist until + the post carrying the button has been made. + """ + + user_id: str + post_id: str + channel_id: str + + def action_context(secret: str, token: str, position: int) -> dict[str, Any]: """The hidden data a button carries, signed so a forgery cannot be built. @@ -61,11 +86,38 @@ def action_context(secret: str, token: str, position: int) -> dict[str, Any]: CONTEXT_KEY: { "token": token, "position": position, - "signature": _sign(secret, token, position), + "signature": _sign(secret, _ANSWER_PURPOSE, f"{token}:{position}"), } } +def activity_action(secret: str, url: str, channel_id: str) -> dict[str, Any]: + """The button that opens a turn's tool calls for whoever presses it. + + Signed over the channel rather than over the post, for a plain reason: at + the moment this is built the post does not exist, so it has no id to sign. + The channel is what the context is bound to, and a press is refused unless + the channel Mattermost says it came from is that one β€” so a context that + leaks is worth one conversation's logs rather than the server's. + + Nothing about who may read them is in here. That is asked of Mattermost + when the press arrives, against the presser the server names. + """ + return { + "id": ACTIVITY_ACTION_ID, + "name": ACTIVITY_LABEL, + "integration": { + "url": url, + "context": { + CONTEXT_KEY: { + "channel": channel_id, + "signature": _sign(secret, _ACTIVITY_PURPOSE, channel_id), + } + }, + }, + } + + def answer_actions( secret: str, url: str, token: str, controls: list[Control] ) -> list[dict[str, Any]]: @@ -113,7 +165,7 @@ def _button_name(control: Control) -> str: return f"{control.position}. {label}" -def read_press(secret: str, body: dict[str, Any]) -> Press | None: +def read_press(secret: str, body: dict[str, Any]) -> Press | ActivityPress | None: """What a callback is asking for, or None if it is not ours to act on. Read as strictly as it is written, and in two stages. A body that does not @@ -123,9 +175,14 @@ def read_press(secret: str, body: dict[str, Any]) -> Press | None: been rotated out from under posts already on the channel, and says so in the log β€” those are worth telling apart, and neither is worth acting on. + Which of the two kinds of button this is comes from the shape of what it + carries. The key sets are disjoint and each is matched exactly, so the + discriminator is not a field a forger gets to choose between: a context + that is not precisely one shape or the other is not read as either. + Only the shape is established here. That the post is the one the card was - published to, and that this person may answer it at all, are decided - against the record further in. + published to, that the turn is one this bridge published, and that this + person may act on it at all, are decided against the record further in. """ carried = body.get("context") if not isinstance(carried, dict): @@ -133,6 +190,8 @@ def read_press(secret: str, body: dict[str, Any]) -> Press | None: switch = carried.get(CONTEXT_KEY) if not isinstance(switch, dict): return None + if set(switch) == {"channel", "signature"}: + return _activity_press(secret, switch, body) # Nothing but what was signed. The signature covers the card and the # option, so a field beside them is one it does not vouch for β€” and a # reader added later would be reading an unsigned value out of a context @@ -150,7 +209,9 @@ def read_press(secret: str, body: dict[str, Any]) -> Press | None: return None if not isinstance(signature, str) or not signature: return None - if not hmac.compare_digest(signature, _sign(secret, token, position)): + if not hmac.compare_digest( + signature, _sign(secret, _ANSWER_PURPOSE, f"{token}:{position}") + ): logger.warning( "Rejected a Mattermost action callback for request %s: the context " "signature does not verify. Either it was not signed with this " @@ -160,6 +221,67 @@ def read_press(secret: str, body: dict[str, Any]) -> Press | None: ) return None + who = _presser(body) + if who is None: + return None + user_id, post_id, channel_id = who + return Press( + user_id=user_id, + post_id=post_id, + channel_id=channel_id, + token=token, + position=position, + ) + + +def _activity_press( + secret: str, switch: dict[str, Any], body: dict[str, Any] +) -> ActivityPress | None: + """A press on the button that opens a turn's tool calls, or None. + + The signed channel is checked against the one Mattermost says the press + came from rather than used in its place. It is there to bind the context + to a conversation, not to name one: what the read is made against is the + server's word, and a context lifted onto a post somewhere else is refused + here rather than resolved against either channel. + """ + channel = switch.get("channel") + signature = switch.get("signature") + if not isinstance(channel, str) or not channel: + return None + if not isinstance(signature, str) or not signature: + return None + if not hmac.compare_digest(signature, _sign(secret, _ACTIVITY_PURPOSE, channel)): + logger.warning( + "Rejected a Mattermost activity callback for channel %s: the " + "context signature does not verify. Either it was not signed with " + "this bridge's credential, or the credential has been rotated " + "since the post was made.", + channel, + ) + return None + who = _presser(body) + if who is None: + return None + user_id, post_id, channel_id = who + if channel_id != channel: + logger.warning( + "Rejected a Mattermost activity callback: the button was signed " + "for channel %s and the press arrived from channel %s.", + channel, + channel_id, + ) + return None + return ActivityPress(user_id=user_id, post_id=post_id, channel_id=channel_id) + + +def _presser(body: dict[str, Any]) -> tuple[str, str, str] | None: + """Who pressed, on what, and where β€” the three fields the server fills in. + + None if any is missing or empty. A press this bridge cannot place is not + one it can check anything about, and every decision downstream is made + against one of the three. + """ user_id = body.get("user_id") post_id = body.get("post_id") channel_id = body.get("channel_id") @@ -169,15 +291,9 @@ def read_press(secret: str, body: dict[str, Any]) -> Press | None: return None if not isinstance(channel_id, str) or not channel_id: return None - return Press( - user_id=user_id, - post_id=post_id, - channel_id=channel_id, - token=token, - position=position, - ) + return user_id, post_id, channel_id -def _sign(secret: str, token: str, position: int) -> str: - message = f"{_PURPOSE}:{token}:{position}".encode() +def _sign(secret: str, purpose: str, subject: str) -> str: + message = f"{purpose}:{subject}".encode() return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index a0089caf9..7346034a5 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -112,6 +112,32 @@ _LOG_EMPTY_YET = "No tool calls yet." _LOG_CUT = "…{left} earlier in this turn, not shown." +# What a reader is told, privately and in place of the log, when the press +# asking for it cannot be answered. Said rather than left silent: a button that +# answers with nothing reads as the platform having dropped the press, and the +# reader goes on pressing it. +# +# Here rather than in each adapter because a reader on two platforms is one +# reader, and the same refusal worded two ways reads as two different problems. +# Which of the three applies is the adapter's to decide β€” only it knows who +# pressed and what the platform would say about them. +ACTIVITY_GONE = ( + "There is no activity behind this message any more. It may belong to a " + "session that has since been removed." +) +# Not "no longer": on one platform this is access that was taken away, on +# another it is a reader who never had it β€” an open channel they can read +# without having joined, which is not membership and is all Mattermost will +# vouch for. The sentence has to be true of both. +ACTIVITY_UNREADABLE = ( + "You cannot read the conversation this turn was published into, so its " + "activity is not shown." +) +ACTIVITY_FAILED = ( + "Switch could not read this turn's activity just now. Try again, or open " + "the session in Switch Console." +) + _OUTCOME_WORDS = { "in-progress": "running", "completed": "done", diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py index 0b608729e..eef12969e 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py @@ -31,10 +31,7 @@ from switch_core.bridges.collaboration.adapter import ActivitySnapshot from switch_core.bridges.collaboration.discord.adapter import ( - _ACTIVITY_FAILED, - _ACTIVITY_GONE, _ACTIVITY_LABEL, - _ACTIVITY_UNREADABLE, _ACTIVITY_VIEW_ID, _CONSOLE_LABEL, _MAX_BUTTON_LABEL, @@ -43,6 +40,11 @@ DiscordAdapter, _refresh_id, ) +from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_FAILED, + ACTIVITY_GONE, + ACTIVITY_UNREADABLE, +) from .test_discord_sdk_only import ( CHANNEL_ID, @@ -492,7 +494,7 @@ async def test_a_reference_that_is_not_an_address_is_told_there_is_nothing() -> await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == _ACTIVITY_GONE + assert _shown(press) == ACTIVITY_GONE # ── Who may read it ────────────────────────────────────────────────────────── @@ -507,7 +509,7 @@ async def test_a_reader_who_has_lost_the_channel_is_told_rather_than_shown() -> await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == _ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_UNREADABLE async def test_a_reader_who_cannot_read_the_history_is_refused_too() -> None: @@ -519,7 +521,7 @@ async def test_a_reader_who_cannot_read_the_history_is_refused_too() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == _ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_UNREADABLE async def test_someone_who_has_left_the_guild_is_refused() -> None: @@ -529,7 +531,7 @@ async def test_someone_who_has_left_the_guild_is_refused() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == _ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_UNREADABLE async def test_a_private_thread_asks_for_membership_not_visibility() -> None: @@ -547,7 +549,7 @@ async def test_a_private_thread_asks_for_membership_not_visibility() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == _ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_UNREADABLE async def test_a_member_of_that_thread_is_shown_it() -> None: @@ -583,7 +585,7 @@ async def test_a_refresh_is_authorised_against_the_thread_its_reference_names() await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == _ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_UNREADABLE async def test_a_destination_nobody_can_ask_about_is_refused_not_assumed( @@ -599,7 +601,7 @@ async def test_a_destination_nobody_can_ask_about_is_refused_not_assumed( with caplog.at_level(logging.WARNING): await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == _ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_UNREADABLE assert any( "Cannot establish who may read" in r.getMessage() for r in caplog.records ) @@ -629,7 +631,7 @@ async def test_someone_not_in_that_channel_is_refused_it() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == _ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_UNREADABLE async def test_a_channel_that_cannot_say_who_is_in_it_is_refused( @@ -643,7 +645,7 @@ async def test_a_channel_that_cannot_say_who_is_in_it_is_refused( with caplog.at_level(logging.WARNING): await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == _ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_UNREADABLE assert any("Cannot establish who is in" in r.getMessage() for r in caplog.records) @@ -659,7 +661,7 @@ async def test_a_message_showing_no_turn_says_so_rather_than_nothing() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == _ACTIVITY_GONE + assert _shown(press) == ACTIVITY_GONE async def test_a_reference_to_a_channel_discord_will_not_name_says_so() -> None: @@ -670,7 +672,7 @@ async def test_a_reference_to_a_channel_discord_will_not_name_says_so() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == _ACTIVITY_GONE + assert _shown(press) == ACTIVITY_GONE async def test_a_read_that_fails_is_reported_to_the_reader_and_the_log( @@ -683,7 +685,7 @@ async def test_a_read_that_fails_is_reported_to_the_reader_and_the_log( with caplog.at_level(logging.ERROR): await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == _ACTIVITY_FAILED + assert _shown(press) == ACTIVITY_FAILED assert any( "failed, so the reader is told" in r.getMessage() for r in caplog.records ) @@ -779,7 +781,7 @@ async def test_an_unpublished_bridge_answers_a_stale_button_rather_than_hanging( await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == _ACTIVITY_GONE + assert _shown(press) == ACTIVITY_GONE def test_the_activity_ids_fit_what_discord_carries() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py new file mode 100644 index 000000000..a06525746 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py @@ -0,0 +1,519 @@ +"""Reading a Mattermost turn's tool calls without posting them to the channel. + +Slack prints the per-call log beside the status. Mattermost's status is one +message and has nowhere to put one, so the log sits behind a button on that +message and comes back as `ephemeral_text` β€” the same content, read by one +person instead of by a channel, and never added to the channel's history. + +The shape is cheaper than Discord's because the reply *is* the answer. There is +no private copy to keep up to date and therefore no Refresh: the log is drawn +when the press arrives, so pressing again is the refresh and nothing on screen +can be older than the press that put it there. + +What it costs is an authority question the status message never had to ask. The +button names no turn β€” Mattermost names the post it was pressed on, which a +client cannot write β€” but who may *read* that conversation is a question only +Mattermost can answer, and it is asked on every press rather than inferred from +the press having arrived at all. + +The context is the other half. Mattermost keeps an action's context +confidential, and this one is signed for the channel it was minted in, so a +context that does escape is worth one conversation rather than the server. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +import pytest +from mattermostdriver.exceptions import NotEnoughPermissions + +from switch_core.bridges.collaboration.adapter import ActivitySnapshot, TurnActivity +from switch_core.bridges.collaboration.ingress import CallbackRefused +from switch_core.bridges.collaboration.mattermost.adapter import MattermostAdapter +from switch_core.bridges.collaboration.mattermost.callback import ( + ACTIVITY_ACTION_ID, + ACTIVITY_LABEL, + CONTEXT_KEY, + action_context, + activity_action, +) +from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_FAILED, + ACTIVITY_GONE, + ACTIVITY_UNREADABLE, +) + +from .test_mattermost_press import ( + CALLBACK_BASE, + CHANNEL, + POST, + TOKEN, + USER, + _adapter, + _body, + _key, + _record, +) +from .test_mattermost_sdk_only import _activity, _card, _posts +from .test_session_activity import _item, _turn + +CALLBACK_URL = f"{CALLBACK_BASE}/collaboration/mattermost/bridge-1/callback" +CONSOLE_URL = "https://console.example.test/s/1" +OTHER_CHANNEL = "chan-2" + + +def _viewer( + *, member_of: str | None = CHANNEL, **kwargs: Any +) -> tuple[MattermostAdapter, list[tuple[str, str]]]: + """An adapter whose presser is in `member_of`, and its list of reads made.""" + adapter = _adapter(**kwargs) + _record(adapter) + if member_of is not None: + _channels(adapter).members[member_of] = {USER} + return adapter, _resolving(adapter, _snapshot()) + + +def _channels(adapter: MattermostAdapter) -> Any: + driver: Any = adapter._admin_driver + return driver.channels + + +def _snapshot(**fields: Any) -> ActivitySnapshot: + items = [ + _item(itemId="a", kind="tool-activity", title="Read config.toml"), + _item( + itemId="b", kind="tool-activity", title="Ran the tests", text="42 passed" + ), + ] + defaults: dict[str, Any] = { + "items": items, + "turn": _turn("completed"), + "elapsed_seconds": 12.0, + "session_url": CONSOLE_URL, + "read_at": datetime(2026, 9, 16, 12, 34, 56, tzinfo=UTC), + } + return ActivitySnapshot(**{**defaults, **fields}) + + +def _resolving(adapter: MattermostAdapter, answer: Any = None) -> list[tuple[str, str]]: + """Give `adapter` something to resolve a press against, and record the asks. + + `answer` is what every read returns β€” a snapshot, None for a post showing + nothing, or an exception instance to raise. + """ + asked: list[tuple[str, str]] = [] + + async def resolve(channel_id: str, ref: str) -> ActivitySnapshot | None: + asked.append((channel_id, ref)) + if isinstance(answer, Exception): + raise answer + return answer # type: ignore[no-any-return] + + adapter.set_activity_resolver(resolve) + return asked + + +def _press(channel: str = CHANNEL, **overrides: Any) -> dict[str, Any]: + """The body Mattermost posts when the activity button is pressed.""" + context = activity_action(_key(), CALLBACK_URL, channel)["integration"]["context"] + return _body(context, channel_id=channel, **overrides) + + +def _buttons(post: dict[str, Any]) -> list[dict[str, Any]]: + attachments = (post.get("props") or {}).get("attachments") or [] + return [action for attachment in attachments for action in attachment["actions"]] + + +def _shown(answer: dict[str, Any]) -> str: + text = answer.get("ephemeral_text") + assert isinstance(text, str) + return text + + +# ── What the status post offers ────────────────────────────────────────────── + + +async def test_a_turns_status_post_carries_the_way_into_its_tool_calls() -> None: + adapter, _ = _viewer() + + await adapter.post_rich(CHANNEL, "worker", _activity(), "root-1") + + assert [button["name"] for button in _buttons(_posts(adapter).created[0])] == [ + ACTIVITY_LABEL + ] + + +async def test_the_button_is_addressed_to_this_bridges_own_callback_url() -> None: + adapter, _ = _viewer() + + await adapter.post_rich(CHANNEL, "worker", _activity(), "root-1") + + button = _buttons(_posts(adapter).created[0])[0] + assert button["integration"]["url"] == CALLBACK_URL + assert button["id"] == ACTIVITY_ACTION_ID + + +async def test_the_button_carries_the_channel_and_a_signature_and_nothing_else() -> ( + None +): + """No session id, no turn id, no card token. Which turn is being asked + about is the post the press arrives on, and the server names that.""" + adapter, _ = _viewer() + + await adapter.post_rich(CHANNEL, "worker", _activity(), "root-1") + + context = _buttons(_posts(adapter).created[0])[0]["integration"]["context"] + assert set(context) == {CONTEXT_KEY} + assert set(context[CONTEXT_KEY]) == {"channel", "signature"} + assert context[CONTEXT_KEY]["channel"] == CHANNEL + + +async def test_a_running_turn_is_offered_it_as_readily_as_a_finished_one() -> None: + """The log is read when the press arrives rather than drawn into the post, + so a running turn's is exactly as current as an ended turn's.""" + adapter, _ = _viewer() + + await adapter.post_rich(CHANNEL, "worker", _activity(), "root-1") + await adapter.post_rich( + CHANNEL, + "worker", + TurnActivity([_item(title="Ran the tests")], _turn("completed")), + "root-1", + ) + + assert all(_buttons(post) for post in _posts(adapter).created) + + +async def test_the_attention_message_is_left_to_say_its_one_thing() -> None: + """A message whose whole job is "somebody has to act" does not want a + control under it inviting the reader somewhere else.""" + adapter, _ = _viewer() + + await adapter.post_rich( + CHANNEL, "worker", _activity(error_summary="The session stopped."), "root-1" + ) + + assert _buttons(_posts(adapter).created[0]) == [] + + +async def test_a_bridge_that_cannot_answer_the_question_does_not_ask_it() -> None: + """A publisher is what knows which turn a post is showing. Without one the + button would be drawn onto a question nobody can resolve.""" + adapter = _adapter() + _record(adapter) + + await adapter.post_rich(CHANNEL, "worker", _activity(), "root-1") + + assert _buttons(_posts(adapter).created[0]) == [] + + +async def test_a_bridge_with_no_callback_address_draws_no_button() -> None: + adapter, _ = _viewer(callback_base_url=None) + + await adapter.post_rich(CHANNEL, "worker", _activity(), "root-1") + + assert _buttons(_posts(adapter).created[0]) == [] + + +async def test_a_request_card_gets_its_options_and_not_this() -> None: + adapter, _ = _viewer() + + await adapter.post_rich(CHANNEL, "worker", await _card(), "root-1") + + names = [button["name"] for button in _buttons(_posts(adapter).created[0])] + assert ACTIVITY_LABEL not in names + assert names == ["1. Allow once", "2. Deny"] + + +async def test_redrawing_a_turn_leaves_the_button_where_it_was() -> None: + """The action is the same on every status post in a channel, so a redraw + has nothing to say about it. Patching props anyway would mean reading the + post back on every tool call, which is the hottest path this bridge has.""" + adapter, _ = _viewer() + + ref = await adapter.post_rich(CHANNEL, "worker", _activity(), "root-1") + await adapter.update_rich(CHANNEL, "worker", ref, _activity(), "root-1") + + patched = _posts(adapter).patched[0][1] + assert "props" not in patched + assert _buttons(_posts(adapter).stored[ref]) + + +# ── Reading the press ──────────────────────────────────────────────────────── + + +async def test_the_read_is_made_against_the_post_the_server_names() -> None: + adapter, asked = _viewer() + + await adapter._handle_callback(_press()) + + assert asked == [(CHANNEL, POST)] + + +async def test_a_press_for_the_log_never_reaches_the_answer_path() -> None: + """The two buttons share a route and a signing key, and mean entirely + different things. Nothing about a read may land as an answer.""" + adapter = _adapter() + seen = _record(adapter) + _channels(adapter).members[CHANNEL] = {USER} + _resolving(adapter, _snapshot()) + + await adapter._handle_callback(_press()) + + assert seen == [] + + +async def test_an_answer_press_is_still_an_answer() -> None: + adapter, asked = _viewer() + seen = _record(adapter) + + await adapter._handle_callback(_body(action_context(_key(), TOKEN, 2))) + + assert asked == [] + assert [interaction.value for interaction in seen] == [TOKEN] + + +async def test_a_context_minted_for_another_channel_is_refused() -> None: + """The signature binds the button to one conversation. A context lifted + onto a post somewhere else is not resolved against either.""" + adapter, asked = _viewer() + + with pytest.raises(CallbackRefused): + await adapter._handle_callback( + _body( + activity_action(_key(), CALLBACK_URL, OTHER_CHANNEL)["integration"][ + "context" + ], + channel_id=CHANNEL, + ) + ) + + assert asked == [] + + +async def test_a_context_signed_by_another_bridge_is_refused() -> None: + adapter, asked = _viewer() + + with pytest.raises(CallbackRefused): + await adapter._handle_callback( + _body( + activity_action(_key("bridge-2"), CALLBACK_URL, CHANNEL)["integration"][ + "context" + ] + ) + ) + + assert asked == [] + + +async def test_an_unsigned_request_for_a_log_is_not_a_press() -> None: + """The route is reachable by anything that can reach the port.""" + adapter, asked = _viewer() + + with pytest.raises(CallbackRefused): + await adapter._handle_callback(_body({CONTEXT_KEY: {"channel": CHANNEL}})) + + assert asked == [] + + +async def test_a_field_beside_what_was_signed_voids_the_whole_context() -> None: + """The signature covers the channel. A reader added later would be reading + an unsigned value out of a context that looks authentic.""" + adapter, asked = _viewer() + context = activity_action(_key(), CALLBACK_URL, CHANNEL)["integration"]["context"] + context[CONTEXT_KEY]["post"] = "post-somewhere-else" + + with pytest.raises(CallbackRefused): + await adapter._handle_callback(_body(context)) + + assert asked == [] + + +# ── What the reader gets ───────────────────────────────────────────────────── + + +async def test_the_log_comes_back_to_the_presser_and_not_to_the_channel() -> None: + adapter, _ = _viewer() + + answer = await adapter._handle_callback(_press()) + + assert "Ran the tests β€” 42 passed" in _shown(answer) + assert _posts(adapter).created == [] + assert _posts(adapter).patched == [] + + +async def test_the_log_is_the_calls_oldest_first_under_the_state_line() -> None: + adapter, _ = _viewer() + + lines = _shown(await adapter._handle_callback(_press())).splitlines() + + assert lines[1:3] == ["βœ“ Read config.toml", "βœ“ Ran the tests β€” 42 passed"] + + +async def test_the_state_line_carries_the_way_into_console() -> None: + """The status post has the link too, but this reply is read on its own β€” + an ephemeral message has no message above it.""" + adapter, _ = _viewer() + + assert CONSOLE_URL in _shown(await adapter._handle_callback(_press())) + + +async def test_it_says_when_the_read_behind_it_was_taken() -> None: + """An ephemeral reply stays on screen until it is dismissed, and one left + open for twenty minutes is not wrong but is not current either.""" + adapter, _ = _viewer() + + assert "12:34:56 UTC" in _shown(await adapter._handle_callback(_press())) + + +async def test_the_reply_is_sent_as_the_markdown_it_already_is() -> None: + """Mattermost's Slack conversion is a second pass of markup rules over + text the neutral renderer has already marked up.""" + adapter, _ = _viewer() + + assert (await adapter._handle_callback(_press()))["skip_slack_parsing"] is True + + +async def test_pressing_again_is_a_second_read_rather_than_a_second_copy() -> None: + """That is the whole of the refresh story here: nothing is cached, and + nothing on screen can be older than the press that put it there.""" + adapter, asked = _viewer() + + await adapter._handle_callback(_press()) + await adapter._handle_callback(_press()) + + assert asked == [(CHANNEL, POST), (CHANNEL, POST)] + + +async def test_a_log_too_long_for_a_post_is_cut_rather_than_refused() -> None: + """Mattermost rejects a post over its size, and a reply it rejects is a + button that does nothing.""" + adapter, _ = _viewer() + _resolving( + adapter, + _snapshot( + items=[ + _item(itemId=f"i{n}", kind="tool-activity", title=f"Call {n}") + for n in range(400) + ] + ), + ) + + shown = _shown(await adapter._handle_callback(_press())) + + assert len(shown) <= adapter.rich_fallback_limit() + assert "not shown." in shown + assert shown.splitlines()[-2] == "βœ“ Call 399" + + +async def test_what_a_host_called_a_tool_cannot_address_the_channel() -> None: + """A tool title is host text, and it goes through the same defusing here as + it does in the status post it was read from β€” an ephemeral reply is still a + message Mattermost renders, and a mention in one still notifies.""" + adapter, _ = _viewer() + _resolving( + adapter, _snapshot(items=[_item(itemId="a", title="Paged @channel twice")]) + ) + + shown = _shown(await adapter._handle_callback(_press())) + + assert "@channel" not in shown + assert "Paged @\u200bchannel twice" in shown + + +# ── Who may read it ────────────────────────────────────────────────────────── + + +async def test_someone_who_is_not_in_the_channel_is_told_rather_than_shown() -> None: + adapter, asked = _viewer(member_of=None) + + answer = await adapter._handle_callback(_press()) + + assert _shown(answer) == ACTIVITY_UNREADABLE + assert asked == [] + + +async def test_the_question_is_asked_of_mattermost_on_every_press() -> None: + """A press establishes that the server accepted it, which is not a + statement about what the presser may read now.""" + adapter, _ = _viewer() + + await adapter._handle_callback(_press()) + await adapter._handle_callback(_press()) + + assert _channels(adapter).calls == [(CHANNEL, USER), (CHANNEL, USER)] + + +async def test_a_membership_lookup_that_cannot_answer_refuses( + caplog: pytest.LogCaptureFixture, +) -> None: + """ "Mattermost did not say" is not "yes".""" + adapter, asked = _viewer() + _channels(adapter).error = RuntimeError("the server is down") + + with caplog.at_level(logging.WARNING): + answer = await adapter._handle_callback(_press()) + + assert _shown(answer) == ACTIVITY_UNREADABLE + assert asked == [] + assert "would not say whether" in caplog.text + + +async def test_a_channel_this_bridge_may_not_inspect_is_one_it_will_not_disclose( + caplog: pytest.LogCaptureFixture, +) -> None: + """An audience that cannot be established is not an empty one, but it is + not one anything should be shown to either β€” and it is an answer rather + than a fault, so it is not logged as one.""" + adapter, _ = _viewer() + _channels(adapter).error = NotEnoughPermissions("not allowed") + + with caplog.at_level(logging.WARNING): + answer = await adapter._handle_callback(_press()) + + assert _shown(answer) == ACTIVITY_UNREADABLE + assert "would not say whether" not in caplog.text + + +async def test_a_bridge_that_is_not_connected_shows_nobody_anything() -> None: + adapter, _ = _viewer() + adapter._admin_driver = None + + assert _shown(await adapter._handle_callback(_press())) == ACTIVITY_UNREADABLE + + +# ── When there is nothing to show ──────────────────────────────────────────── + + +async def test_a_post_showing_no_turn_says_so_rather_than_nothing() -> None: + """A button that answers with nothing reads as the press having been + dropped, and the reader goes on pressing it.""" + adapter, _ = _viewer() + _resolving(adapter, None) + + assert _shown(await adapter._handle_callback(_press())) == ACTIVITY_GONE + + +async def test_a_bridge_with_no_publisher_says_the_same() -> None: + """Reachable only for a post whose button was drawn when there was one.""" + adapter = _adapter() + _record(adapter) + _channels(adapter).members[CHANNEL] = {USER} + + assert _shown(await adapter._handle_callback(_press())) == ACTIVITY_GONE + + +async def test_a_read_that_fails_tells_the_reader_instead_of_hanging( + caplog: pytest.LogCaptureFixture, +) -> None: + adapter, _ = _viewer() + _resolving(adapter, RuntimeError("the database went away")) + + with caplog.at_level(logging.ERROR): + answer = await adapter._handle_callback(_press()) + + assert _shown(answer) == ACTIVITY_FAILED + assert "failed, so the reader is told" in caplog.text diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py index 287bd2cb9..b77f0e094 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py @@ -223,7 +223,13 @@ async def handle(interaction: InboundInteraction) -> None: answer = await adapter._handle_callback(_signed()) - assert answer == {"ephemeral_text": "That request is already answered."} + # Sent as the Mattermost markdown it already is: the notice comes from the + # same renderer as everything else this bridge writes, and Mattermost's + # Slack conversion would be a second pass of markup rules over it. + assert answer == { + "ephemeral_text": "That request is already answered.", + "skip_slack_parsing": True, + } assert _posts(adapter).created == [] diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py index 7c84f6eea..3690c6d61 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -160,6 +160,28 @@ def delete_reaction( return {"status": "OK"} +class _FakeChannels: + """Who is in a channel, as Mattermost answers it. + + A channel the fake has never been told about is not an empty channel: it + answers 404 either way, which is what the server does for a member it + cannot find. + """ + + def __init__(self) -> None: + self.members: dict[str, set[str]] = {} + self.error: Exception | None = None + self.calls: list[tuple[str, str]] = [] + + def get_channel_member(self, channel_id: str, user_id: str) -> dict[str, str]: + self.calls.append((channel_id, user_id)) + if self.error: + raise self.error + if user_id not in self.members.get(channel_id, set()): + raise ResourceNotFound(f"no member {user_id} in channel {channel_id}") + return {"channel_id": channel_id, "user_id": user_id} + + class _FakeClient: """The raw HTTP surface, which is how the typing nudge is sent.""" @@ -200,6 +222,7 @@ class _FakeDriver: def __init__(self, posts: _FakePosts, users: _FakeUsers, owner: str) -> None: self.posts = _DriverPosts(posts, owner) self.users = users + self.channels = _FakeChannels() self.reactions = _FakeReactions() self.client = _FakeClient() From 24e79c797eb9bac6615394a70f4791d45659d765 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 21:42:37 +0100 Subject: [PATCH 093/120] Telegram: fold a finished turn's tool calls into its status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram can post no message that only one reader sees. The reply to a callback query is the one private surface a press can reach and it holds two sentences, so Discord's answer β€” a private copy fetched on demand β€” has nowhere to land here. An expandable blockquote is drawn by the reader's own client instead: the log travels in the status message, collapsed, and opening it reaches Switch in no way at all. Offered on an ended turn only. Editing a message closes a block the reader had opened, which is observed on a real chat rather than reasoned from the API docs, and a running turn's status is edited on every tool call β€” so a log folded into one would be shut in the reader's face by the next. A turn that called no tools is offered nothing to open, which also covers the separate message a failure gets here: it is drawn with no items at all. The log spends what the status left of the message and nothing is taken from the status to make room. Telegram rejects an over-long message outright and an edit cannot be split, so the message limit is already the cap on the log and a smaller number invented here would drop calls there was room to print. What does not fit the log reports itself, and the status above it links the Console, which has all of them. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/telegram/adapter.py | 68 ++++- .../test_telegram_activity_fold.py | 269 ++++++++++++++++++ 2 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index b81ab4bc5..6e07573b3 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -69,6 +69,7 @@ position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + activity_log, render_request, turn_status, ) @@ -111,6 +112,11 @@ # Telegram's own limit on the text of a reply to a press. _MAX_ALERT = 200 +# The block a finished turn's tool calls travel in: Telegram's own expandable +# quote, drawn and opened by the reader's client rather than by the bot. +_FOLD_OPEN = "
" +_FOLD_CLOSE = "
" + # The notice a press is owed, collected while the press is being handled. # # A refusal is raised deep inside the shared inbound path, which knows the @@ -397,11 +403,11 @@ class TelegramAdapter(CollaborationAdapter): #: One message for the whole of a turn's progress. #: - #: Telegram has no collapsed disclosure inside an ordinary message that a - #: second post would buy, and it prices edits per chat rather than per - #: message: a separate log would double the edit rate of every turn and - #: spend the chat's budget on the half nobody is waiting for. The compact - #: status already carries the tool counts. + #: Telegram prices edits per chat rather than per message, so a log posted + #: separately would double the edit rate of every turn and spend the chat's + #: budget on the half nobody is waiting for. It costs the reader nothing: + #: the calls travel in the status message itself once the turn has ended, + #: collapsed into a block their own client draws. separate_activity_log: ClassVar[bool] = False #: A problem somebody has to act on gets its own message. @@ -1225,7 +1231,8 @@ def _draw( error_summary=content.error_summary, tool_detail=False, ) - return Drawn(text=f"{prefix}{body}{tail}", answerable=False) + fold = self._activity_fold(content, limit - len(tail) - len(body)) + return Drawn(text=f"{prefix}{body}{tail}{fold}", answerable=False) # The mention goes on its own line rather than in front of the heading: # a card is a block, and a handle wedged before "Permission needed" # reads as part of the heading. @@ -1243,6 +1250,48 @@ def _draw( ) return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") + def _activity_fold(self, content: TurnActivity, budget: int) -> str: + """A finished turn's tool calls, collapsed under its own status. + + Offered on an ended turn only, and that restriction is observed rather + than assumed: an edit closes a block a reader had opened, so a log + folded into a running turn would be shut in their face by the next tool + call. An ended turn is edited no further, so what a reader opens stays + open. Telegram draws the fold in the reader's client, which is what + makes it worth having at all β€” opening it is one reader's business and + costs the chat neither a message nor an edit. + + Nothing is offered where there is nothing behind it. A turn that called + no tools would open onto "No tool calls." beneath a status line already + saying as much, and the separate message a failure gets here is drawn + with no items at all β€” so one check keeps the same list from appearing + in the chat twice as well. + + Whatever the status left of the message is the whole of the budget. The + fold shares that message rather than taking one of its own, so + Telegram's limit is already the cap and a smaller number invented here + would drop calls there was room to print. What does not fit is reported + by the log itself, and the status above it links the Console, which has + all of them. + """ + if content.turn.status not in TURN_ENDED: + return "" + if not any(item.kind == "tool-activity" for item in content.items): + return "" + log = activity_log( + content.items, + content.turn, + escape=self._rich_escape, + limit=max(0, budget - len(_FOLD_OPEN) - len(_FOLD_CLOSE) - 1), + markup=self.rich_markup(), + elapsed_seconds=content.elapsed_seconds, + session_url=None, + heading=False, + ) + if not log: + return "" + return f"\n{_FOLD_OPEN}{log}{_FOLD_CLOSE}" + def _controls( self, content: RichContent, drawn: Drawn ) -> InlineKeyboardMarkup | None: @@ -1420,9 +1469,10 @@ async def update_rich( took, and where to open it β€” which is what a reader scrolling back wants and what a deletion left them without. It is compact for the same reason it used to be deleted: a Telegram chat or topic is the - conversation itself, so the status is a line and its link rather than a - running commentary on tool calls. An answered request card does come - down, through `remove_publication` and never through a redraw. + conversation itself, so while the turn runs the status is a line and + its link rather than a running commentary on tool calls. The calls + arrive with the last edit, folded away. An answered request card does + come down, through `remove_publication` and never through a redraw. `agent_name` is what the redraw writes back into the body. The name is the message here β€” one bot posts for every agent β€” so an edit that did diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py b/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py new file mode 100644 index 000000000..4845c4256 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py @@ -0,0 +1,269 @@ +"""A Telegram turn's tool calls, collapsed into the status that summarises them. + +Telegram has no message a bot can post to one reader. The only private surface +a press can reach is the reply to a callback query, which is capped at a couple +of sentences, so Discord's answer β€” a private copy fetched on demand β€” has +nowhere to land. `
` is drawn by the reader's own client +instead: the log travels in the status message, collapsed, and opening it +reaches Switch in no way at all. + +The two things that fall out of drawing it that way, and are tested here: + +- the fold is offered on an *ended* turn only. Editing a message closes a block + a reader had opened β€” observed on a real chat, not reasoned from the docs β€” + and a running turn's status is edited on every tool call. +- the log is charged to the same message as the status. Telegram rejects an + over-long message outright and an edit cannot be split, so what a turn's + calls may spend is whatever the status left. +""" + +from __future__ import annotations + +import re +from typing import Any + +from switch_core.bridges.collaboration.adapter import TurnActivity +from switch_core.bridges.collaboration.telegram.chunking import MAX_MESSAGE + +from .test_session_activity import _item, _turn +from .test_telegram_adapter import _adapter, _bot +from .test_telegram_sdk_only import CHANNEL, SESSION_URL, _card, _running + +_FOLD_RE = re.compile(r"
(.*)
", re.DOTALL) + + +def _ended(*titles: str, **kwargs: Any) -> TurnActivity: + """A finished turn that made the named calls, in the order named.""" + items = [ + _item(itemId=f"item-{position}", title=title) + for position, title in enumerate(titles, start=1) + ] + return TurnActivity(items, _turn("completed"), **kwargs) + + +def _fold(text: str) -> list[str]: + """The lines inside the collapsed block, or nothing where there is no block.""" + found = _FOLD_RE.search(text) + return found.group(1).split("\n") if found else [] + + +def _sent(adapter: Any) -> str: + return str(_bot(adapter).messages[0]["text"]) + + +def _last_edit(adapter: Any) -> str: + return str(_bot(adapter).edits[-1]["text"]) + + +# ── When the fold is offered ───────────────────────────────────────────────── + + +async def test_a_finished_turn_carries_its_tool_calls_folded_under_the_status() -> None: + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, "my-agent", _ended("Read a file", "Ran a test"), None + ) + + assert _fold(_sent(adapter)) == ["βœ“ Read a file", "βœ“ Ran a test"] + + +async def test_a_running_turn_is_offered_no_fold_because_the_next_edit_shuts_it() -> ( + None +): + """Telegram closes an expanded block when the message it is in is edited, + and a running turn is edited on every tool call. A log that shut in the + reader's face mid-read would be worse than the status line they had.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", _running(), None) + + assert _fold(_sent(adapter)) == [] + + +async def test_the_fold_arrives_on_the_edit_that_ends_the_turn() -> None: + """The status is posted while the turn runs and rewritten when it stops, so + the edit is the only place the log can appear β€” nothing posts it again.""" + adapter = _adapter() + ref = await adapter.post_rich(CHANNEL, "my-agent", _running(), None) + + await adapter.update_rich(CHANNEL, "my-agent", ref, _ended("Ran a test"), None) + + assert _fold(_last_edit(adapter)) == ["βœ“ Ran a test"] + + +async def test_a_turn_that_called_no_tools_is_offered_nothing_to_open() -> None: + """The block promises something is behind it. "No tool calls." under a + status line already saying so is not what anybody went looking for.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, + "my-agent", + TurnActivity([_item(kind="assistant-message")], _turn("completed")), + None, + ) + + assert _fold(_sent(adapter)) == [] + + +async def test_the_message_a_failure_gets_of_its_own_does_not_repeat_the_log() -> None: + """A problem somebody has to act on is published here as a second message, + because an edit does not notify. It is drawn from no items at all, so the + same check that spares an empty turn keeps one log out of the chat twice.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, + "my-agent", + TurnActivity( + [], _turn("error"), status_only=True, error_summary="The turn failed." + ), + None, + ) + + assert _fold(_sent(adapter)) == [] + + +async def test_a_request_card_is_offered_its_options_rather_than_a_fold() -> None: + """One message asks one thing. Only a turn has a log to fold, and only a + card has options to press.""" + adapter = _adapter() + + await adapter.post_rich(CHANNEL, "my-agent", await _card(), None) + + assert _fold(_sent(adapter)) == [] + + +# ── What is inside it ──────────────────────────────────────────────────────── + + +async def test_the_log_does_not_repeat_the_status_it_is_folded_under() -> None: + """The status sits immediately above and carries the turn's state and its + Console link. Printing both again as the log's first line is the message + saying the same sentence twice, a centimetre apart.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, "my-agent", _ended("Ran a test", session_url=SESSION_URL), None + ) + + text = _sent(adapter) + assert SESSION_URL in text + assert _fold(text) == ["βœ“ Ran a test"] + + +async def test_the_calls_read_oldest_first_so_the_newest_is_where_it_ended() -> None: + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, "my-agent", _ended("First", "Second", "Third"), None + ) + + assert _fold(_sent(adapter)) == ["βœ“ First", "βœ“ Second", "βœ“ Third"] + + +async def test_host_text_in_the_log_cannot_close_the_block_it_is_inside() -> None: + """Every title in there came from a host. Telegram parses the whole message + as HTML and rejects all of it over one stray tag, so a title carrying one + would cost the turn its status as well as its log.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, "my-agent", _ended("Read
everything"), None + ) + + text = _sent(adapter) + assert text.count("") == 1 + assert "</blockquote>" in text + + +async def test_a_log_too_long_for_the_message_is_cut_and_says_how_much() -> None: + """A turn with hundreds of calls must not push the message past what + Telegram accepts, and a log that quietly showed its tail reads as a turn + that made only those calls.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, "my-agent", _ended(*[f"Call {n}" for n in range(400)]), None + ) + + lines = _fold(_sent(adapter)) + assert lines[0].startswith("…") + assert "not shown" in lines[0] + assert lines[-1] == "βœ“ Call 399" + + +async def test_the_notice_that_nobody_was_reached_stays_out_of_the_fold() -> None: + """It is about the message rather than about the turn, and a reader who has + not opened the block is exactly the reader it is for.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, "my-agent", _ended("Ran a test", notify_unreachable=True), None + ) + + text = _sent(adapter) + assert adapter.unnotified_notice() in text + assert _fold(text) == ["βœ“ Ran a test"] + + +# ── What it costs the message ──────────────────────────────────────────────── + + +async def test_a_folded_turn_still_fits_in_one_telegram_message() -> None: + """An edit cannot be split, and `_clamp` cutting this one would take the + closing tag with it and have Telegram reject the whole redraw.""" + adapter = _adapter() + long_call = "Read " + "a very long path " * 20 + + await adapter.post_rich( + CHANNEL, "my-agent", _ended(*[f"{long_call} {n}" for n in range(400)]), None + ) + + assert len(_sent(adapter)) <= MAX_MESSAGE + + +async def test_the_status_is_not_made_smaller_to_make_room_for_the_log() -> None: + """The fold spends what the status left, not the other way round. A turn's + state and its Console link are what a reader gets either way.""" + adapter = _adapter() + await adapter.post_rich( + CHANNEL, "my-agent", _ended("Ran a test", session_url=SESSION_URL), None + ) + short = _sent(adapter).split("\n ( + None +): + """The floor of the arithmetic above, reached only where the status has + taken the whole message. A block a reader opens onto nothing is worse than + the status on its own, which still says how the turn went and links the + Console.""" + adapter = _adapter() + + assert adapter._activity_fold(_ended("Ran a test"), 10) == "" + + +async def test_a_turn_republished_after_a_refusal_still_carries_its_calls() -> None: + """`rich_fallback_text` is what the publisher posts when the drawing itself + was refused. It is the same drawing without the name and the buttons, and a + turn that arrives there having apparently called nothing is a worse record + than no record.""" + adapter = _adapter() + + fallback = adapter.rich_fallback_text(_ended("Ran a test")) + + assert _fold(fallback) == ["βœ“ Ran a test"] From dede7313e64a2f6c777a46b0bc22cfac503481ea Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Wed, 16 Sep 2026 22:28:18 +0100 Subject: [PATCH 094/120] Put what the agent said into the activity disclosures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn's own prose never reached a room at all: `_publish` filtered the item list down to tool calls before anything was drawn, so the four disclosures built for task 16 β€” Discord's private view, Mattermost's ephemeral log, the Teams card fold, Telegram's expandable blockquote β€” held the calls and nothing else. The reply an agent posts is not the same thing; the reasoning beside the work is where a reader finds out why a call happened. The turn now travels whole as far as the adapter, which is the only layer that knows where on its own platform prose can be shown. Said lines are interleaved in session order, marked `Β»` rather than an outcome glyph β€” a sentence has no outcome, and a tick beside one reads as a call that succeeded β€” folded onto one line each and capped at a call's two caps added. Three readers narrow for themselves, because prose is revised on every token: - `_status_state` keys the public status redraw on tool calls only. Counting prose would rewrite that message continuously for a reader who never opened the fold, and on Teams and Telegram an edit closes the fold for the reader who did. - `_log_state` does the same for a separate tool log, which `render_activity` draws from calls alone. - `turn_state`'s unfinished count now asks for tool calls. A streaming assistant item is routinely still in progress when a turn ends, so the unfiltered count would have had every clean turn on all five platforms report phantom unfinished work. The Teams and Telegram folds are now offered to a turn that only talked, and withheld from an item a host has opened and not yet filled. "Show tool calls" and "No tool calls." stop being true once prose is in there, so Teams and Mattermost say "Show activity" / "Hide activity" β€” matching Discord's existing "View activity" β€” and the empty case reads "No activity." Mattermost matches a press on the action id, so relabelling breaks no button already in a channel. Co-Authored-By: Claude Opus 5 --- .../collaboration/mattermost/callback.py | 4 +- .../bridges/collaboration/session/outbound.py | 49 ++++++-- .../session/renderers/__init__.py | 10 +- .../session/renderers/neutral.py | 73 +++++++++--- .../bridges/collaboration/teams/adapter.py | 12 +- .../bridges/collaboration/teams/cards.py | 6 +- .../bridges/collaboration/telegram/adapter.py | 20 ++-- .../test_discord_activity_view.py | 33 +++++- .../test_mattermost_activity_view.py | 28 +++++ .../collaboration/test_session_activity.py | 19 +++ .../test_session_activity_log.py | 109 ++++++++++++++++-- .../test_session_turn_messages.py | 19 +++ .../collaboration/test_teams_activity_fold.py | 49 +++++++- .../test_telegram_activity_fold.py | 50 +++++++- 14 files changed, 418 insertions(+), 63 deletions(-) diff --git a/core/switch_core/bridges/collaboration/mattermost/callback.py b/core/switch_core/bridges/collaboration/mattermost/callback.py index 78fe519c1..66071dbf9 100644 --- a/core/switch_core/bridges/collaboration/mattermost/callback.py +++ b/core/switch_core/bridges/collaboration/mattermost/callback.py @@ -22,12 +22,12 @@ _ANSWER_PURPOSE = "answer" _ACTIVITY_PURPOSE = "activity" -# The button that opens a turn's tool calls, and what it says. One per status +# The button that opens a turn's activity, and what it says. One per status # post, and the same on every status post in a channel: which turn is being # asked about is the post the press arrives on, which the Mattermost server # fills in and a client cannot write. ACTIVITY_ACTION_ID = "switchactivity" -ACTIVITY_LABEL = "Show tool calls" +ACTIVITY_LABEL = "Show activity" # How much of an option a button shows. Not a server limit β€” Mattermost # documents none β€” but a width past which a control stops reading as a control diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index 7d3e9eeaa..e67598768 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -326,6 +326,13 @@ def _status_state( even where the log is a message of its own. The clock counts only where the platform redraws for it; elsewhere the elapsed time goes out with the next change to either. + + What the agent is saying does not count, even where a platform folds it + into this same message. Prose is revised on every token, so counting it + would rewrite the message continuously for a reader who has not opened + the fold β€” and an edit closes a fold for the reader who has. It goes + out with the next call, and with the edit that ends the turn, which is + the one a finished turn's fold is drawn by. """ clock = ( f"{int(elapsed_seconds) if elapsed_seconds is not None else ''}" @@ -333,10 +340,33 @@ def _status_state( else "" ) drawn = f"{clock}/" + ",".join( - f"{item.item_id}:{item.revision}" for item in items + f"{item.item_id}:{item.revision}" + for item in items + if item.kind == "tool-activity" ) return (turn.turn_id, f"{turn.status}:{drawn}", session_url) + def _log_state( + self, items: list[Item], turn: TurnUpsert + ) -> tuple[tuple[str, int], ...]: + """What a separate tool log is already showing. + + A tool log is the tool calls β€” `render_activity` drops everything else + before drawing one β€” so what the agent said is not a change to it and + redrawing for one would spend an edit on a message that would come back + identical. + + No adapter sets `separate_activity_log` today, so nothing observes this + and no test can distinguish it from the version that counts everything. + It is written for the behaviour the flag asks for rather than for the + behaviour nothing currently exercises. + """ + return tuple( + (item.item_id, item.revision) + for item in items + if item.kind == "tool-activity" + ) + ((turn.status, 0),) + async def recorded_commands(self, session_id: str) -> set[str]: return ( await self._journal.recorded_commands(session_id) @@ -732,13 +762,17 @@ async def _publish( `False` on any refusal along the way, so it knows not to treat a refusal as the turn's current state having been shown. + The turn arrives whole β€” what the agent said as well as what it did β€” + and stays that way as far as the adapter, which is the only thing that + knows where on its own platform prose can be shown and where it would + only be the reply said twice. Whatever must not see it narrows for + itself: `_status_state` and `_log_state` below, and every renderer that + draws a list of calls rather than a turn. + Retain anchors until the final summary edit succeeds. A failed final publication retries the same messages instead of posting duplicate history. The journal retains them across process restarts. """ - # The SDK transcript includes internal narration such as "Answered in - # the room". Activity is a tool log; the actual reply is delivered separately. - items = [item for item in items if item.kind == "tool-activity"] # Reuse the receipt message when an accepted command gains an SDK turn ID. key = (session_id, turn.command_id or turn.turn_id) anchor = self._anchors.get(key) @@ -884,8 +918,7 @@ async def _begin( status_state=self._status_state(turn, items, elapsed_seconds, session_url) if self._journal is None else None, - log_state=tuple((item.item_id, item.revision) for item in items) - + ((turn.status, 0),), + log_state=self._log_state(items, turn), ) async def _edit( @@ -944,9 +977,7 @@ async def _draw_log( self, anchor: _Anchor, items: list[Item], turn: TurnUpsert ) -> bool: # Reserve the second reply before requests arrive, even before the first tool. - state = tuple((item.item_id, item.revision) for item in items) + ( - (turn.status, 0), - ) + state = self._log_state(items, turn) if state == anchor.log_state and anchor.log_ref: return True content = TurnActivity(items, turn, tool_log=True) diff --git a/core/switch_core/bridges/collaboration/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index f60a08567..679f1ce71 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -54,6 +54,10 @@ def turn_state( call actually ended β€” so the count is what tells the reader those lines are not still moving. + Calls only. What the agent said arrives as items too, and the last of them + is routinely still marked in progress when the turn stops, so counting + those would have a turn that finished cleanly report unfinished work. + `elapsed_seconds` comes from outside: neither a turn nor an item carries a timestamp, so a caller that tracked one against the session's own event log supplies it. A running turn shows the live duration. A completed turn shows it in @@ -77,7 +81,11 @@ def turn_state( if elapsed_seconds is not None: worked = _worked_for(elapsed_seconds, items, tool_detail=tool_detail) state = worked if turn.status == "completed" else f"{state} {worked}" - unfinished = sum(1 for item in items if item.status == "in-progress") + unfinished = sum( + 1 + for item in items + if item.kind == "tool-activity" and item.status == "in-progress" + ) if not unfinished: return state step = "step" if unfinished == 1 else "steps" diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 7346034a5..63d7dd7fe 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -16,10 +16,10 @@ where the turn got to, how long it has been going, what it is doing now, how the tool calls went, and one link to the Console. One message, edited, never a second one. -- `activity_log` β€” the calls behind that status, one to a line. Not part of the - status and not posted beside it: what a platform draws where it has somewhere - to put a list, which on Discord is a message only the reader who asked for it - can see. +- `activity_log` β€” the calls behind that status and what the agent said while + making them, one to a line. Not part of the status and not posted beside it: + what a platform draws where it has somewhere to put a list, which on Discord + is a message only the reader who asked for it can see. - `request_summary` β€” the text form of a request, in every state it can be in, with the typed-answer grammar the card is asking for spelled out against this particular form. @@ -108,10 +108,17 @@ _LOG_DETAIL = 120 _LOG_UNTITLED = "(untitled)" -_LOG_EMPTY = "No tool calls." -_LOG_EMPTY_YET = "No tool calls yet." +_LOG_EMPTY = "No activity." +_LOG_EMPTY_YET = "No activity yet." _LOG_CUT = "…{left} earlier in this turn, not shown." +# What the agent said, and how much of it. The marker is not one of the outcome +# glyphs because a sentence has no outcome, and a tick beside one would read as +# a call that succeeded. The ceiling is a call's two ceilings added, so neither +# kind of line is the systematically longer one. +_LOG_SAID = "Β»" +_LOG_SAID_TEXT = _LOG_TITLE + _LOG_DETAIL + # What a reader is told, privately and in place of the log, when the press # asking for it cannot be answered. Said rather than left silent: a button that # answers with nothing reads as the platform having dropped the press, and the @@ -354,6 +361,35 @@ def _doing( return lines +def in_activity_log(item: Item) -> bool: + """Whether this item is one of the turn's own lines in the log. + + A call always is. What the agent said is, when there is something it said: + a host opens the item before the first token arrives, and a marker with + nothing after it is a line that tells a reader less than no line at all. + """ + if item.kind == "tool-activity": + return True + return item.kind == "assistant-message" and bool(item.text) + + +def _log_line(item: Item, *, escape: Callable[[str], str]) -> str: + """One item as the single line the log gives it. + + What the agent said is folded onto one line like everything else in here, + its own paragraph breaks removed: a sentence spread over four lines is + indistinguishable from four things having happened. + """ + if item.kind == "assistant-message": + said = _fit(" ".join(item.text.split()), _LOG_SAID_TEXT, escape=escape) + return f"{_LOG_SAID} {said}" + title = _fit(item.title, _LOG_TITLE, escape=escape) if item.title else _LOG_UNTITLED + line = f"{_OUTCOME[item.status]} {title}" + if item.text: + line += f" β€” {_fit(item.text, _LOG_DETAIL, escape=escape)}" + return line + + def activity_log( items: list[Item], turn: TurnUpsert, @@ -365,7 +401,7 @@ def activity_log( session_url: str | None, heading: bool, ) -> str: - """The tool calls behind a turn, one to a line, oldest cut first. + """What a turn did and what it said, one to a line, oldest cut first. `turn_status` is the line that sits beside a running turn and says what it is doing. This is the list behind that line and says what it did. Slack @@ -373,6 +409,14 @@ def activity_log( so a platform drawing it somewhere narrower is deciding where it is read, not what is in it. + The agent's own prose is interleaved in the order the session produced it, + rather than gathered at one end, because the order is the point: a sentence + is usually about the calls either side of it. It is here and not in the + status because it is the half a reader does not need β€” most of what an + agent says beside its work is said again, better, in the reply it posts β€” + and because the reply is what the room gets unprompted. This costs a reader + nothing until they ask for it. + The cut is at the front because the newest end is what a reader came for, and it says how many it took: a log that quietly showed its tail reads as a turn that only made those calls. @@ -397,8 +441,8 @@ def activity_log( if link and len(head) + 3 + len(link) <= limit: head = f"{head} Β· {link}" - did = [item for item in items if item.kind == "tool-activity"] - if not did: + shown = [item for item in items if in_activity_log(item)] + if not shown: nothing = _LOG_EMPTY if turn.status in TURN_ENDED else _LOG_EMPTY_YET return _truncate(nothing if head is None else f"{head}\n{nothing}", limit) @@ -408,15 +452,10 @@ def activity_log( spent = len(head) lines: list[str] = [] omitted = 0 - for position, item in enumerate(reversed(did), start=1): - title = ( - _fit(item.title, _LOG_TITLE, escape=escape) if item.title else _LOG_UNTITLED - ) - line = f"{_OUTCOME[item.status]} {title}" - if item.text: - line += f" β€” {_fit(item.text, _LOG_DETAIL, escape=escape)}" + for position, item in enumerate(reversed(shown), start=1): + line = _log_line(item, escape=escape) if spent + len(line) + 1 > limit: - omitted = len(did) - position + 1 + omitted = len(shown) - position + 1 break lines.append(line) spent += len(line) + 1 diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 2a25881da..fea87d6bb 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -52,6 +52,7 @@ ) from switch_core.bridges.collaboration.session.renderers.neutral import ( activity_log, + in_activity_log, render_request, turn_status, ) @@ -1481,7 +1482,7 @@ def _below(self, content: RichContent, drawn: Drawn) -> list[dict[str, Any]]: return self._controls(content, drawn) def _activity_detail(self, content: TurnActivity) -> list[dict[str, Any]]: - """An ended turn's tool calls, folded away under its status. + """An ended turn's work and its own words, folded away under its status. Offered on an ended turn only, and that restriction is the design rather than a simplification. Every change to a running turn rewrites @@ -1490,9 +1491,10 @@ def _activity_detail(self, content: TurnActivity) -> list[dict[str, Any]]: the next tool call. An ended turn has no further changes to redraw for, so what a reader opens stays open. - A turn that called no tools is offered nothing. The fold is a promise - that there is something behind it, and "No tool calls." under a status - line that already says the same is not something anyone pressed for. + A turn that neither called anything nor said anything is offered + nothing. The fold is a promise that there is something behind it, and + "No activity." under a status line that already says the same is not + something anyone pressed for. The status above carries the Console link, so the log does not repeat it, and it does not repeat the state line either β€” it would sit @@ -1500,7 +1502,7 @@ def _activity_detail(self, content: TurnActivity) -> list[dict[str, Any]]: """ if content.turn.status not in TURN_ENDED: return [] - if not any(item.kind == "tool-activity" for item in content.items): + if not any(in_activity_log(item) for item in content.items): return [] return activity_detail( activity_log( diff --git a/core/switch_core/bridges/collaboration/teams/cards.py b/core/switch_core/bridges/collaboration/teams/cards.py index 2821879b3..e9ecb16c8 100644 --- a/core/switch_core/bridges/collaboration/teams/cards.py +++ b/core/switch_core/bridges/collaboration/teams/cards.py @@ -32,8 +32,8 @@ _DETAIL_ID = "switchActivityDetail" _SHOW_ID = "switchActivityShow" _HIDE_ID = "switchActivityHide" -_SHOW_TITLE = "Show tool calls" -_HIDE_TITLE = "Hide tool calls" +_SHOW_TITLE = "Show activity" +_HIDE_TITLE = "Hide activity" # How much of an option's label a button shows. Not a documented Teams limit β€” # it is where a row of buttons stops being readable. Safe to impose because the @@ -168,7 +168,7 @@ def _action_title(control: Control) -> str: def activity_detail(log: str) -> list[dict[str, Any]]: - """A turn's tool calls, folded away under its status with a button to open. + """A turn's activity, folded away under its status with a button to open. `Action.ToggleVisibility` is drawn entirely by the reader's own client: nothing reaches Switch when it is pressed, so opening the log is local to diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 6e07573b3..d776d439e 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -70,6 +70,7 @@ ) from switch_core.bridges.collaboration.session.renderers.neutral import ( activity_log, + in_activity_log, render_request, turn_status, ) @@ -1251,7 +1252,7 @@ def _draw( return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") def _activity_fold(self, content: TurnActivity, budget: int) -> str: - """A finished turn's tool calls, collapsed under its own status. + """A finished turn's work and its own words, collapsed under its status. Offered on an ended turn only, and that restriction is observed rather than assumed: an edit closes a block a reader had opened, so a log @@ -1261,11 +1262,11 @@ def _activity_fold(self, content: TurnActivity, budget: int) -> str: makes it worth having at all β€” opening it is one reader's business and costs the chat neither a message nor an edit. - Nothing is offered where there is nothing behind it. A turn that called - no tools would open onto "No tool calls." beneath a status line already - saying as much, and the separate message a failure gets here is drawn - with no items at all β€” so one check keeps the same list from appearing - in the chat twice as well. + Nothing is offered where there is nothing behind it. A turn that neither + called anything nor said anything would open onto "No activity." beneath + a status line already saying as much, and the separate message a + failure gets here is drawn with no items at all β€” so one check keeps the + same list from appearing in the chat twice as well. Whatever the status left of the message is the whole of the budget. The fold shares that message rather than taking one of its own, so @@ -1276,7 +1277,7 @@ def _activity_fold(self, content: TurnActivity, budget: int) -> str: """ if content.turn.status not in TURN_ENDED: return "" - if not any(item.kind == "tool-activity" for item in content.items): + if not any(in_activity_log(item) for item in content.items): return "" log = activity_log( content.items, @@ -1470,8 +1471,9 @@ async def update_rich( wants and what a deletion left them without. It is compact for the same reason it used to be deleted: a Telegram chat or topic is the conversation itself, so while the turn runs the status is a line and - its link rather than a running commentary on tool calls. The calls - arrive with the last edit, folded away. An answered request card does + its link rather than a running commentary on tool calls. The calls, + and what the agent said while making them, arrive with the last edit, + folded away. An answered request card does come down, through `remove_publication` and never through a redraw. `agent_name` is what the redraw writes back into the body. The name is diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py index eef12969e..06ee10d61 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py @@ -749,6 +749,37 @@ async def test_a_long_log_says_how_much_of_itself_it_is_not_showing() -> None: assert len(text) <= 2000 +async def test_the_view_carries_what_the_agent_said_as_well_as_what_it_did() -> None: + """Prose the agent produced beside its work never reached this channel at + all β€” the reply is posted on its own and the rest stayed in the session. It + comes back here, to one reader, in the order the turn produced it.""" + adapter, channel = _guild_with({READER_ID}) + _resolving( + adapter, + _snapshot( + items=[ + _item(itemId="a", title="Ran the tests", status="completed"), + _item( + itemId="b", + kind="assistant-message", + title="", + text="Both write to the same fixture user.", + status="completed", + ), + ] + ), + ) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + lines = _shown(press).splitlines() + assert lines[1:3] == [ + "\u2713 Ran the tests", + "\u00bb Both write to the same fixture user.", + ] + + async def test_a_turn_with_no_tool_calls_says_that_too() -> None: adapter, channel = _guild_with({READER_ID}) _resolving(adapter, _snapshot(items=[])) @@ -756,7 +787,7 @@ async def test_a_turn_with_no_tool_calls_says_that_too() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] - assert "No tool calls." in _shown(press) + assert "No activity." in _shown(press) async def test_a_turn_with_no_console_link_offers_only_refresh() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py index a06525746..7f2ed617d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py @@ -353,6 +353,34 @@ async def test_the_log_is_the_calls_oldest_first_under_the_state_line() -> None: assert lines[1:3] == ["βœ“ Read config.toml", "βœ“ Ran the tests β€” 42 passed"] +async def test_the_log_carries_what_the_agent_said_as_well_as_what_it_did() -> None: + """Prose the agent produced beside its work never reached this channel at + all β€” the reply is posted on its own and the rest stayed in the session. It + comes back here, privately, in the order the turn produced it.""" + adapter, _ = _viewer() + _resolving( + adapter, + _snapshot( + items=[ + _item(itemId="a", kind="tool-activity", title="Ran the tests"), + _item( + itemId="b", + kind="assistant-message", + title="", + text="Both write to the same fixture user.", + ), + ] + ), + ) + + lines = _shown(await adapter._handle_callback(_press())).splitlines() + + assert lines[1:3] == [ + "\u2713 Ran the tests", + "\u00bb Both write to the same fixture user.", + ] + + async def test_the_state_line_carries_the_way_into_console() -> None: """The status post has the link too, but this reply is read on its own β€” an ephemeral message has no message above it.""" diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 9ec674d3b..36e16cce1 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -326,6 +326,25 @@ async def test_a_running_turn_is_not_told_off_for_work_still_in_flight() -> None assert _state(items, _turn("running")) == "Working…" +async def test_a_sentence_still_being_written_is_not_an_unfinished_step() -> None: + """A host opens the item for what the agent is saying and revises it as the + tokens arrive, and the last of them is routinely still open when the turn + stops. Counting those would have a turn that finished cleanly report work + it never left undone β€” on every platform, since this line is shared.""" + items = [ + _item(itemId="i1", status="completed", title="Ran the tests"), + _item( + itemId="i2", + kind="assistant-message", + status="in-progress", + title="", + text="All green.", + ), + ] + + assert _state(items, _turn("completed")) == "Turn complete." + + async def test_more_than_one_unfinished_step_is_counted_as_more_than_one() -> None: items = [ _item(itemId=f"i{n}", status="in-progress", title=f"Step {n}") for n in range(3) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py index d301d976a..bfef7394b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py @@ -1,4 +1,4 @@ -"""The per-call log behind a turn's status, for a platform with room for one. +"""The log behind a turn's status, for a platform with room for one. `test_session_turn_status.py` covers the line that sits beside a running turn and says what it is doing. This is the list behind that line and says what it @@ -6,6 +6,13 @@ own, so a platform drawing it somewhere narrower is deciding where it is read, not what is in it. +It also carries what the agent said while it worked, which no conversation ever +sees: the reply reaches the room on its own, and this is the prose around it +that used to exist only in the session. Interleaved with the calls in the order +the turn produced them, because a sentence is usually about the call either +side of it, and behind a disclosure because much of it is the agent narrating +itself rather than telling anyone anything. + Two things it has to get right. It has to fit: the caller gives it one budget for the whole message and it cannot spend more. And when it does not fit it has to say so, and say how much β€” a log that quietly showed its tail reads as a @@ -34,6 +41,11 @@ def _call( return _item(kind="tool-activity", status=status, title=title, text=text) +def _said(text: str, status: str = "completed") -> Item: + """Something the agent said: no title, and everything in the text.""" + return _item(kind="assistant-message", status=status, title="", text=text) + + def _log( items: list[Item], turn_state: str = "completed", @@ -89,26 +101,107 @@ def test_a_call_with_no_name_is_shown_rather_than_dropped() -> None: assert lines[1] == "βœ“ (untitled)" -def test_only_tool_calls_are_in_the_tool_log() -> None: - """What the agent said belongs to the conversation, not to this.""" +# ── What the agent said, beside what it did ────────────────────────────────── + + +def test_what_the_agent_said_sits_where_it_was_said() -> None: + """The order is the meaning. A sentence collected at one end of the log is + a sentence a reader has to guess the subject of.""" items = [ - _item(kind="assistant-message", title="", text="Looking now."), - _call(title="Searched"), + _call(title="Read the adapter"), + _said("That test shares a fixture user with the session test."), + _call(title="Ran the tests"), ] lines = _log(items) + assert lines[1:] == [ + "βœ“ Read the adapter", + "Β» That test shares a fixture user with the session test.", + "βœ“ Ran the tests", + ] + + +def test_a_sentence_is_not_marked_as_a_call_that_succeeded() -> None: + """Prose has no outcome. Reusing the tick would have every remark read as + something the agent did and got right.""" + lines = _log([_said("Looking now."), _call(title="Searched")]) + + assert lines[1].startswith("Β»") + assert "βœ“" not in lines[1] + + +def test_a_paragraph_is_folded_onto_the_one_line_it_is_given() -> None: + """Every other entry here is one line. A remark spread over four of them is + indistinguishable from four things having happened.""" + lines = _log([_said("First thought.\n\nSecond thought.\nThird.")]) + + assert lines[1:] == ["Β» First thought. Second thought. Third."] + + +def test_an_item_the_host_has_opened_and_not_filled_is_not_a_line() -> None: + """A host creates the item before the first token arrives. A marker with + nothing after it says the agent said something and withholds it.""" + lines = _log([_said(""), _call(title="Searched")]) + assert lines[1:] == ["βœ“ Searched"] +def test_a_turn_that_only_talked_has_a_log_rather_than_nothing() -> None: + """A turn that answers from what it already knows calls nothing, and used + to leave a reader who asked what happened with "No activity.".""" + lines = _log([_said("Yes β€” it was fixed in the merge yesterday.")]) + + assert lines[1:] == ["Β» Yes β€” it was fixed in the merge yesterday."] + + +def test_a_long_remark_is_cut_like_a_call_is_rather_than_spending_the_log() -> None: + """One remark is allowed a call's two ceilings and no more. A turn that + thought aloud at length must not push its own calls out of the log.""" + lines = _log([_said("word " * 400), _call(title="Searched")]) + + assert lines[1].endswith("…") + assert len(lines[1]) <= 2 + 200 + 120 + assert lines[2] == "βœ“ Searched" + + +def test_a_remark_is_escaped_the_way_a_call_name_is() -> None: + """It is host text like everything else in here β€” further from Switch than + a tool name, if anything, since a model wrote it.""" + lines = activity_log( + [_said("not bold")], + _turn("completed"), + escape=lambda text: text.replace("<", "<"), + limit=10_000, + markup=MARKDOWN, + elapsed_seconds=None, + session_url=None, + heading=False, + ).splitlines() + + assert lines == ["Β» <b>not bold</b>"] + + +def test_the_cut_counts_what_was_said_as_well_as_what_was_done() -> None: + """The note is the reader's only measure of what they are not seeing, and + one that counted calls alone would understate it.""" + items = [_said(f"Thought {index}.") for index in range(20)] + + lines = _log(items, limit=120) + + assert lines[1].startswith("…") + assert "not shown" in lines[1] + assert lines[-1] == "Β» Thought 19." + + def test_a_turn_that_has_ended_with_no_calls_says_it_made_none() -> None: - assert _log([], "completed")[1] == "No tool calls." + assert _log([], "completed")[1] == "No activity." def test_a_turn_still_running_says_it_has_made_none_yet() -> None: """The difference matters: one is a finding about the turn, the other is a report about right now.""" - assert _log([], "running")[1] == "No tool calls yet." + assert _log([], "running")[1] == "No activity yet." # ── What it does when it will not fit ──────────────────────────────────────── @@ -188,7 +281,7 @@ def test_declining_the_heading_gives_its_room_back_to_the_calls() -> None: def test_a_headless_log_with_no_calls_is_still_the_sentence_saying_so() -> None: """An empty log is not an empty string. A fold opening onto nothing reads as a card that failed to draw.""" - assert _log([], "completed", heading=False) == ["No tool calls."] + assert _log([], "completed", heading=False) == ["No activity."] def test_a_link_handed_to_a_headless_log_is_refused_rather_than_dropped() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py index cd2034d5d..91f1f0a14 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py @@ -137,6 +137,25 @@ async def test_a_turn_is_posted_once_and_edited_every_time_after() -> None: assert [call["ts"] for call in client.updated] == ["1.0"] +async def test_a_sentence_being_written_is_not_on_its_own_a_reason_to_redraw() -> None: + """What the agent says reaches the drawing whole now, and a host revises it + on every token. If that counted as a change, a turn would rewrite its + status message continuously while the agent talked β€” which costs a chat its + edit budget, and, on the platforms that fold the log into that same + message, snaps it shut in the face of whoever had it open. It goes out with + the next call, and with the edit that ends the turn.""" + client = FakeWebClient() + activity = SessionTurnActivity(_adapter(client)) + said = _item() + revised = said.model_copy(update={"revision": 2, "text": "Working on it still"}) + + await _publish(activity, [said], _turn("running")) + await _publish(activity, [revised], _turn("running")) + + assert len(client.posted) == 1 + assert client.updated == [] + + async def test_the_last_edit_is_the_state_the_turn_ended_in() -> None: """What the channel is left with once the session has stopped talking. diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py b/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py index 2220ba1d3..b96162518 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py @@ -98,9 +98,13 @@ def test_a_running_turn_is_offered_no_fold_because_the_next_call_would_shut_it() assert _fold(connector) == {} -def test_a_turn_that_called_no_tools_is_offered_nothing_to_open() -> None: - """The fold promises something is behind it. "No tool calls." under a - status line already saying so is not what anybody pressed for.""" +def test_a_turn_with_nothing_behind_it_is_offered_nothing_to_open() -> None: + """The fold promises something is behind it. "No activity." under a status + line already saying so is not what anybody pressed for. + + An item a host has opened and not yet filled is nothing behind it: the + agent has not said anything yet, it has been given somewhere to say it. + """ adapter, connector = _teams() _run( @@ -127,6 +131,41 @@ def test_the_fold_arrives_on_the_redraw_that_ends_the_turn() -> None: assert _log_lines(edited) == ["βœ“ Ran a test"] +def test_what_the_agent_said_is_in_the_fold_and_not_on_the_card() -> None: + """The prose an agent produces beside its work never reached a Teams + channel at all. It arrives folded rather than in the status, because the + reply is what the channel gets unprompted and much of the rest is the agent + narrating itself.""" + adapter, connector = _teams() + items = [ + _item(itemId="item-1", title="Ran the tests"), + _item(itemId="item-2", kind="assistant-message", title="", text="All green."), + ] + + _run( + adapter.post_rich(CHANNEL, AGENT, TurnActivity(items, _turn("completed")), ROOT) + ) + + card = _posted(connector) + assert _log_lines(card) == ["\u2713 Ran the tests", "\u00bb All green."] + assert "All green." not in card["fallbackText"] + + +def test_a_turn_that_only_talked_is_still_worth_a_fold() -> None: + """It used to be offered nothing, because it called nothing. Now the thing + it produced is the thing behind the fold.""" + adapter, connector = _teams() + said = _item(kind="assistant-message", title="", text="Fixed yesterday.") + + _run( + adapter.post_rich( + CHANNEL, AGENT, TurnActivity([said], _turn("completed")), ROOT + ) + ) + + assert _log_lines(_posted(connector)) == ["\u00bb Fixed yesterday."] + + # ── What opening it does ───────────────────────────────────────────────────── @@ -152,8 +191,8 @@ def test_opening_and_closing_are_exact_opposites_of_each_other() -> None: fold = _fold(connector) show = fold[SHOW_ID]["actions"][0] hide = fold[HIDE_ID]["actions"][0] - assert show["title"] == "Show tool calls" - assert hide["title"] == "Hide tool calls" + assert show["title"] == "Show activity" + assert hide["title"] == "Hide activity" assert show["targetElements"] == [ {"elementId": SHOW_ID, "isVisible": False}, {"elementId": DETAIL_ID, "isVisible": True}, diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py b/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py index 4845c4256..c49200481 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py @@ -92,9 +92,13 @@ async def test_the_fold_arrives_on_the_edit_that_ends_the_turn() -> None: assert _fold(_last_edit(adapter)) == ["βœ“ Ran a test"] -async def test_a_turn_that_called_no_tools_is_offered_nothing_to_open() -> None: - """The block promises something is behind it. "No tool calls." under a - status line already saying so is not what anybody went looking for.""" +async def test_a_turn_with_nothing_behind_it_is_offered_nothing_to_open() -> None: + """The block promises something is behind it. "No activity." under a status + line already saying so is not what anybody went looking for. + + An item a host has opened and not yet filled is nothing behind it: the + agent has not said anything yet, it has been given somewhere to say it. + """ adapter = _adapter() await adapter.post_rich( @@ -208,6 +212,46 @@ async def test_the_notice_that_nobody_was_reached_stays_out_of_the_fold() -> Non assert _fold(text) == ["βœ“ Ran a test"] +async def test_what_the_agent_said_is_in_the_fold_and_not_in_the_chat() -> None: + """The prose an agent produces beside its work never reached this chat at + all. It arrives folded rather than in the status line, because the reply is + what the chat gets unprompted and much of the rest is the agent narrating + itself.""" + adapter = _adapter() + said = _item(itemId="item-2", kind="assistant-message", title="", text="All green.") + + await adapter.post_rich( + CHANNEL, + "my-agent", + TurnActivity( + [_item(itemId="item-1", title="Ran the tests"), said], _turn("completed") + ), + None, + ) + + text = _sent(adapter) + assert _fold(text) == ["βœ“ Ran the tests", "Β» All green."] + assert "All green." not in text.split("\n None: + """It used to be offered nothing, because it called nothing. Now the thing + it produced is the thing behind the block.""" + adapter = _adapter() + + await adapter.post_rich( + CHANNEL, + "my-agent", + TurnActivity( + [_item(kind="assistant-message", title="", text="Fixed yesterday.")], + _turn("completed"), + ), + None, + ) + + assert _fold(_sent(adapter)) == ["Β» Fixed yesterday."] + + # ── What it costs the message ──────────────────────────────────────────────── From 45b4b1e475197a8ccc552339039e5f338b54fab8 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 00:15:59 +0100 Subject: [PATCH 095/120] Put what the agent said into Slack's activity plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack was left out when the other four platforms gained the agent's own prose, on the strength of a docstring saying its words already reach the room as messages in their own right. That is not true and never was: outside the renderers nothing in the backend treats `assistant-message` specially, and an agent's console narration reaches a room only when the agent posts it. So Slack had the same gap the change was written to close, and the comment asserting otherwise was what hid it. The plan block is the only part of a Slack turn message a reader opens rather than is shown, so it is the only place prose can go without putting it in the channel. Both live paths β€” the streamed message and the ordinary post used where no stream can be opened β€” now draw the turn through `in_activity_log`, the same predicate the other four use. A remark is a card like a call, marked with the marker now shared from `neutral` so there is still one line to change. Slack has three card states against the contract's four, and none of them means "not a step", so a remark is settled and its check is the cost: spinner and error both say something worse. `_settled` leaves prose alone, since a prose item is routinely still in progress when a turn ends and "Unfinished" belongs to a call that never came back. `_running` now indexes the list as drawn rather than the calls within it. The index chooses which page carries the live-step label, pages are cut from the drawn list, and counting calls alone named the right step while pointing at a page the reader can see does not contain it. Only a call can be that step: a half-written sentence is not work in progress. Page headings said "Steps 1–50" and the collapsed header counted "earlier steps". Half of what a page holds is no longer a step, so they read "Activity" and "earlier lines". The notification text is unchanged and carries no prose β€” that is what a phone shows without being asked, and it now has a test of its own rather than resting on the filter that used to make it true by accident. Co-Authored-By: Claude Opus 5 --- .../session/renderers/neutral.py | 4 +- .../collaboration/session/renderers/slack.py | 102 ++++++++++++------ .../collaboration/test_session_activity.py | 86 +++++++++++++++ .../test_session_card_posting.py | 9 +- .../test_session_slack_streaming.py | 34 +++--- .../test_session_turn_messages.py | 14 ++- 6 files changed, 196 insertions(+), 53 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 63d7dd7fe..f19d4da9a 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -116,7 +116,7 @@ # glyphs because a sentence has no outcome, and a tick beside one would read as # a call that succeeded. The ceiling is a call's two ceilings added, so neither # kind of line is the systematically longer one. -_LOG_SAID = "Β»" +SAID_MARKER = "Β»" _LOG_SAID_TEXT = _LOG_TITLE + _LOG_DETAIL # What a reader is told, privately and in place of the log, when the press @@ -382,7 +382,7 @@ def _log_line(item: Item, *, escape: Callable[[str], str]) -> str: """ if item.kind == "assistant-message": said = _fit(" ".join(item.text.split()), _LOG_SAID_TEXT, escape=escape) - return f"{_LOG_SAID} {said}" + return f"{SAID_MARKER} {said}" title = _fit(item.title, _LOG_TITLE, escape=escape) if item.title else _LOG_UNTITLED line = f"{_OUTCOME[item.status]} {title}" if item.text: diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 1af7df2da..8f1277ad2 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -59,6 +59,7 @@ turn_state, unanswerable, ) +from .neutral import SAID_MARKER, in_activity_log # Slack's own limits. Exceeding one is rejected at the API, so it is caught here # where the offending value can still be named. @@ -1137,18 +1138,22 @@ def render_activity_plan( be able to tell which transport carried their turn β€” so both take their header from `_activity_title` and settle their cards the same way. - This is deliberately the plan alone. The agent's own words are posted to - the room as messages in their own right, and repeating them inside the - activity block would say everything twice. - - A turn that has run no tools yet has no plan to show, so it falls back to - the spinning card the status line used to be β€” this one message stands in - for both of the two it replaced. + What the agent said goes in here too, interleaved with the calls in the + order the session produced it. The plan is the only part of this message a + reader opens rather than is shown, so it is the only place prose can go + without putting it in the channel: nothing else here is collapsed. Prose + reaches a Slack channel no other way β€” an agent's console narration is not + posted to the room unless the agent posts it β€” so without this the turn's + reasoning is simply not available to a reader who wants it. + + A turn that has neither called nor said anything has no plan to show, so it + falls back to the spinning card the status line used to be β€” this one + message stands in for both of the two it replaced. """ - did = [item for item in items if item.kind == "tool-activity"] - kept = did[len(did) - _MAX_PLAN_TASKS :] + shown = [item for item in items if in_activity_log(item)] + kept = shown[len(shown) - _MAX_PLAN_TASKS :] title = _activity_title( - items, turn, elapsed_seconds=elapsed_seconds, omitted=len(did) - len(kept) + items, turn, elapsed_seconds=elapsed_seconds, omitted=len(shown) - len(kept) ) blocks: list[dict[str, Any]] = [] if kept: @@ -1216,7 +1221,7 @@ def render_activity_stream( section is the useful half: it is the one a reader wants to open, and the glyph beside it is already the thing that says work is happening there. """ - did = [item for item in items if item.kind == "tool-activity"] + shown = [item for item in items if in_activity_log(item)] return StreamedActivity( title=_truncate( turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds), @@ -1224,8 +1229,8 @@ def render_activity_stream( ), session=_session_card(session_url, turn), blocks=_step_blocks( - [_settled(_plan_task(item), item, turn) for item in did], - _running(did, turn), + [_settled(_plan_task(item), item, turn) for item in shown], + _running(shown, turn), ), ) @@ -1299,7 +1304,7 @@ def _step_blocks( "type": "context", "block_id": top, "elements": [ - {"type": "mrkdwn", "text": f"_Steps 1–{gone} no longer shown_"} + {"type": "mrkdwn", "text": f"_Activity 1–{gone} no longer shown_"} ], }, _step_page(steps, last - 1, middle, running), @@ -1313,7 +1318,11 @@ def _step_page( block_id: str, running: tuple[int, str] | None, ) -> dict[str, Any]: - """One fifty-step page as the plan block that draws it. + """One fifty-card page as the plan block that draws it. + + A page holds the turn as it happened, so a card in it is a call or a thing + the agent said. It is headed "Activity" rather than "Steps" because half of + what can be in there is not a step. The page holding the live step names it, so the heading a reader is drawn to is the one where something is happening. Only that page: the same @@ -1321,7 +1330,7 @@ def _step_page( """ start = page * _MAX_PLAN_TASKS shown = steps[start : start + _MAX_PLAN_TASKS] - title = f"Steps {start + 1}–{start + len(shown)}" + title = f"Activity {start + 1}–{start + len(shown)}" if running and start <= running[0] < start + len(shown): title += f" Β· {running[1]}" return { @@ -1340,7 +1349,14 @@ def _settled(task: dict[str, Any], item: Item, turn: TurnUpsert) -> dict[str, An whole plan a failed plan. A call still open when the turn stopped will never close, so the title says so rather than leaving a step spinning for good. + + None of that is about what the agent said. A host opens a prose item and + fills it token by token, so it is routinely still in progress when the turn + stops β€” marking that "Unfinished" would put the word on the one line of the + turn where nothing was left undone. """ + if item.kind == "assistant-message": + return task if item.status != "in-progress" or turn.status in TURN_ENDED: task["status"] = "complete" if item.status == "in-progress" and turn.status in TURN_ENDED: @@ -1369,38 +1385,43 @@ def _activity_title( nobody can see. """ title = turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds) - running = _running([i for i in items if i.kind == "tool-activity"], turn) + running = _running(items, turn) if running: title += f" Β· {running[1]}" if omitted: - step = "step" if omitted == 1 else "steps" - title += f" Β· {omitted} earlier {step} not shown" + line = "line" if omitted == 1 else "lines" + title += f" Β· {omitted} earlier {line} not shown" return title -def _running(did: list[Item], turn: TurnUpsert) -> tuple[int, str] | None: +def _running(drawn: list[Item], turn: TurnUpsert) -> tuple[int, str] | None: """Where the live step is and what to call it, or nothing for an ended turn. - The index is what lets a streamed turn put the label on the page the step - is actually in, rather than on whichever page happens to be last. + The index is into the list as drawn, remarks and all, because it is what + puts the label on the page the step is actually in and a page holds + whatever was interleaved with it. Counting calls alone would name the right + step and point at the wrong page. + + Only a call can be the live step. A sentence being written is not work in + progress, and a heading naming a half-finished one would report a turn busy + talking when it is busy working. A turn with nothing open still names the step it finished most recently: between two calls there is nothing running, and a heading that went blank for that moment would flicker on every step. """ - if not did or turn.status in TURN_ENDED: + if turn.status in TURN_ENDED: + return None + calls = [index for index, item in enumerate(drawn) if item.kind == "tool-activity"] + if not calls: return None current = next( - ( - index - for index in reversed(range(len(did))) - if did[index].status == "in-progress" - ), + (index for index in reversed(calls) if drawn[index].status == "in-progress"), None, ) - index = len(did) - 1 if current is None else current + index = calls[-1] if current is None else current label = "Last" if current is None else "Running" - title = plain_text(did[index].title) if did[index].title else "Tool" + title = plain_text(drawn[index].title) if drawn[index].title else "Tool" return index, f"{label}: {title}" @@ -1456,7 +1477,28 @@ def _plan_task(item: Item) -> dict[str, Any]: by the same glyph the text fallback uses. `failed` is marked too, because a collapsed plan shows its cards without a status anywhere a reader can see at a glance. + + What the agent said is a card as well, because a card is the only thing + this block holds. It is marked `SAID_MARKER` for the same reason it is + everywhere else, and it is settled rather than running: a sentence has no + outcome to report and nothing about it is still being worked on once the + turn has moved past it. + + Slack draws a settled card with a check, which beside a sentence is the one + thing the marker exists to deny. There is no fourth status to reach for β€” + Slack has three and the other two are a spinner and an error, both of which + say something worse β€” so the marker carries the distinction alone here, + where on every other platform the glyph column carries it. """ + if item.kind == "assistant-message": + return { + "task_id": _task_id(item.item_id), + "title": _truncate( + f"{SAID_MARKER} {plain_text(' '.join(item.text.split()))}", + _MAX_PLAN_TASK_TITLE, + ), + "status": "complete", + } title = plain_text(item.title) if item.title else "" if item.status in ("failed", "declined"): title = f"{_ACTIVITY[item.status]} {title}".strip() diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 36e16cce1..073438665 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -25,6 +25,7 @@ from switch_core.bridges.collaboration.session.renderers.slack import ( render_activity, render_activity_plan, + render_activity_stream, render_activity_text, render_request, render_turn_with_request, @@ -790,3 +791,88 @@ async def test_a_turn_with_nothing_to_plan_yet_still_shows_it_is_working() -> No assert running.blocks[0]["type"] == "task_card" assert running.blocks[0]["status"] == "in_progress" assert ended.blocks[0]["type"] == "context" + + +# ── What the agent said, in the one block a reader opens ───────────────────── + + +def _said(text: str, *, item_id: str = "said", status: str = "completed") -> Item: + """A remark: no title, everything in the text, the way a host sends one.""" + return _item( + itemId=item_id, kind="assistant-message", title="", text=text, status=status + ) + + +async def test_what_the_agent_said_is_a_card_in_the_plan_marked_as_speech() -> None: + """Slack's plan is the only part of the message a reader opens rather than + is shown, so it is the only place prose can go without putting it in the + channel. The marker is what keeps it from reading as another call.""" + drawn = render_activity_plan( + [_item(itemId="call", title="Ran the tests"), _said("All green.")], + _turn("completed"), + ) + + titles = [task["title"] for task in drawn.blocks[0]["tasks"]] + assert titles == ["Ran the tests", "Β» All green."] + + +async def test_a_sentence_is_never_marked_unfinished_when_the_turn_stops() -> None: + """A host fills a prose item token by token, so the last one is routinely + still open when the turn ends. "Unfinished" belongs to a call that never + came back, not to the sentence the agent had just finished saying.""" + drawn = render_activity_plan( + [_said("Handing it over.", status="in-progress")], _turn("completed") + ) + + card = drawn.blocks[0]["tasks"][0] + assert card["title"] == "Β» Handing it over." + assert "Unfinished" not in card["title"] + + +async def test_a_turn_that_only_talked_has_a_plan_rather_than_a_bare_line() -> None: + """It called nothing, so it used to have no disclosure at all β€” and the + thing it produced was exactly what a reader would have opened it for.""" + drawn = render_activity_plan([_said("Fixed that yesterday.")], _turn("completed")) + + assert drawn.blocks[0]["type"] == "plan" + assert drawn.blocks[0]["tasks"][0]["title"] == "Β» Fixed that yesterday." + + +async def test_a_paragraph_is_folded_onto_the_one_line_its_card_gives_it() -> None: + """A card title is one line. Prose arrives with its own breaks in it, and + four lines of sentence in a list of calls reads as four things happening.""" + drawn = render_activity_plan( + [_said("First thought.\n\nSecond thought.")], _turn("completed") + ) + + assert drawn.blocks[0]["tasks"][0]["title"] == "Β» First thought. Second thought." + + +async def test_the_header_still_counts_calls_rather_than_everything_drawn() -> None: + """The collapsed header is the whole message for most readers. Counting + remarks in "N tool calls" would inflate every turn the agent talked in.""" + drawn = render_activity_plan( + [_item(itemId="call", title="Ran the tests"), _said("All green.")], + _turn("completed"), + elapsed_seconds=5, + ) + + assert "1 tool call" in drawn.blocks[0]["title"] + + +async def test_the_live_step_label_lands_on_the_page_the_call_is_actually_on() -> None: + """The label is placed by an index, and the pages are cut from the list as + drawn. Counting only calls gives the right step and the wrong index, which + puts "Running:" on a page the reader can see does not contain it.""" + talked = [_said(f"Thinking {n}.", item_id=f"said-{n}") for n in range(10)] + calls = [ + _item(itemId=f"call-{n}", title=f"Tool {n}", status="completed") + for n in range(44) + ] + live = _item(itemId="live", title="Grep", status="in-progress") + + drawn = render_activity_stream([*talked, *calls, live], _turn("running")) + + pages = [block for block in drawn.blocks if block["type"] == "plan"] + assert "Running: Grep" not in pages[0]["title"] + assert pages[1]["title"].endswith("Β· Running: Grep") diff --git a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py index b8c5576bf..a175fe07b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py @@ -577,7 +577,12 @@ async def test_a_redraw_slack_refused_leaves_the_row_on_what_is_on_screen( async def test_the_trigger_posts_the_recorded_turn_and_then_its_card( session_factory: async_sessionmaker[AsyncSession], ) -> None: - """The work first, then the question, which is the order they happened in.""" + """The work first, then the question, which is the order they happened in. + + The agent's reasoning rides along inside the plan, which is where the demo + wants it: the point of the demo is a channel seeing a turn the way a reader + will, and a reader who opens the plan gets the thinking behind the calls. + """ client = FakeWebClient() demo, room_id = await _demo(session_factory, client) @@ -586,7 +591,7 @@ async def test_the_trigger_posts_the_recorded_turn_and_then_its_card( assert len(client.posted) == 2 turn, card = (json.dumps(post["blocks"]) for post in client.posted) assert "Working" in turn - assert "same fixture user" not in turn + assert "same fixture user" in turn assert "Ran tests/auth/test_login.py" in turn assert "Edit tests/auth/conftest.py?" in card assert [post.get("thread_ts") for post in client.posted] == [None, None] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py index abe0c79ff..c164d8dcf 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -203,7 +203,7 @@ async def test_only_the_header_and_the_pages_that_moved_are_appended() -> None: later = _chunks(client)[1] assert [c["type"] for c in later] == ["plan_update", "blocks"] assert later[0]["title"] == "Working… 9s" - assert later[1]["blocks"][0]["title"] == "Steps 1–2 Β· Running: Grep" + assert later[1]["blocks"][0]["title"] == "Activity 1–2 Β· Running: Grep" assert [(t["title"], t["status"]) for t in later[1]["blocks"][0]["tasks"]] == [ ("Read", "complete"), ("Grep", "in_progress"), @@ -242,7 +242,7 @@ async def test_a_page_that_did_not_move_is_not_sent_again() -> None: later = [c for c in _chunks(client)[1] if c["type"] == "blocks"] assert len(later) == 1 - assert later[0]["blocks"][0]["title"] == "Steps 51–52 Β· Last: Tool 51" + assert later[0]["blocks"][0]["title"] == "Activity 51–52 Β· Last: Tool 51" async def test_a_publish_that_changed_nothing_appends_nothing() -> None: @@ -488,10 +488,10 @@ async def test_a_turn_of_any_length_draws_the_same_three_step_blocks() -> None: ) gone, older, newer = _drawn(client).values() - assert gone["elements"][0]["text"] == "_Steps 1–150 no longer shown_" - assert older["title"] == "Steps 151–200" + assert gone["elements"][0]["text"] == "_Activity 1–150 no longer shown_" + assert older["title"] == "Activity 151–200" assert [task["title"] for task in older["tasks"]][:1] == ["Tool 150"] - assert newer["title"] == "Steps 201–240 Β· Last: Tool 239" + assert newer["title"] == "Activity 201–240 Β· Last: Tool 239" assert [task["title"] for task in newer["tasks"]][-1:] == ["Tool 239"] @@ -518,16 +518,16 @@ async def test_what_is_no_longer_shown_is_one_line_above_the_steps() -> None: ) assert [block["title"] for block in before] == [ - "Steps 1–50", - "Steps 51–100 Β· Last: Tool 99", + "Activity 1–50", + "Activity 51–100 Β· Last: Tool 99", ] after = list(_drawn(client).values()) assert [block["type"] for block in after] == ["context", "plan", "plan"] assert after[0]["block_id"] == before[0]["block_id"] - assert after[0]["elements"][0]["text"] == "_Steps 1–50 no longer shown_" + assert after[0]["elements"][0]["text"] == "_Activity 1–50 no longer shown_" assert [block["title"] for block in after[1:]] == [ - "Steps 51–100", - "Steps 101–101 Β· Last: Tool 100", + "Activity 51–100", + "Activity 101–101 Β· Last: Tool 100", ] @@ -549,7 +549,7 @@ async def test_a_step_never_moves_between_pages_once_it_has_landed() -> None: moved = [c for c in _chunks(client)[1] if c["type"] == "blocks"] assert [chunk["blocks"][0]["title"] for chunk in moved] == [ - "Steps 51–52 Β· Last: Tool 51" + "Activity 51–52 Β· Last: Tool 51" ] older, newer = _pages(client) assert [task["title"] for task in older["tasks"]][:1] == ["Tool 0"] @@ -578,8 +578,8 @@ async def test_the_live_step_is_named_on_its_own_section_and_not_in_the_header() assert _chunks(client)[0][0] == {"type": "plan_update", "title": "Working… 40s"} assert [page["title"] for page in _pages(client)] == [ - "Steps 1–50 Β· Running: Grep", - "Steps 51–60", + "Activity 1–50 Β· Running: Grep", + "Activity 51–60", ] @@ -606,12 +606,12 @@ async def test_the_label_leaves_a_settled_section_when_the_live_step_moves_past_ first = [c for c in _chunks(client)[0] if c["type"] == "blocks"] assert [chunk["blocks"][0]["title"] for chunk in first] == [ - "Steps 1–50 Β· Last: Tool 49" + "Activity 1–50 Β· Last: Tool 49" ] later = [c for c in _chunks(client)[1] if c["type"] == "blocks"] assert [chunk["blocks"][0]["title"] for chunk in later] == [ - "Steps 1–50", - "Steps 51–51 Β· Last: Tool 50", + "Activity 1–50", + "Activity 51–51 Β· Last: Tool 50", ] @@ -628,7 +628,7 @@ async def test_a_step_title_is_not_escaped_because_nothing_in_it_is_parsed() -> ) assert _steps(client)[0]["title"] == shell - assert _pages(client)[0]["title"] == f"Steps 1–1 Β· Running: {shell}" + assert _pages(client)[0]["title"] == f"Activity 1–1 Β· Running: {shell}" # ── Ending ─────────────────────────────────────────────────────────────────── diff --git a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py index 91f1f0a14..01fd8a09d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py @@ -486,7 +486,16 @@ async def test_releasing_a_claim_this_turn_never_made_still_clears_a_live_reacti assert client.reactions == [("remove", "parent-1", "eyes")] -async def test_internal_narration_is_not_published_in_activity_or_fallback() -> None: +async def test_what_the_agent_said_is_behind_the_plan_and_not_in_the_notification() -> ( + None +): + """Slack gets the agent's words, but only inside the block a reader opens. + + The message's `text` is what a notification, a preview and a screen reader + are given, and it is the one part of this that arrives without being asked + for. An agent narrating itself β€” "Answered in the room." β€” pushed to + somebody's phone is the opposite of the discretion the disclosure is for. + """ client = FakeWebClient() activity = SessionTurnActivity(_adapter(client)) narration = _item().model_copy(update={"text": "Answered in the room."}) @@ -501,7 +510,8 @@ async def test_internal_narration_is_not_published_in_activity_or_fallback() -> await _publish(activity, [narration, tool], _turn("running")) await _publish(activity, [narration, tool], _turn("completed"), elapsed_seconds=25) for call in [*client.posted, *client.updated]: - assert "Answered in the room" not in json.dumps(call) + assert "Answered in the room" not in call.get("text", "") + assert "Β» Answered in the room." in _blocks(client.posted[0]) assert "Read file" in _blocks(client.posted[0]) assert "Worked for 25s" in _blocks(client.updated[0]) From d2128fffee6d588dcd52051389e877ae16f41d91 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 00:29:58 +0100 Subject: [PATCH 096/120] Correct three comments that stopped being true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `models.py` said Slack was the only platform posting request cards, which was the reason a card could ignore `sender_is_app`. All five post them now; what is still Slack-only is the flag itself, so that is what the comment says. The base `is_first_reply` test said the same thing about cards. Slack, Discord and Mattermost can read a thread; Teams and Telegram inherit the refusal, which is what the test is for. Teams' `_ACTION_VERSION` comment called 1.5 the version that introduced `Action.Execute`. It was 1.4. The constant is left alone β€” lowering it would widen which clients render the card, and that is a question for a real client rather than for the schema β€” but the reason given for it now matches the documentation. Co-Authored-By: Claude Opus 5 --- core/switch_core/bridges/collaboration/models.py | 3 ++- .../bridges/collaboration/teams/cards.py | 13 ++++++++----- .../collaboration/test_session_text_answers.py | 7 ++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/core/switch_core/bridges/collaboration/models.py b/core/switch_core/bridges/collaboration/models.py index 860bedea8..a1f5522ff 100644 --- a/core/switch_core/bridges/collaboration/models.py +++ b/core/switch_core/bridges/collaboration/models.py @@ -100,7 +100,8 @@ class InboundMessage(BaseModel): # person (a Slack workflow, a third-party integration). Such a post is # relayed like any other, but it cannot answer a request: a decision is # attributed to whoever made it, and an app made none. Only Slack reports - # it today, and Slack is the only platform posting request cards. + # it today; a platform that cannot tell an app from a person leaves this + # False and its cards accept the answer. sender_is_app: bool = False diff --git a/core/switch_core/bridges/collaboration/teams/cards.py b/core/switch_core/bridges/collaboration/teams/cards.py index e9ecb16c8..a0ecc1752 100644 --- a/core/switch_core/bridges/collaboration/teams/cards.py +++ b/core/switch_core/bridges/collaboration/teams/cards.py @@ -18,11 +18,14 @@ # collision waiting for the first card here to grow an input. _ANSWER_DATA = "switchAnswer" -# The schema version that introduced Action.Execute, which is the only card -# action that reaches a bot with a reply the presser alone sees. A card with -# nothing to press stays at 1.4: a client too old for the newer schema falls -# back to `fallbackText` for the whole card, which is a cost worth paying only -# where there is something to gain. +# The schema version a card declares once it has something to press. +# Action.Execute β€” the only card action that reaches a bot with a reply the +# presser alone sees β€” arrived in 1.4, so this is a version above what the +# action itself requires; lowering it would widen the set of clients that can +# render the card, which is a question for a real client rather than for the +# schema. A card with nothing to press stays at 1.4: a client too old for the +# declared version falls back to `fallbackText` for the whole card, which is a +# cost worth paying only where there is something to gain. _ACTION_VERSION = "1.5" _BASE_VERSION = "1.4" diff --git a/core/tests/switch_core/bridges/collaboration/test_session_text_answers.py b/core/tests/switch_core/bridges/collaboration/test_session_text_answers.py index f7d6a8b30..6ec423836 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_text_answers.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_text_answers.py @@ -167,9 +167,10 @@ def test_a_platform_with_no_way_to_read_a_thread_never_says_first( ) -> None: """The default every adapter inherits until it can actually check. - Slack is the only platform posting cards today. The next one to grow them - must not silently turn every "yes" in a card's thread into an answer by - saying nothing about threads at all β€” so the base refuses, and says why. + Slack, Discord and Mattermost can read a thread and answer the question; + the rest inherit this. A platform that says nothing about threads must not + silently turn every "yes" in a card's thread into an answer, so the base + refuses, and says why. """ adapter = TelegramAdapter.__new__(TelegramAdapter) From a6aade82679bde987d74fc1690cc9168f0a1c696 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 00:30:07 +0100 Subject: [PATCH 097/120] Discord: report a failed card with the options it could not show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same defect Mattermost had at 72f89a04. A card whose options are carried by buttons stops listing them in its body. The text of a `RichContentFailed` has no buttons β€” the caller forwards it as an ordinary message β€” so reporting a refusal with the drawing that went on the post asks for a choice it has stopped printing. `rich_fallback_text` was already the control-free drawing and already the string these failures were meant to carry; five sites were passing the button-aware one instead. A refused DM post, a refused webhook post, a refused edit, a thread that has gone and a press id too long for Discord all take the fallback now. `_edit_rich` is given it rather than deriving one, since by then the content has been drawn and thrown away. Regressions for the first four, each failing against the code before this. The reviewer's own reproduction of the refused edit passes unmodified. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 43 +++++++--- .../test_discord_card_buttons.py | 82 ++++++++++++++++++- 2 files changed, 112 insertions(+), 13 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 8e92398dd..ac6aecd0e 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -998,6 +998,13 @@ def rich_fallback_text(self, content: RichContent) -> str: something looked up. This is the string that travels in a `RichContentFailed`, where the lookups would be decorating a message nobody is going to see. + + It is also drawn as if no buttons were possible, and that half is not + cosmetic. A card whose options are carried by buttons stops listing + them in its body; the caller forwards a failure's text as an ordinary + message, which has no buttons under it. Reporting a refusal with the + drawing that went on the post would ask for a choice it had stopped + printing. """ return self._draw( content, mention=None, responder=None, prefix="", controls=False @@ -1155,7 +1162,7 @@ def _controls( f"Cannot put a button on request {content.request.request_id} " f"in Discord: its press would carry {len(custom_id)} " f"characters and Discord allows {_MAX_CUSTOM_ID}.", - text=drawn.text, + text=self.rich_fallback_text(content), ) view.add_item( discord.ui.Button( @@ -1276,7 +1283,7 @@ async def post_rich( ) except Exception as error: raise self._rich_failure( - error, f"Discord refused the post in DM {channel_id}", text + error, f"Discord refused the post in DM {channel_id}", fallback ) from error return f"{sent.channel.id}:{sent.id}" @@ -1296,7 +1303,7 @@ async def post_rich( thread: Any = None if thread_root_id: thread = await self._publication_thread( - int(channel_id), thread_root_id, text + int(channel_id), thread_root_id, fallback ) try: @@ -1317,12 +1324,12 @@ async def post_rich( ) except Exception as error: raise self._rich_failure( - error, f"Discord refused the post in channel {channel_id}", text + error, f"Discord refused the post in channel {channel_id}", fallback ) from error return f"{sent.channel.id}:{sent.id}" async def _publication_thread( - self, channel_id: int, thread_root_id: str, text: str + self, channel_id: int, thread_root_id: str, fallback: str ) -> Any: """The thread this publication goes in. Never the channel instead. @@ -1337,7 +1344,7 @@ async def _publication_thread( the channel" is then the difference between a conversation and an audience. """ - existing = await self._reachable_thread(channel_id, thread_root_id, text) + existing = await self._reachable_thread(channel_id, thread_root_id, fallback) if existing is not None: return existing try: @@ -1346,17 +1353,17 @@ async def _publication_thread( # The create may have been refused because the thread is already # there β€” the one failure that means the opposite of what it looks # like. Ask again before reporting that there is none. - settled = await self._reachable_thread(channel_id, thread_root_id, text) + settled = await self._reachable_thread(channel_id, thread_root_id, fallback) if settled is not None: return settled raise ThreadUnavailable( f"Discord has no thread under {thread_root_id} in channel " f"{channel_id} and would not make one: {error}", - text=text, + text=fallback, ) from error async def _reachable_thread( - self, channel_id: int, thread_root_id: str, text: str + self, channel_id: int, thread_root_id: str, fallback: str ) -> Any: """The thread already hanging from this message, if there is one. @@ -1389,7 +1396,7 @@ async def _reachable_thread( f"{channel_id}, so this publication has nowhere it is known to " f"belong. The channel is not a substitute: a thread this bridge " f"cannot open may be one the channel cannot read either. {error}", - text=text, + text=fallback, ) from error async def update_rich( @@ -1458,7 +1465,14 @@ async def update_rich( text, view = self._render_rich( replace(content, notify_external_id=None), prefix=prefix, controls=controls ) - await self._edit_rich(channel_id, message_ref, text, view, lobby=lobby) + await self._edit_rich( + channel_id, + message_ref, + text, + view, + lobby=lobby, + fallback=self.rich_fallback_text(content), + ) async def _edit_rich( self, @@ -1468,6 +1482,7 @@ async def _edit_rich( view: discord.ui.View | None, *, lobby: bool, + fallback: str, ) -> None: """Redraw a publication, including the buttons it does or does not keep. @@ -1475,6 +1490,10 @@ async def _edit_rich( because leaving it out leaves the components alone: a settled card would keep the buttons it was posted with and go on inviting a press that can no longer land. `None` is what takes them off. + + `fallback` is what a refused edit is reported with, in place of `text`: + the drawing that was going on the message assumes the buttons beside + it, and a failure notice carries none. """ location_id, message_id = self._parse_message_ref(message_ref) try: @@ -1500,7 +1519,7 @@ async def _edit_rich( raise self._rich_failure( error, f"Discord refused the edit to {message_ref} in channel {channel_id}", - text, + fallback, ) from error def _rich_failure(self, error: Exception, description: str, text: str) -> Exception: diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py b/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py index f87e3cf1a..037386757 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py @@ -27,7 +27,11 @@ import discord import pytest -from switch_core.bridges.collaboration.adapter import RequestCard +from switch_core.bridges.collaboration.adapter import ( + RequestCard, + RichContentFailed, + ThreadUnavailable, +) from switch_core.bridges.collaboration.discord.adapter import ( _MAX_BUTTONS, _PUBLICATION_WEBHOOK_NAME, @@ -57,6 +61,8 @@ _Channel, _DMChannel, _guild_setup, + _http_error, + _no_thread_yet, _Thread, _Webhook, ) @@ -250,6 +256,80 @@ async def test_an_option_too_long_for_its_button_is_kept_whole_in_the_body() -> assert "very very very" in webhook.sent[0]["content"] +# ── What a refusal is reported with ────────────────────────────────────────── + + +async def test_a_refused_edit_reports_the_options_the_card_stopped_printing() -> None: + """The one that actually reaches a reader. + + A card whose redraw is refused travels as the text of a + `RichContentFailed`, and the publisher forwards that as an ordinary + message β€” where there are no buttons to carry the options. The drawing that + was going on the card is the wrong one to report with, because it left out + every option a button was going to say in full. + """ + adapter, _channel, _thread, webhook, _seen = _answering() + card = await _post_card(adapter) + webhook.edit_error = _http_error(403) + + with pytest.raises(RichContentFailed) as raised: + await adapter.update_rich( + str(CHANNEL_ID), + "my-agent", + f"{ROOT_MESSAGE_ID}:{CARD_MESSAGE_ID}", + card, + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + ) + + label = offered_controls(card.request)[0].label + assert label in raised.value.text + assert label not in webhook.sent[0]["content"] + + +async def test_a_refused_post_reports_the_options_its_buttons_never_got() -> None: + """Nothing was posted, so the reported text is the only place the options + appear at all.""" + adapter, _channel, _thread, webhook, _seen = _answering() + card = await _card() + webhook.send_error = _http_error(403) + + with pytest.raises(RichContentFailed) as raised: + await adapter.post_rich(str(CHANNEL_ID), "my-agent", card, None) + + assert offered_controls(card.request)[0].label in raised.value.text + + +async def test_a_refused_dm_card_reports_the_options_the_bot_could_not_send() -> None: + """A DM card is the bot's own message and always earns buttons, so this is + the path where the body most reliably has the options left out of it.""" + dm = _DMChannel() + adapter = _adapter({DM_CHANNEL_ID: dm}) + _handled(adapter) + card = await _card() + dm.send_error = _http_error(403) + + with pytest.raises(RichContentFailed) as raised: + await adapter.post_rich(str(DM_CHANNEL_ID), "my-agent", card, None) + + assert offered_controls(card.request)[0].label in raised.value.text + + +async def test_a_card_with_nowhere_to_go_says_what_it_was_asking() -> None: + """`ThreadUnavailable` carries a text too, and the caller posts it at the + channel root when the thread it was meant for has gone.""" + adapter, channel, _webhook = _no_thread_yet() + _handled(adapter) + channel.thread_error = _http_error(403) + card = await _card() + + with pytest.raises(ThreadUnavailable) as raised: + await adapter.post_rich( + str(CHANNEL_ID), "my-agent", card, f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert offered_controls(card.request)[0].label in raised.value.text + + async def test_a_settled_card_is_redrawn_with_its_buttons_taken_off() -> None: """`view=None` rather than nothing at all: an edit that leaves the components alone leaves a settled card inviting a press.""" From 822fe45f7130929730a3414de6fae555032bede5 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 00:41:47 +0100 Subject: [PATCH 098/120] Say which fact refused an activity view, not the strongest one One string, ACTIVITY_UNREADABLE, stood for three different findings: an established non-membership, an established non-readability, and nothing established at all. Every site that refused a press asserted the strongest of them, so a reader whose membership lookup had timed out was told they could not read a conversation nothing had checked them against, and a Mattermost reader outside an open channel was told the same about one they could read every word of. Three constants, so a call site has to pick the fact it established: ACTIVITY_NOT_A_MEMBER, ACTIVITY_UNREADABLE, ACTIVITY_AUDIENCE_UNKNOWN. They are not one parameterised sentence for the same reason. Mattermost's get_channel_member establishes membership and nothing wider, so a 404 is now the membership refusal; a bridge with no connection, a channel it may not inspect, and a lookup that fell over are the unestablished one. The permissions case also now logs, because a bridge that cannot see its own channels is misconfigured rather than idle. Discord already distinguished NotFound from every other HTTPException, which is exactly that boundary: a member the guild or thread does not have is a fact about the reader, a 500 is not. Its permission check keeps the readability wording, since permissions are what it consulted. The two bool-returning checks became str | None, which is also what CLAUDE.md asks of a check that can fail. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 65 ++++++++++++++----- .../collaboration/mattermost/adapter.py | 48 +++++++++----- .../session/renderers/neutral.py | 34 ++++++++-- .../test_discord_activity_view.py | 43 ++++++++++-- .../test_mattermost_activity_view.py | 29 ++++++--- 5 files changed, 166 insertions(+), 53 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index ac6aecd0e..c79c4f334 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -60,8 +60,10 @@ position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_AUDIENCE_UNKNOWN, ACTIVITY_FAILED, ACTIVITY_GONE, + ACTIVITY_NOT_A_MEMBER, ACTIVITY_UNREADABLE, activity_log, render_request, @@ -2712,8 +2714,9 @@ async def _show_activity(self, interaction: discord.Interaction, ref: str) -> No ) await self._privately(interaction, ACTIVITY_GONE, ref) return - if not await self._still_reads(location, interaction.user): - await self._privately(interaction, ACTIVITY_UNREADABLE, ref) + refusal = await self._still_reads(location, interaction.user) + if refusal is not None: + await self._privately(interaction, refusal, ref) return parent_id = getattr(location, "parent_id", None) channel_id = str(parent_id if parent_id is not None else location.id) @@ -2797,8 +2800,8 @@ async def _privately( error, ) - async def _still_reads(self, channel: Any, user: Any) -> bool: - """Whether this reader can still read the conversation a turn is in. + async def _still_reads(self, channel: Any, user: Any) -> str | None: + """Why this reader may not see the conversation a turn is in, or None. A channel outside any guild has no permissions to consult: who may read it is exactly who is in it, so that is what is asked. A private @@ -2810,6 +2813,13 @@ async def _still_reads(self, channel: Any, user: Any) -> bool: cannot ask about is one nothing here can say a reader may see, and the reader is told that rather than shown the log on the strength of not having been able to check. + + Which refusal is returned is the fact that was actually established. + Discord distinguishes "not found" from every other error, so a reader + the guild or the thread does not have is a reader who is not in it, + while a request that failed some other way establishes nothing about + them at all β€” and telling somebody they cannot read a conversation, on + the strength of a call that never came back, is a claim nothing checked. """ guild = getattr(channel, "guild", None) if guild is None: @@ -2821,29 +2831,48 @@ async def _still_reads(self, channel: Any, user: Any) -> bool: "activity view of it is refused.", getattr(channel, "id", "?"), ) - return False + return ACTIVITY_AUDIENCE_UNKNOWN member = guild.get_member(user.id) if member is None: try: member = await guild.fetch_member(user.id) - except discord.HTTPException: - return False + except discord.NotFound: + return ACTIVITY_NOT_A_MEMBER + except discord.HTTPException as error: + logger.warning( + "Discord would not say whether user %s is in guild %s (%s), " + "so an activity view of channel %s is refused.", + user.id, + getattr(guild, "id", "?"), + error, + getattr(channel, "id", "?"), + ) + return ACTIVITY_AUDIENCE_UNKNOWN allowed = permissions_for(member) if not (allowed.view_channel and allowed.read_message_history): - return False + return ACTIVITY_UNREADABLE is_private = getattr(channel, "is_private", None) if is_private is None or not is_private(): - return True + return None if allowed.manage_threads: - return True + return None try: await channel.fetch_member(user.id) - except discord.HTTPException: - return False - return True + except discord.NotFound: + return ACTIVITY_NOT_A_MEMBER + except discord.HTTPException as error: + logger.warning( + "Discord would not say whether user %s is in thread %s (%s), so " + "an activity view of it is refused.", + user.id, + getattr(channel, "id", "?"), + error, + ) + return ACTIVITY_AUDIENCE_UNKNOWN + return None - def _is_recipient(self, channel: Any, user: Any) -> bool: - """Whether this reader is one of the people a guildless channel is between. + def _is_recipient(self, channel: Any, user: Any) -> str | None: + """Why this reader is not one of the people a guildless channel is between. Asked rather than taken as read. The address that named this channel came off a press, and the whole point of checking here is that an @@ -2861,8 +2890,10 @@ def _is_recipient(self, channel: Any, user: Any) -> bool: "activity view of it is refused.", getattr(channel, "id", "?"), ) - return False - return any(getattr(person, "id", None) == user.id for person in recipients) + return ACTIVITY_AUDIENCE_UNKNOWN + if any(getattr(person, "id", None) == user.id for person in recipients): + return None + return ACTIVITY_NOT_A_MEMBER async def _tell_presser( self, interaction: discord.Interaction, notice: str diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index f7997cf89..4068a4477 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -71,9 +71,10 @@ position_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_AUDIENCE_UNKNOWN, ACTIVITY_FAILED, ACTIVITY_GONE, - ACTIVITY_UNREADABLE, + ACTIVITY_NOT_A_MEMBER, activity_log, render_request, turn_status, @@ -559,8 +560,9 @@ async def _show_activity(self, press: ActivityPress) -> dict[str, Any]: resolve = self._resolve_activity if resolve is None: return _ephemeral(ACTIVITY_GONE) - if not await self._reads_channel(press.channel_id, press.user_id): - return _ephemeral(ACTIVITY_UNREADABLE) + refusal = await self._reads_channel(press.channel_id, press.user_id) + if refusal is not None: + return _ephemeral(refusal) try: snapshot = await resolve(press.channel_id, press.post_id) except Exception: @@ -575,19 +577,24 @@ async def _show_activity(self, press: ActivityPress) -> dict[str, Any]: return _ephemeral(ACTIVITY_GONE) return _ephemeral(self._activity_text(snapshot)) - async def _reads_channel(self, channel_id: str, user_id: str) -> bool: - """Whether this person is in the channel a turn was published into. + async def _reads_channel(self, channel_id: str, user_id: str) -> str | None: + """Why this person may not see the channel's activity, or None if they may. Membership, because that is the audience Mattermost actually holds, and it holds it the same way for an open channel, a private one and a direct message. It is narrower than readability on an open channel, where any member of the team may read without having joined; a reader in that position is refused and told so, which is the side to be wrong - on for a disclosure this message does not already make. + on for a disclosure this message does not already make. That is also + why the refusal says they are not in the channel rather than that they + cannot read it β€” membership is the fact this establishes, and + readability is not. A lookup that cannot answer refuses too. "Mattermost did not say" is not "yes", and an audience that cannot be established is one nothing - should be disclosed to. + should be disclosed to. It is a different sentence, though: a reader + told they cannot read something goes and asks to be let in, and this + one has nothing to ask for. """ driver = self._admin_driver loop = self._main_loop @@ -597,16 +604,25 @@ async def _reads_channel(self, channel_id: str, user_id: str) -> bool: "is not connected, so no activity is shown.", channel_id, ) - return False + return ACTIVITY_AUDIENCE_UNKNOWN try: member = await loop.run_in_executor( None, driver.channels.get_channel_member, channel_id, user_id ) - except (ResourceNotFound, NotEnoughPermissions): - # Both are answers rather than failures: Mattermost says "not a - # member" with a 404, and a channel this bridge may not inspect is - # one whose audience it cannot vouch for either. - return False + except ResourceNotFound: + # An answer rather than a failure: Mattermost says "not a member" + # with a 404. + return ACTIVITY_NOT_A_MEMBER + except NotEnoughPermissions as error: + # A channel this bridge may not inspect is one whose audience it + # cannot vouch for β€” which says nothing about the reader. + logger.warning( + "Mattermost will not let this bridge see who is in channel %s " + "(%s), so no activity is shown.", + channel_id, + error, + ) + return ACTIVITY_AUDIENCE_UNKNOWN except Exception as error: logger.warning( "Mattermost would not say whether user %s is in channel %s " @@ -615,8 +631,10 @@ async def _reads_channel(self, channel_id: str, user_id: str) -> bool: channel_id, error, ) - return False - return bool(member) and member.get("user_id") == user_id + return ACTIVITY_AUDIENCE_UNKNOWN + if bool(member) and member.get("user_id") == user_id: + return None + return ACTIVITY_NOT_A_MEMBER def _activity_text(self, snapshot: ActivitySnapshot) -> str: """The tool calls, and the time the read behind them was taken. diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index f19d4da9a..94248b5e7 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -126,20 +126,42 @@ # # Here rather than in each adapter because a reader on two platforms is one # reader, and the same refusal worded two ways reads as two different problems. -# Which of the three applies is the adapter's to decide β€” only it knows who -# pressed and what the platform would say about them. +# Which of these applies is the adapter's to decide β€” only it knows who pressed +# and what the platform would say about them. ACTIVITY_GONE = ( "There is no activity behind this message any more. It may belong to a " "session that has since been removed." ) -# Not "no longer": on one platform this is access that was taken away, on -# another it is a reader who never had it β€” an open channel they can read -# without having joined, which is not membership and is all Mattermost will -# vouch for. The sentence has to be true of both. +# Two refusals rather than one, because a refused press has two quite different +# causes and the reader can act on only one of them. They are separate +# constants and not one parameterised sentence so that a site has to choose +# which fact it established: the whole defect this replaced was a single string +# asserting the stronger of the two wherever either had happened. +# +# Not "no longer": this is as often a reader who never had access as one whose +# access was taken away. +ACTIVITY_NOT_A_MEMBER = ( + "You are not in the conversation this turn was published into, so its " + "activity is not shown." +) +# What a platform's permissions establish, where it has them to consult. Kept +# apart from membership because they are not the same claim: an open Mattermost +# channel is readable by people who have not joined it, so membership is the +# narrower fact and saying "cannot read" on the strength of it is a claim +# nothing checked. ACTIVITY_UNREADABLE = ( "You cannot read the conversation this turn was published into, so its " "activity is not shown." ) +# Nothing was established at all β€” the bridge is disconnected, the platform +# would not answer, or the destination has no audience this can consult. The +# refusal stands either way, since an audience that cannot be established is +# one nothing should be disclosed to, but it is not the reader's doing and the +# sentence must not blame them for it. +ACTIVITY_AUDIENCE_UNKNOWN = ( + "Switch could not establish who may read the conversation this turn was " + "published into, so its activity is not shown." +) ACTIVITY_FAILED = ( "Switch could not read this turn's activity just now. Try again, or open " "the session in Switch Console." diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py index 06ee10d61..275659256 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py @@ -41,8 +41,10 @@ _refresh_id, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_AUDIENCE_UNKNOWN, ACTIVITY_FAILED, ACTIVITY_GONE, + ACTIVITY_NOT_A_MEMBER, ACTIVITY_UNREADABLE, ) @@ -104,12 +106,17 @@ def __init__(self, members: set[int]) -> None: super().__init__() self.members = members self.fetched: list[int] = [] + # What Discord says instead of answering. "Not found" is an answer and + # is the default below; this is everything else. + self.fetch_error: Exception | None = None def get_member(self, user_id: int) -> _Member | None: return _Member(user_id) if user_id in self.members else None async def fetch_member(self, user_id: int) -> _Member: self.fetched.append(user_id) + if self.fetch_error is not None: + raise self.fetch_error if user_id in self.members: return _Member(user_id) raise discord.NotFound(_HTTPResponse(), "no such member") # type: ignore[arg-type] @@ -525,13 +532,37 @@ async def test_a_reader_who_cannot_read_the_history_is_refused_too() -> None: async def test_someone_who_has_left_the_guild_is_refused() -> None: + """Discord answers "no such member", which is a fact about them rather + than a failure to find one, so that is the refusal they get.""" adapter, channel = _guild_with(set()) _resolving(adapter, _snapshot()) press = _status_press(channel) await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_NOT_A_MEMBER + + +async def test_a_guild_lookup_that_failed_does_not_say_the_reader_is_outside_it( + caplog: pytest.LogCaptureFixture, +) -> None: + """The sibling of the case above, and the reason they are two branches. + "Not found" is Discord saying this person is not here; a 500 is Discord + not saying anything, and the reader must not be told they left a guild on + the strength of a request that fell over.""" + adapter, channel = _guild_with({READER_ID}) + guild: Any = channel.guild + guild.members = set() + guild.fetch_error = discord.HTTPException(_HTTPResponse(), "upstream") # type: ignore[arg-type] + asked = _resolving(adapter, _snapshot()) + press = _status_press(channel) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == ACTIVITY_AUDIENCE_UNKNOWN + assert any("would not say whether" in r.getMessage() for r in caplog.records) async def test_a_private_thread_asks_for_membership_not_visibility() -> None: @@ -549,7 +580,7 @@ async def test_a_private_thread_asks_for_membership_not_visibility() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_NOT_A_MEMBER async def test_a_member_of_that_thread_is_shown_it() -> None: @@ -585,7 +616,7 @@ async def test_a_refresh_is_authorised_against_the_thread_its_reference_names() await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_NOT_A_MEMBER async def test_a_destination_nobody_can_ask_about_is_refused_not_assumed( @@ -601,7 +632,7 @@ async def test_a_destination_nobody_can_ask_about_is_refused_not_assumed( with caplog.at_level(logging.WARNING): await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_AUDIENCE_UNKNOWN assert any( "Cannot establish who may read" in r.getMessage() for r in caplog.records ) @@ -631,7 +662,7 @@ async def test_someone_not_in_that_channel_is_refused_it() -> None: await adapter._handle_interaction(press) # type: ignore[arg-type] assert asked == [] - assert _shown(press) == ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_NOT_A_MEMBER async def test_a_channel_that_cannot_say_who_is_in_it_is_refused( @@ -645,7 +676,7 @@ async def test_a_channel_that_cannot_say_who_is_in_it_is_refused( with caplog.at_level(logging.WARNING): await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == ACTIVITY_UNREADABLE + assert _shown(press) == ACTIVITY_AUDIENCE_UNKNOWN assert any("Cannot establish who is in" in r.getMessage() for r in caplog.records) diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py index 7f2ed617d..fb8ddfd49 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py @@ -41,9 +41,10 @@ activity_action, ) from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_AUDIENCE_UNKNOWN, ACTIVITY_FAILED, ACTIVITY_GONE, - ACTIVITY_UNREADABLE, + ACTIVITY_NOT_A_MEMBER, ) from .test_mattermost_press import ( @@ -456,11 +457,14 @@ async def test_what_a_host_called_a_tool_cannot_address_the_channel() -> None: async def test_someone_who_is_not_in_the_channel_is_told_rather_than_shown() -> None: + """And told the fact that was established. Mattermost answered "not a + member", which on an open channel is not the same as "cannot read" β€” a + reader who has never joined one can still read every word in it.""" adapter, asked = _viewer(member_of=None) answer = await adapter._handle_callback(_press()) - assert _shown(answer) == ACTIVITY_UNREADABLE + assert _shown(answer) == ACTIVITY_NOT_A_MEMBER assert asked == [] @@ -478,14 +482,16 @@ async def test_the_question_is_asked_of_mattermost_on_every_press() -> None: async def test_a_membership_lookup_that_cannot_answer_refuses( caplog: pytest.LogCaptureFixture, ) -> None: - """ "Mattermost did not say" is not "yes".""" + """ "Mattermost did not say" is not "yes" β€” and it is not "you cannot read + this" either. Nothing was established about the reader, so the refusal + stands without telling them it was their doing.""" adapter, asked = _viewer() _channels(adapter).error = RuntimeError("the server is down") with caplog.at_level(logging.WARNING): answer = await adapter._handle_callback(_press()) - assert _shown(answer) == ACTIVITY_UNREADABLE + assert _shown(answer) == ACTIVITY_AUDIENCE_UNKNOWN assert asked == [] assert "would not say whether" in caplog.text @@ -494,23 +500,28 @@ async def test_a_channel_this_bridge_may_not_inspect_is_one_it_will_not_disclose caplog: pytest.LogCaptureFixture, ) -> None: """An audience that cannot be established is not an empty one, but it is - not one anything should be shown to either β€” and it is an answer rather - than a fault, so it is not logged as one.""" + not one anything should be shown to either. + + It says nothing about the presser β€” the bridge's own admin account is what + was refused β€” so the reader is not told they are outside a channel nobody + checked them against, and the operator is told, because a bridge that + cannot see its own channels is misconfigured rather than idle. + """ adapter, _ = _viewer() _channels(adapter).error = NotEnoughPermissions("not allowed") with caplog.at_level(logging.WARNING): answer = await adapter._handle_callback(_press()) - assert _shown(answer) == ACTIVITY_UNREADABLE - assert "would not say whether" not in caplog.text + assert _shown(answer) == ACTIVITY_AUDIENCE_UNKNOWN + assert "will not let this bridge see who is in channel" in caplog.text async def test_a_bridge_that_is_not_connected_shows_nobody_anything() -> None: adapter, _ = _viewer() adapter._admin_driver = None - assert _shown(await adapter._handle_callback(_press())) == ACTIVITY_UNREADABLE + assert _shown(await adapter._handle_callback(_press())) == ACTIVITY_AUDIENCE_UNKNOWN # ── When there is nothing to show ──────────────────────────────────────────── From 1a6e3e39796fbc832c3f8f5f8b6cdd9899339abf Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 00:48:18 +0100 Subject: [PATCH 099/120] Mattermost: take a card's buttons off even where none can be offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updating a post's props was gated on _button_address(), so a deployment that had withdrawn its callback address β€” unset callback_base_url, or no interaction handler after a restart β€” patched only the message text. The actions posted while it still had an address survived every redraw, including the redraw that settles the card, leaving buttons in the channel that invite a press at a route that has gone. Removal cannot be conditional on the ability to offer. Props are now part of every RequestCard edit; _button_address() answers only the drawing question. A deployment that never had buttons now reads its posts back on redraw and writes their props unchanged, which is the cost of not being able to tell it apart from one that has just dropped them without looking. test_a_bridge_that_draws_no_buttons_does_not_read_a_post_back asserted the old behaviour on the premise that a bridge drawing no buttons has nothing to remove. It does not hold for a bridge that has stopped drawing them, so the test now asserts the opposite. Co-Authored-By: Claude Opus 5 --- .../collaboration/mattermost/adapter.py | 19 ++++++++++++------- .../test_mattermost_card_buttons.py | 19 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 4068a4477..82a6ee7b9 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -1046,9 +1046,11 @@ def _button_address(self) -> tuple[str, str] | None: All three have to hold and they are settled at different moments: an address the Mattermost server can reach, a place on the shared listener for a press to arrive at, and something to route it to once it has. - Asked on the redraw path as well as the drawing one, so a deployment - that draws no buttons never rewrites a post's props to remove them - either. + + A question about offering a button, and only that. Taking one off asks + the post instead: a deployment that has withdrawn its callback address + is exactly the one whose old buttons most need removing, and it is the + one this answers None for. """ url = self.callback_url endpoint = self._callback @@ -1204,9 +1206,12 @@ async def update_rich( whose outcome is unknown may well have landed, and reporting it as a refusal buys a fallback reply about a card that is already correct. - A card's buttons are carried in the post's props, so where this bridge - draws any the props are part of the edit β€” which is what takes them off - a card the moment it stops being answerable. + A card's buttons are carried in the post's props, so the props are part + of the edit β€” which is what takes them off a card the moment it stops + being answerable. Part of every card's edit, not just the edits of a + bridge that could put a button on: a deployment that has since dropped + its callback address can offer no new button and has old ones still + inviting a press at a route that has gone. """ # A post notifies; an edit does not. Repeating the mention on every # redraw would be a handle in the channel that never resolves to @@ -1223,7 +1228,7 @@ async def update_rich( ) try: patch: dict[str, Any] = {"message": rendered.text} - if isinstance(content, RequestCard) and self._button_address() is not None: + if isinstance(content, RequestCard): patch["props"] = await self._props_with_actions( driver, loop, message_ref, rendered.actions ) diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py index 07ffb8fde..0a0f18060 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py @@ -436,18 +436,23 @@ async def test_a_bridge_that_takes_no_presses_draws_no_buttons() -> None: assert _buttons(_created(adapter)) == [] -async def test_a_bridge_that_draws_no_buttons_does_not_read_a_post_back() -> None: - """The read is what keeps a props rewrite from dropping the server's own - marks. Where there is nothing to rewrite there is nothing to protect, and a - deployment without buttons behaves exactly as it did before them.""" - adapter, _ = _handled(callback_base_url=None) +async def test_a_bridge_that_draws_no_buttons_still_takes_the_old_ones_off() -> None: + """A deployment that has withdrawn its callback address is not a deployment + that never had one. Cards posted while it had one are still in the channel + with buttons on them, pointing at a route that has gone, and this is the + only pass that can take them off. Removing a control cannot be conditional + on being able to offer one.""" + adapter, _ = _handled() card = await _card() ref = await adapter.post_rich(CHANNEL, "worker", card, "root-1") - _posts(adapter).read_error = AssertionError("the post was read back") + assert _buttons(_created(adapter)) != [] + adapter._config = adapter._config.model_copy(update={"callback_base_url": None}) await adapter.update_rich(CHANNEL, "worker", ref, card, "root-1") - assert set(_patched(adapter)) == {"message"} + props = _patched(adapter)["props"] + assert "attachments" not in props + assert props["from_bot"] == "true" # ── The press that comes back ──────────────────────────────────────────────── From bde0dd028f4522b4a70627d6cd115b2b525bffda Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 00:51:29 +0100 Subject: [PATCH 100/120] Discord: keep the wait when a confirming read is rate-limited remove_publication classifies every other failure through _removal_failure, which keeps a 429 as a throttle carrying Discord's own Retry-After. The read that confirms whether a card the webhook disowned is still in the channel raised RemovalFailed directly, so a rate limit there arrived as a flat failure and the publisher backed off on its own growing interval instead of the delay Discord had just named. That route is only reached after the webhook route has already answered, which makes it the likeliest 429 on the path rather than an edge of it. The other four adapters already separate a rate-limited deletion from a refused one; this was the gap. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 11 +++++++++-- .../collaboration/test_discord_card_removal.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index c79c4f334..8013cdfba 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -1640,6 +1640,12 @@ async def _confirm_card_gone( The channel route answers about the message, so it is the one that can settle it. + + Classified like any other failed removal, so a channel read that is + throttled still arrives as a wait. Discord rate-limits per route, and + this route is reached only after the webhook route has already + answered: a 429 here is the likeliest one on the whole path, and the + delay it carries is the only thing that makes the retry useful. """ try: location = await self._get_channel(location_id) @@ -1648,10 +1654,11 @@ async def _confirm_card_gone( self._say_already_gone(message_ref, error) return except Exception as failure: - raise RemovalFailed( + raise self._removal_failure( + failure, f"Discord said the webhook does not know message {message_ref}, " f"and reading the channel to find out whether the card is still " - f"there did not work either: {failure}" + f"there did not work either", ) from failure raise RemovalFailed( f"Discord card {message_ref} is still in the channel: the " diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py index 54b78e0b3..6659c6868 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py @@ -205,6 +205,21 @@ async def test_a_card_that_cannot_be_read_back_is_owed_rather_than_gone() -> Non await adapter.remove_publication(str(CHANNEL_ID), CARD) +async def test_a_throttled_read_back_is_a_wait_like_any_other() -> None: + """The likeliest 429 on the whole path, because this route is only reached + after the webhook route has already answered β€” and the one where losing the + delay costs most, since the caller then backs off against a number Discord + had already named.""" + adapter, channel, _thread, publication = _guild_setup() + publication.delete_error = _unknown_message() + channel.fetch_error = _http_error(429, headers={"Retry-After": "17"}) + + with pytest.raises(RichContentThrottled) as raised: + await adapter.remove_publication(str(CHANNEL_ID), CARD) + + assert raised.value.retry_after == 17 + + async def test_being_asked_to_wait_is_kept_apart_from_being_refused() -> None: """A rate limit says nothing about the card, so it must not arrive as `RemovalFailed`: the caller's backoff would then double its own interval From 8b5ccaa0b76fce779e2bb902fcae3df8560e05c1 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 01:08:39 +0100 Subject: [PATCH 101/120] Publish the port a Mattermost button press arrives on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart declared no container or Service port for the collaboration callback listener, and the README said outright that Mattermost "connects outbound over a WebSocket and needs no ingress at all". That is true of everything Mattermost delivers and false of what it sends back: a press is a POST from the Mattermost server to an address Switch puts on the card when it draws it. Deployed from this chart, that address resolved to a Service with no such port, so every press was lost β€” and lost quietly, since a card with buttons looks exactly like a card whose buttons work. switchCore.collaborationCallback, off by default and shaped like the Teams block beside it: bridges are made at runtime in the gateway, so the chart cannot tell whether one wants buttons, and an unpublished port is a reduced service rather than a broken one. No Ingress. The Mattermost this chart deploys is in the cluster and needs only the Service port; a Mattermost outside it needs a route its operator owns, which is the distinction between a port switch-core binds and a port a host publishes. NOTES.txt prints the callback_base_url to paste into the bridge, because it is the cluster-internal name even where Mattermost itself is reached publicly, and that is the part an operator gets wrong. Co-Authored-By: Claude Opus 5 --- deploy/remote/helm/switch/README.md | 13 +++++++--- deploy/remote/helm/switch/templates/NOTES.txt | 16 +++++++++++++ .../templates/switch-core/deployment.yaml | 5 ++++ .../switch/templates/switch-core/service.yaml | 6 +++++ deploy/remote/helm/switch/values.yaml | 24 +++++++++++++++++++ 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/deploy/remote/helm/switch/README.md b/deploy/remote/helm/switch/README.md index 88e447e79..0bac980fa 100644 --- a/deploy/remote/helm/switch/README.md +++ b/deploy/remote/helm/switch/README.md @@ -28,11 +28,18 @@ two of the three fail *silently* β€” the pods are healthy and the dashboard work | Gateway dashboard | 3000 | Your operators | You cannot administer Switch | | Agent API + MCP | 8000 | Agents, wherever they run | Remote agents cannot connect; local ones are fine | | Teams bridge listener | 3978 | **Microsoft, from the public internet** | The Teams bridge half-works, silently | +| Collaboration callbacks | 8081 | Your Mattermost server | Mattermost cards carry no buttons; requests are answered by typing | **Only the Teams listener requires public internet exposure**, and only if you -run a Microsoft Teams bridge. Every other collaboration bridge β€” Slack, -Mattermost, Discord, Telegram β€” connects *outbound* over a WebSocket and needs -no ingress at all. +run a Microsoft Teams bridge. Slack, Discord and Telegram connect *outbound* +over a WebSocket and need no ingress at all. + +Mattermost is outbound for everything it receives, but not for what it sends +back: a button press is delivered by the Mattermost server over HTTP, to an +address Switch hands it when it draws the card. So it needs port 8081 reachable +from Mattermost β€” usually a cluster-internal Service port and nothing public. +Set `switchCore.collaborationCallback.enabled` and `helm install` prints the +address to configure on the bridge. If your agents all run inside the cluster or on operator machines that can reach it privately, nothing here needs to be on the public internet. diff --git a/deploy/remote/helm/switch/templates/NOTES.txt b/deploy/remote/helm/switch/templates/NOTES.txt index 991e442ad..99ca8e288 100644 --- a/deploy/remote/helm/switch/templates/NOTES.txt +++ b/deploy/remote/helm/switch/templates/NOTES.txt @@ -89,6 +89,22 @@ Expect 200 with `ping` echoed back. A timeout means it is not public; a 404 means the path is missing or aimed at the API port. `helm test` checks the in-cluster half. See docs/bridges/TEAMS_SETUP.md. {{- end }} +{{- if .Values.switchCore.collaborationCallback.enabled }} + +── Mattermost button presses ─────────────────────────────────────────────────── +Service {{ include "switch.switchCoreHost" . }} now publishes port {{ .Values.switchCore.collaborationCallback.port }}. +Give the Mattermost bridge exactly this callback_base_url: + + http://{{ include "switch.switchCoreHost" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.switchCore.collaborationCallback.port }} + +That is the address the Mattermost *server* calls, not one a browser follows, so +it stays the cluster-internal name even where Mattermost itself is reached +publicly. A Mattermost outside this cluster needs a route you own β€” the chart +renders none. + +Leave callback_base_url unset and the bridge still works: its cards arrive with +no buttons and are answered by typing. +{{- end }} ⚠ BACK UP YOUR DATA β€” this chart ships no backup jobs. Postgres holds everything: every room, every message, every account, and the diff --git a/deploy/remote/helm/switch/templates/switch-core/deployment.yaml b/deploy/remote/helm/switch/templates/switch-core/deployment.yaml index 95873ebaf..bd3be01c8 100644 --- a/deploy/remote/helm/switch/templates/switch-core/deployment.yaml +++ b/deploy/remote/helm/switch/templates/switch-core/deployment.yaml @@ -44,6 +44,11 @@ spec: name: teams protocol: TCP {{- end }} + {{- if .Values.switchCore.collaborationCallback.enabled }} + - containerPort: {{ .Values.switchCore.collaborationCallback.port }} + name: callbacks + protocol: TCP + {{- end }} env: {{- include "switch.coreEnv" . | nindent 12 }} readinessProbe: diff --git a/deploy/remote/helm/switch/templates/switch-core/service.yaml b/deploy/remote/helm/switch/templates/switch-core/service.yaml index 21a5c2e0d..3ae8207fe 100644 --- a/deploy/remote/helm/switch/templates/switch-core/service.yaml +++ b/deploy/remote/helm/switch/templates/switch-core/service.yaml @@ -19,5 +19,11 @@ spec: protocol: TCP name: teams {{- end }} + {{- if .Values.switchCore.collaborationCallback.enabled }} + - port: {{ .Values.switchCore.collaborationCallback.port }} + targetPort: {{ .Values.switchCore.collaborationCallback.port }} + protocol: TCP + name: callbacks + {{- end }} selector: {{- include "switch.selectorLabels" (dict "Release" .Release "component" "switch-core") | nindent 4 }} diff --git a/deploy/remote/helm/switch/values.yaml b/deploy/remote/helm/switch/values.yaml index 5fd3c4710..c97d54465 100644 --- a/deploy/remote/helm/switch/values.yaml +++ b/deploy/remote/helm/switch/values.yaml @@ -226,6 +226,30 @@ switchCore: # Secret holding the cert; cert-manager creates it from the annotation # above, or reference a pre-created TLS Secret. secretName: "" + # Collaboration callback listener. Mattermost's buttons are delivered by the + # Mattermost *server*, which POSTs a press to an address Switch hands it when + # it draws the card β€” so unlike Slack, Discord and Telegram, Mattermost's half + # of the conversation is not all on the outbound WebSocket. switch-core serves + # those presses from a third HTTP server, separate from the API on + # service.port and from the Teams listener above. + # + # Off by default for the same reason the Teams block is: bridges are created + # at runtime in the gateway, not in Helm values, so the chart cannot tell + # whether you have a Mattermost bridge. Leaving it off is a reduced service + # rather than a broken one β€” the bridge says so at startup, and its cards + # arrive with no buttons and stay answerable by typing. + # + # The in-cluster Mattermost this chart deploys needs only the Service port + # below; `helm install` prints the callback_base_url to give that bridge. A + # Mattermost OUTSIDE the cluster needs a route you own: this is a port + # switch-core binds, not necessarily a published host port, and the chart + # renders no Ingress for it. + collaborationCallback: + enabled: false + # Bind port inside the container. The chart sets no environment variable + # for it, so this must match switch-core's own COLLABORATION_CALLBACK_PORT + # default. + port: 8081 # switch-core MUST run as a single replica. Four things live in the process # rather than the database: the per-agent event buffer, the invite bus, the # ephemeral (presence) bus, and the message listener's subscriber registry. A From 6544d461a1d00c752017050fd57b8bf05935f350 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 01:18:06 +0100 Subject: [PATCH 102/120] Read a 404 for what it names, and stop calling our failures gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two residuals from the refusal-wording pass, both the same shape as the defect it fixed: a site answering with the strongest sentence available rather than the one it established. A 404 was taken as a membership verdict on its own. It is not: the member routes answer 10007 "Unknown Member", 10004 "Unknown Guild" and 10003 "Unknown Channel" with the same status, and only the first is about the reader. A bot looking at a guild that is not there told the reader they were not in the conversation. Both member lookups now read the code, and anything else is logged and refused as an audience nothing established. The fakes were raising a 404 with no code at all, which is not what Discord sends, so they now carry the real one β€” that is what makes the check load-bearing rather than decorative. "Gone" was the answer to three different things, and it is the one answer a reader cannot come back from: it retires the turn in their mind and they stop pressing. A channel lookup that fell over says nothing about the turn, which is still where it was, so only a 404 keeps that sentence. A bridge with no publisher behind it β€” a card outliving the process that drew it β€” is this end's problem too, and Switch Console can still open the session. Mattermost had the same site and is changed with it, since one reader on two platforms is one reader. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/discord/adapter.py | 75 ++++++++-- .../collaboration/mattermost/adapter.py | 15 +- .../test_discord_activity_view.py | 130 ++++++++++++++++-- .../test_mattermost_activity_view.py | 15 +- 4 files changed, 206 insertions(+), 29 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 8013cdfba..e9c4178e1 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -95,6 +95,13 @@ # still on the screen. _UNKNOWN_MESSAGE_CODE = 10008 +# "Unknown Member". The 404 that is about the person asked after, as against +# 10004 "Unknown Guild" and 10003 "Unknown Channel" on the same routes and with +# the same status. Only this one says a reader is not in a conversation; the +# other two say the conversation could not be found, which is the bot's problem +# and not the reader's. +_UNKNOWN_MEMBER_CODE = 10007 + # Applied to the bot posts that inline an agent's name into the body β€” the DM # path, which has no webhook identity to carry it. Escaping the text is not # enough on its own: Discord decides who a message pings from the raw content @@ -2704,22 +2711,40 @@ async def _show_activity(self, interaction: discord.Interaction, ref: str) -> No Everything that can go wrong is said rather than left silent. A button that answers with nothing reads as Discord having dropped the press, and the reader would go on pressing it. + + Said as what it is, too. Only a reference that names no conversation, + and a conversation Discord answers 404 for, are gone; a bridge that + cannot reach the log, or cannot resolve a channel it was given, has a + problem of its own and the turn is still there. Retiring it in the + reader's mind is the one answer they cannot come back from. """ resolve = self._resolve_activity location_id = _conversation_in(ref) - if resolve is None or location_id is None: + if location_id is None: await self._privately(interaction, ACTIVITY_GONE, ref) return + if resolve is None: + logger.warning( + "A Discord activity view was pressed on message %s, but this " + "bridge has nothing to read the log with, so it is refused.", + ref, + ) + await self._privately(interaction, ACTIVITY_FAILED, ref) + return try: location = await self._get_channel(location_id) - except (discord.HTTPException, RuntimeError): + except discord.NotFound: + await self._privately(interaction, ACTIVITY_GONE, ref) + return + except (discord.HTTPException, RuntimeError) as error: logger.warning( - "Discord would not say what channel %s is, so the activity " + "Discord would not say what channel %s is (%s), so the activity " "behind message %s is not shown.", location_id, + error, ref, ) - await self._privately(interaction, ACTIVITY_GONE, ref) + await self._privately(interaction, ACTIVITY_FAILED, ref) return refusal = await self._still_reads(location, interaction.user) if refusal is not None: @@ -2821,12 +2846,14 @@ async def _still_reads(self, channel: Any, user: Any) -> str | None: reader is told that rather than shown the log on the strength of not having been able to check. - Which refusal is returned is the fact that was actually established. - Discord distinguishes "not found" from every other error, so a reader - the guild or the thread does not have is a reader who is not in it, - while a request that failed some other way establishes nothing about - them at all β€” and telling somebody they cannot read a conversation, on - the strength of a call that never came back, is a claim nothing checked. + Which refusal is returned is the fact that was actually established. A + request that failed establishes nothing about the reader at all, and + telling somebody they cannot read a conversation on the strength of a + call that never came back is a claim nothing checked. Nor is the status + enough on its own: a 404 on these routes is about the member, the guild + or the channel, and only the first is about the reader. It is read for + which, because "you are not in it" and "the bot cannot find it" are the + reader's problem and ours respectively. """ guild = getattr(channel, "guild", None) if guild is None: @@ -2843,8 +2870,19 @@ async def _still_reads(self, channel: Any, user: Any) -> str | None: if member is None: try: member = await guild.fetch_member(user.id) - except discord.NotFound: - return ACTIVITY_NOT_A_MEMBER + except discord.NotFound as error: + if error.code == _UNKNOWN_MEMBER_CODE: + return ACTIVITY_NOT_A_MEMBER + logger.warning( + "Discord answered 404 %s for user %s in guild %s, which is " + "not an answer about the user, so an activity view of " + "channel %s is refused.", + error.code, + user.id, + getattr(guild, "id", "?"), + getattr(channel, "id", "?"), + ) + return ACTIVITY_AUDIENCE_UNKNOWN except discord.HTTPException as error: logger.warning( "Discord would not say whether user %s is in guild %s (%s), " @@ -2865,8 +2903,17 @@ async def _still_reads(self, channel: Any, user: Any) -> str | None: return None try: await channel.fetch_member(user.id) - except discord.NotFound: - return ACTIVITY_NOT_A_MEMBER + except discord.NotFound as error: + if error.code == _UNKNOWN_MEMBER_CODE: + return ACTIVITY_NOT_A_MEMBER + logger.warning( + "Discord answered 404 %s for user %s in thread %s, which is not " + "an answer about the user, so an activity view of it is refused.", + error.code, + user.id, + getattr(channel, "id", "?"), + ) + return ACTIVITY_AUDIENCE_UNKNOWN except discord.HTTPException as error: logger.warning( "Discord would not say whether user %s is in thread %s (%s), so " diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 82a6ee7b9..783dff140 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -553,13 +553,20 @@ async def _show_activity(self, press: ActivityPress) -> dict[str, Any]: on every press β€” a press establishes that the server accepted it, which is not a statement about what the presser may read now. - Every way it can fail says something. A button that answers with - nothing reads as the press having been dropped, and the reader would go - on pressing it. + Every way it can fail says something, and says which thing. A button + that answers with nothing reads as the press having been dropped, and + the reader would go on pressing it; a button that answers "gone" when + this end simply cannot reach the log retires a turn that is still + running, which the reader cannot come back from. """ resolve = self._resolve_activity if resolve is None: - return _ephemeral(ACTIVITY_GONE) + logger.warning( + "A Mattermost activity view was pressed on post %s, but this " + "bridge has nothing to read the log with, so it is refused.", + press.post_id, + ) + return _ephemeral(ACTIVITY_FAILED) refusal = await self._reads_channel(press.channel_id, press.user_id) if refusal is not None: return _ephemeral(refusal) diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py index 275659256..4399eae3d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py @@ -98,6 +98,16 @@ def __init__(self, user_id: int = READER_ID) -> None: self.name = "kim" +def _unknown_member() -> discord.NotFound: + """Discord's answer for a person who is not there β€” carrying the error code + that says so. The status alone does not: the same 404 answers "Unknown + Guild" and "Unknown Channel", and those are about the bot rather than the + reader.""" + return discord.NotFound( # type: ignore[arg-type] + _HTTPResponse(), {"code": 10007, "message": "Unknown Member"} + ) + + class _PeopledGuild(_Guild): """A guild that can be asked who somebody is, which is the whole of what the permission check needs from it.""" @@ -119,7 +129,7 @@ async def fetch_member(self, user_id: int) -> _Member: raise self.fetch_error if user_id in self.members: return _Member(user_id) - raise discord.NotFound(_HTTPResponse(), "no such member") # type: ignore[arg-type] + raise _unknown_member() class _HTTPResponse: @@ -189,13 +199,18 @@ def __init__( ) -> None: super().__init__(parent, thread_id, permissions=permissions) self.thread_members = members if members is not None else set() + # What Discord says instead of answering. "Unknown Member" is an answer + # and is the default below; this is everything else. + self.fetch_error: Exception | None = None def is_private(self) -> bool: return True async def fetch_member(self, user_id: int) -> object: + if self.fetch_error is not None: + raise self.fetch_error if user_id not in self.thread_members: - raise discord.NotFound(_HTTPResponse(), "not in this thread") # type: ignore[arg-type] + raise _unknown_member() return object() @@ -504,6 +519,51 @@ async def test_a_reference_that_is_not_an_address_is_told_there_is_nothing() -> assert _shown(press) == ACTIVITY_GONE +async def test_a_conversation_discord_will_not_resolve_is_not_a_turn_that_is_gone( + caplog: pytest.LogCaptureFixture, +) -> None: + """ "Gone" is the one answer a reader cannot come back from: it retires the + turn in their mind and they stop pressing. A channel lookup that fell over + has established nothing about the turn, which is still exactly where it + was, so the reader is told to try again instead.""" + adapter, channel = _guild_with({READER_ID}) + client: Any = adapter._client + client._channels.pop(CHANNEL_ID) + response = _HTTPResponse() + response.status = 500 # type: ignore[misc] + client.fetch_errors[CHANNEL_ID] = discord.HTTPException( # type: ignore[arg-type] + response, {"code": 0, "message": "Internal Server Error"} + ) + asked = _resolving(adapter, _snapshot()) + press = _status_press(channel) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == ACTIVITY_FAILED + assert any("would not say what channel" in r.getMessage() for r in caplog.records) + + +async def test_a_conversation_discord_says_is_not_there_is_gone() -> None: + """The other half of that split, kept so it cannot quietly collapse into + one answer again. A 404 is Discord saying the channel does not exist, and + that really is a turn nothing can be read from.""" + adapter, channel = _guild_with({READER_ID}) + client: Any = adapter._client + client._channels.pop(CHANNEL_ID) + client.fetch_errors[CHANNEL_ID] = discord.NotFound( # type: ignore[arg-type] + _HTTPResponse(), {"code": 10003, "message": "Unknown Channel"} + ) + asked = _resolving(adapter, _snapshot()) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == ACTIVITY_GONE + + # ── Who may read it ────────────────────────────────────────────────────────── @@ -565,6 +625,54 @@ async def test_a_guild_lookup_that_failed_does_not_say_the_reader_is_outside_it( assert any("would not say whether" in r.getMessage() for r in caplog.records) +async def test_a_guild_discord_cannot_find_is_not_a_reader_who_left_it( + caplog: pytest.LogCaptureFixture, +) -> None: + """The third case, and the one the status hides. "Unknown Guild" arrives as + the same 404 as "Unknown Member", so reading the status alone tells a reader + they are not in a conversation when what Discord actually said is that the + bot is looking at a guild that is not there.""" + adapter, channel = _guild_with(set()) + guild: Any = channel.guild + guild.fetch_error = discord.NotFound( # type: ignore[arg-type] + _HTTPResponse(), {"code": 10004, "message": "Unknown Guild"} + ) + asked = _resolving(adapter, _snapshot()) + press = _status_press(channel) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == ACTIVITY_AUDIENCE_UNKNOWN + assert any("not an answer about the user" in r.getMessage() for r in caplog.records) + + +async def test_a_thread_discord_cannot_find_is_not_a_reader_outside_it( + caplog: pytest.LogCaptureFixture, +) -> None: + """The same split on the thread route, which asks a different endpoint and + so needs its own evidence.""" + guild = _PeopledGuild({READER_ID}) + parent = _ReadableChannel(CHANNEL_ID, guild) + thread = _PrivateThread(parent, members=set()) + thread.fetch_error = discord.NotFound( # type: ignore[arg-type] + _HTTPResponse(), {"code": 10003, "message": "Unknown Channel"} + ) + adapter = _adapter({CHANNEL_ID: parent, PRIVATE_THREAD_ID: thread}) + asked = _resolving(adapter, _snapshot()) + press = _Press( + _ACTIVITY_VIEW_ID, channel=thread, message=_PressedMessage(STATUS_MESSAGE_ID) + ) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert asked == [] + assert _shown(press) == ACTIVITY_AUDIENCE_UNKNOWN + assert any("not an answer about the user" in r.getMessage() for r in caplog.records) + + async def test_a_private_thread_asks_for_membership_not_visibility() -> None: """Everyone who can see the parent passes the channel check. Only the thread's own membership says who is actually in it.""" @@ -833,17 +941,23 @@ async def test_a_turn_with_no_console_link_offers_only_refresh() -> None: ] -async def test_an_unpublished_bridge_answers_a_stale_button_rather_than_hanging() -> ( - None -): +async def test_an_unpublished_bridge_answers_a_stale_button_rather_than_hanging( + caplog: pytest.LogCaptureFixture, +) -> None: """Old buttons outlive the process that drew them, and a restart that no - longer publishes sessions must not leave them pressing into silence.""" + longer publishes sessions must not leave them pressing into silence. + + Answered as this end's problem, which is what it is. The turn is untouched + and Switch Console can still open it; telling the reader it is gone would + retire a session that is running.""" adapter, channel = _guild_with({READER_ID}) press = _status_press(channel) - await adapter._handle_interaction(press) # type: ignore[arg-type] + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] - assert _shown(press) == ACTIVITY_GONE + assert _shown(press) == ACTIVITY_FAILED + assert any("nothing to read the log with" in r.getMessage() for r in caplog.records) def test_the_activity_ids_fit_what_discord_carries() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py index fb8ddfd49..96a53f0e0 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py @@ -536,13 +536,22 @@ async def test_a_post_showing_no_turn_says_so_rather_than_nothing() -> None: assert _shown(await adapter._handle_callback(_press())) == ACTIVITY_GONE -async def test_a_bridge_with_no_publisher_says_the_same() -> None: - """Reachable only for a post whose button was drawn when there was one.""" +async def test_a_bridge_with_no_publisher_says_so_without_calling_it_gone( + caplog: pytest.LogCaptureFixture, +) -> None: + """Reachable only for a post whose button was drawn when there was one, so + it is a card outliving the bridge that drew it. The turn is untouched and + Switch Console can still open it β€” this end is what cannot reach it, and + saying "gone" would retire a session that is running.""" adapter = _adapter() _record(adapter) _channels(adapter).members[CHANNEL] = {USER} - assert _shown(await adapter._handle_callback(_press())) == ACTIVITY_GONE + with caplog.at_level(logging.WARNING): + shown = _shown(await adapter._handle_callback(_press())) + + assert shown == ACTIVITY_FAILED + assert any("nothing to read the log with" in r.getMessage() for r in caplog.records) async def test_a_read_that_fails_tells_the_reader_instead_of_hanging( From 343263a7ffb3b7fd44f1e392dd3a30c02d5173df Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 01:26:02 +0100 Subject: [PATCH 103/120] Let the bundled Mattermost call the port we just published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing the callback port is half of it. Mattermost will not call a private address unless that host is on ServiceSettings.AllowedUntrustedInternalConnections, so a fresh bundled server rejects the press with the Service perfectly reachable, and says so only in its own log. Compose has supplied that host all along; the chart supplied nothing, and the setup instructions it prints did not mention the step. Set it from the Service name in all three forms a bridge might be configured with, gated on the same value as the port. The instructions also read the failure backwards. An unset callback_base_url is a reduced service β€” no buttons, answer by typing β€” but that is the adapter's own check, not a statement about the network. A bridge configured with an address whose port is not published draws the buttons and loses every press, which is the case the port exists to prevent, and was the one case the docs did not name. Say which is which, and say the in-cluster address is in-cluster before saying to use exactly it. Verified with helm lint, helm template and helm install --dry-run over enabled/disabled and bundled/external Mattermost, plus kubeconform on the rendered manifests. Co-Authored-By: Claude Opus 5 --- deploy/remote/helm/switch/README.md | 12 ++++++++-- deploy/remote/helm/switch/templates/NOTES.txt | 24 +++++++++++++++---- .../templates/mattermost/deployment.yaml | 11 +++++++++ deploy/remote/helm/switch/values.yaml | 17 ++++++++----- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/deploy/remote/helm/switch/README.md b/deploy/remote/helm/switch/README.md index 0bac980fa..5b8987a6e 100644 --- a/deploy/remote/helm/switch/README.md +++ b/deploy/remote/helm/switch/README.md @@ -28,7 +28,7 @@ two of the three fail *silently* β€” the pods are healthy and the dashboard work | Gateway dashboard | 3000 | Your operators | You cannot administer Switch | | Agent API + MCP | 8000 | Agents, wherever they run | Remote agents cannot connect; local ones are fine | | Teams bridge listener | 3978 | **Microsoft, from the public internet** | The Teams bridge half-works, silently | -| Collaboration callbacks | 8081 | Your Mattermost server | Mattermost cards carry no buttons; requests are answered by typing | +| Collaboration callbacks | 8081 | Your Mattermost server | Cards still show buttons and every press is silently lost | **Only the Teams listener requires public internet exposure**, and only if you run a Microsoft Teams bridge. Slack, Discord and Telegram connect *outbound* @@ -39,7 +39,15 @@ back: a button press is delivered by the Mattermost server over HTTP, to an address Switch hands it when it draws the card. So it needs port 8081 reachable from Mattermost β€” usually a cluster-internal Service port and nothing public. Set `switchCore.collaborationCallback.enabled` and `helm install` prints the -address to configure on the bridge. +address to configure on the bridge; the bundled Mattermost is told to accept +that address at the same time, which it will otherwise refuse as a private +host. A Mattermost you run yourself needs the same entry adding by hand +(`ServiceSettings.AllowedUntrustedInternalConnections`). + +A bridge with no `callback_base_url` draws no buttons and its requests are +answered by typing, which is a reduced service and says so in the logs. A +bridge with an address that nothing can reach is the failure this port exists +to prevent: the buttons are drawn and the presses go nowhere. If your agents all run inside the cluster or on operator machines that can reach it privately, nothing here needs to be on the public internet. diff --git a/deploy/remote/helm/switch/templates/NOTES.txt b/deploy/remote/helm/switch/templates/NOTES.txt index 99ca8e288..3070baa29 100644 --- a/deploy/remote/helm/switch/templates/NOTES.txt +++ b/deploy/remote/helm/switch/templates/NOTES.txt @@ -93,17 +93,33 @@ in-cluster half. See docs/bridges/TEAMS_SETUP.md. ── Mattermost button presses ─────────────────────────────────────────────────── Service {{ include "switch.switchCoreHost" . }} now publishes port {{ .Values.switchCore.collaborationCallback.port }}. -Give the Mattermost bridge exactly this callback_base_url: + +{{ if .Values.mattermost.enabled -}} +For a bridge pointed at the Mattermost this chart deploys β€” and only that one, +since the address below resolves inside this cluster and nowhere else β€” set its +callback_base_url to exactly: http://{{ include "switch.switchCoreHost" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.switchCore.collaborationCallback.port }} That is the address the Mattermost *server* calls, not one a browser follows, so it stays the cluster-internal name even where Mattermost itself is reached -publicly. A Mattermost outside this cluster needs a route you own β€” the chart -renders none. +publicly. This release has already told that server it may call it. + +For a Mattermost anywhere else, route the port to it yourself, and add the name +you route it from to that server's ServiceSettings.AllowedUntrustedInternalConnections +β€” a space-separated list of hosts. Mattermost refuses to call a private address +it has not been given, and says so only in its own log. +{{- else -}} +This release deploys no Mattermost, so the bridge points at one you run. Route +this port to it yourself, on a name that server can resolve, and add that name +to its ServiceSettings.AllowedUntrustedInternalConnections β€” a space-separated +list of hosts. Mattermost refuses to call a private address it has not been +given, and says so only in its own log. +{{- end }} Leave callback_base_url unset and the bridge still works: its cards arrive with -no buttons and are answered by typing. +no buttons and are answered by typing. Setting an address nothing can reach is +the one combination to avoid β€” the buttons are drawn and the presses vanish. {{- end }} ⚠ BACK UP YOUR DATA β€” this chart ships no backup jobs. diff --git a/deploy/remote/helm/switch/templates/mattermost/deployment.yaml b/deploy/remote/helm/switch/templates/mattermost/deployment.yaml index 66ba73cf7..ecb519849 100644 --- a/deploy/remote/helm/switch/templates/mattermost/deployment.yaml +++ b/deploy/remote/helm/switch/templates/mattermost/deployment.yaml @@ -64,6 +64,17 @@ spec: value: "switchdash" - name: MM_TEAMSETTINGS_MAXUSERSPERTEAM value: {{ .Values.mattermost.maxUsersPerTeam | default 1000 | quote }} + {{- if .Values.switchCore.collaborationCallback.enabled }} + {{- $core := include "switch.switchCoreHost" . }} + # Mattermost will not call a private address unless it is named + # here, so publishing the callback port is only half of it: without + # this the press reaches this server and stops, leaving a line in + # its log and nothing at all on the card. All three forms of the + # Service name, because the allowlist matches the host in the URL + # exactly and the bridge is configured by hand with one of them. + - name: MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS + value: "{{ $core }} {{ $core }}.{{ .Release.Namespace }} {{ $core }}.{{ .Release.Namespace }}.svc.cluster.local" + {{- end }} readinessProbe: httpGet: path: /api/v4/system/ping diff --git a/deploy/remote/helm/switch/values.yaml b/deploy/remote/helm/switch/values.yaml index c97d54465..3ef1216b3 100644 --- a/deploy/remote/helm/switch/values.yaml +++ b/deploy/remote/helm/switch/values.yaml @@ -236,14 +236,19 @@ switchCore: # Off by default for the same reason the Teams block is: bridges are created # at runtime in the gateway, not in Helm values, so the chart cannot tell # whether you have a Mattermost bridge. Leaving it off is a reduced service - # rather than a broken one β€” the bridge says so at startup, and its cards - # arrive with no buttons and stay answerable by typing. + # rather than a broken one, PROVIDED the bridge's callback_base_url is also + # unset: that is what the adapter checks before drawing a button, and it says + # so at startup. A bridge configured with an address whose port is not + # published here is the bad case β€” the cards carry buttons, and every press + # is lost. Enable this, or clear the address; do not leave the two disagreeing. # # The in-cluster Mattermost this chart deploys needs only the Service port - # below; `helm install` prints the callback_base_url to give that bridge. A - # Mattermost OUTSIDE the cluster needs a route you own: this is a port - # switch-core binds, not necessarily a published host port, and the chart - # renders no Ingress for it. + # below, and is told to accept that address when this is enabled β€” Mattermost + # refuses to call a private host it has not been given. `helm install` prints + # the callback_base_url to configure on the bridge. A Mattermost OUTSIDE the + # cluster needs a route you own and an allowlist entry of its own: this is a + # port switch-core binds, not necessarily a published host port, and the + # chart renders no Ingress for it. collaborationCallback: enabled: false # Bind port inside the container. The chart sets no environment variable From e8092ecac4f8f2506396f3ceccfcc5730075a9a3 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 01:30:20 +0100 Subject: [PATCH 104/120] Stop treating a bundled server as the test for where Mattermost is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The callback instructions branched on whether this chart deploys Mattermost, and then described that branch as a network location. It is not one: a Mattermost somebody else manages in this same namespace reaches the Service by the same name, and was being sent to route a port it can already reach. The address now turns on whether Mattermost is in the cluster, and the bundled server is named only where it actually differs β€” the allowlist entry, which this chart makes and nobody else's does. Also stop promising the failure is silent. A press against an address the server will not call fails; whether the client shows that, or shows nothing, is the client's business and not something this chart knows. Re-rendered: lint clean, both NOTES branches, kubeconform 18/18 after the same pre-existing duplicate-label strip. Co-Authored-By: Claude Opus 5 --- deploy/remote/helm/switch/README.md | 6 ++-- deploy/remote/helm/switch/templates/NOTES.txt | 34 +++++++++---------- deploy/remote/helm/switch/values.yaml | 13 +++---- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/deploy/remote/helm/switch/README.md b/deploy/remote/helm/switch/README.md index 5b8987a6e..fdd164ae4 100644 --- a/deploy/remote/helm/switch/README.md +++ b/deploy/remote/helm/switch/README.md @@ -28,7 +28,7 @@ two of the three fail *silently* β€” the pods are healthy and the dashboard work | Gateway dashboard | 3000 | Your operators | You cannot administer Switch | | Agent API + MCP | 8000 | Agents, wherever they run | Remote agents cannot connect; local ones are fine | | Teams bridge listener | 3978 | **Microsoft, from the public internet** | The Teams bridge half-works, silently | -| Collaboration callbacks | 8081 | Your Mattermost server | Cards still show buttons and every press is silently lost | +| Collaboration callbacks | 8081 | Your Mattermost server | Cards still show buttons and every press fails | **Only the Teams listener requires public internet exposure**, and only if you run a Microsoft Teams bridge. Slack, Discord and Telegram connect *outbound* @@ -46,8 +46,8 @@ host. A Mattermost you run yourself needs the same entry adding by hand A bridge with no `callback_base_url` draws no buttons and its requests are answered by typing, which is a reduced service and says so in the logs. A -bridge with an address that nothing can reach is the failure this port exists -to prevent: the buttons are drawn and the presses go nowhere. +bridge with an address the Mattermost server cannot reach is the failure this +port exists to prevent: the buttons are still drawn, and every press fails. If your agents all run inside the cluster or on operator machines that can reach it privately, nothing here needs to be on the public internet. diff --git a/deploy/remote/helm/switch/templates/NOTES.txt b/deploy/remote/helm/switch/templates/NOTES.txt index 3070baa29..8f60bd908 100644 --- a/deploy/remote/helm/switch/templates/NOTES.txt +++ b/deploy/remote/helm/switch/templates/NOTES.txt @@ -94,32 +94,32 @@ in-cluster half. See docs/bridges/TEAMS_SETUP.md. ── Mattermost button presses ─────────────────────────────────────────────────── Service {{ include "switch.switchCoreHost" . }} now publishes port {{ .Values.switchCore.collaborationCallback.port }}. -{{ if .Values.mattermost.enabled -}} -For a bridge pointed at the Mattermost this chart deploys β€” and only that one, -since the address below resolves inside this cluster and nowhere else β€” set its -callback_base_url to exactly: +For a Mattermost running inside this cluster, set the bridge's callback_base_url +to exactly: http://{{ include "switch.switchCoreHost" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.switchCore.collaborationCallback.port }} That is the address the Mattermost *server* calls, not one a browser follows, so it stays the cluster-internal name even where Mattermost itself is reached -publicly. This release has already told that server it may call it. +publicly. For a Mattermost outside this cluster, route this port to it yourself +β€” the chart renders no Ingress β€” and the address is whatever you route it from. + +Either way, that host has to be on the Mattermost server's +ServiceSettings.AllowedUntrustedInternalConnections: a whitespace-separated list, +matched against the URL before DNS resolution. Mattermost will not call a private +address it has not been given, and says so only in its own log. -For a Mattermost anywhere else, route the port to it yourself, and add the name -you route it from to that server's ServiceSettings.AllowedUntrustedInternalConnections -β€” a space-separated list of hosts. Mattermost refuses to call a private address -it has not been given, and says so only in its own log. +{{ if .Values.mattermost.enabled -}} +This release has already put the Service name there for the Mattermost it +deploys. Any other server needs the entry adding by hand. {{- else -}} -This release deploys no Mattermost, so the bridge points at one you run. Route -this port to it yourself, on a name that server can resolve, and add that name -to its ServiceSettings.AllowedUntrustedInternalConnections β€” a space-separated -list of hosts. Mattermost refuses to call a private address it has not been -given, and says so only in its own log. +This release deploys no Mattermost, so that entry is yours to add. {{- end }} -Leave callback_base_url unset and the bridge still works: its cards arrive with -no buttons and are answered by typing. Setting an address nothing can reach is -the one combination to avoid β€” the buttons are drawn and the presses vanish. +Leave callback_base_url unset and the bridge draws no buttons at all: its +requests are answered by typing, which is a reduced service and logs itself. +Setting an address the server cannot reach is the worse case β€” the buttons are +still drawn, and every press fails. {{- end }} ⚠ BACK UP YOUR DATA β€” this chart ships no backup jobs. diff --git a/deploy/remote/helm/switch/values.yaml b/deploy/remote/helm/switch/values.yaml index 3ef1216b3..a11e23715 100644 --- a/deploy/remote/helm/switch/values.yaml +++ b/deploy/remote/helm/switch/values.yaml @@ -240,13 +240,14 @@ switchCore: # unset: that is what the adapter checks before drawing a button, and it says # so at startup. A bridge configured with an address whose port is not # published here is the bad case β€” the cards carry buttons, and every press - # is lost. Enable this, or clear the address; do not leave the two disagreeing. + # fails. Enable this, or clear the address; do not leave the two disagreeing. # - # The in-cluster Mattermost this chart deploys needs only the Service port - # below, and is told to accept that address when this is enabled β€” Mattermost - # refuses to call a private host it has not been given. `helm install` prints - # the callback_base_url to configure on the bridge. A Mattermost OUTSIDE the - # cluster needs a route you own and an allowlist entry of its own: this is a + # Any Mattermost inside the cluster needs only the Service port below, and + # the one this chart deploys is also told to accept that address when this is + # enabled β€” Mattermost refuses to call a private host it has not been given, + # so a server you manage yourself needs that entry adding even in-cluster. + # `helm install` prints the callback_base_url to configure on the bridge. A + # Mattermost OUTSIDE the cluster needs a route you own as well: this is a # port switch-core binds, not necessarily a published host port, and the # chart renders no Ingress for it. collaborationCallback: From e5530f720f569b09dc9437380fb8f86a4b30e31e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 09:34:25 +0100 Subject: [PATCH 105/120] Keep the rest of a long remark where Slack will expand it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card's title is one line. A remark is not bounded by one, and the assistant-message branch put the whole thing there, so anything past 200 characters was cut and thrown away β€” while a tool call beside it already carried its overflow in the card's detail, the one part of a plan block Slack offers to expand. A remark longer than its title now keeps the rest in that detail. The title stays a folded one-line preview, because four lines of sentence in a list of calls reads as four things happening; the detail keeps the paragraph breaks, because that is where the remark is read rather than scanned. A remark that fits its title gets no detail at all β€” an expansion holding what is already on the line is a control that does nothing. The 750-character budget is ours, like the others here: fifty cards at that size stay inside the same 39,000 the text fallback is held to, so a talkative turn cannot be what makes a post too large to send. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 26 +++++++-- .../collaboration/test_session_activity.py | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 8f1277ad2..c7a01c836 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -129,6 +129,12 @@ _MAX_PLAN_TITLE = 150 _MAX_PLAN_TASK_TITLE = 200 _MAX_PLAN_TASK_DETAILS = 200 +# Prose gets more room than a tool result because it is read rather than +# scanned, and because a card's detail is the one part of this block Slack will +# expand on request. Fifty cards at this budget stay inside the same 39,000 the +# text fallback is held to, so a talkative turn cannot be what makes a post too +# large to send. +_MAX_SAID_DETAILS = 750 # A local display budget, not a claimed Slack rich_text protocol limit. # Preserve the decision/answer before spending the remainder on context. _MAX_RESOLVED_DETAILS = 2800 @@ -1489,16 +1495,26 @@ def _plan_task(item: Item) -> dict[str, Any]: Slack has three and the other two are a spinner and an error, both of which say something worse β€” so the marker carries the distinction alone here, where on every other platform the glyph column carries it. + + A remark longer than its title keeps the rest in the card's detail, which is + the part Slack offers to expand. The title is a one-line preview and folds + the paragraph breaks out, because four lines of sentence in a list of calls + reads as four things happening; the detail keeps them, because that is where + the remark is read rather than scanned. A remark that fits its title gets no + detail at all β€” an expansion holding what is already on the line is a + control that does nothing. """ if item.kind == "assistant-message": - return { + said = plain_text(item.text) + line = f"{SAID_MARKER} {' '.join(said.split())}" + card: dict[str, Any] = { "task_id": _task_id(item.item_id), - "title": _truncate( - f"{SAID_MARKER} {plain_text(' '.join(item.text.split()))}", - _MAX_PLAN_TASK_TITLE, - ), + "title": _truncate(line, _MAX_PLAN_TASK_TITLE), "status": "complete", } + if len(line) > _MAX_PLAN_TASK_TITLE: + card["details"] = _rich_text(_truncate(said, _MAX_SAID_DETAILS)) + return card title = plain_text(item.title) if item.title else "" if item.status in ("failed", "declined"): title = f"{_ACTIVITY[item.status]} {title}".strip() diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 073438665..dd5783a26 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -848,6 +848,59 @@ async def test_a_paragraph_is_folded_onto_the_one_line_its_card_gives_it() -> No assert drawn.blocks[0]["tasks"][0]["title"] == "Β» First thought. Second thought." +def _detail(card: dict[str, object]) -> str: + """What Slack shows when a reader expands the card.""" + rich = card["details"] + assert isinstance(rich, dict) + section = rich["elements"][0] + return str(section["elements"][0]["text"]) + + +async def test_a_remark_too_long_for_its_line_keeps_the_rest_where_it_expands() -> None: + """A card title is one line and a remark is not bounded by one. Cutting it + there threw the rest away, which is the part a reader opened the block for + β€” the detail is the one place in this block Slack offers to expand.""" + said = "Right. " + "The failure is in the retry path. " * 12 + drawn = render_activity_plan([_said(said)], _turn("completed")) + + card = drawn.blocks[0]["tasks"][0] + assert card["title"].endswith("…") + assert len(card["title"]) <= 200 + assert _detail(card).startswith("Right. The failure is in the retry path.") + assert len(_detail(card)) > len(card["title"]) + + +async def test_a_remark_that_fits_its_line_is_not_given_an_expansion() -> None: + """An expansion holding what is already on the line is a control that does + nothing, and a reader who opens one learns that the hard way.""" + drawn = render_activity_plan([_said("All green.")], _turn("completed")) + + assert "details" not in drawn.blocks[0]["tasks"][0] + + +async def test_the_expansion_keeps_the_breaks_the_line_had_to_fold_out() -> None: + """Folding is what stops a paragraph reading as four separate steps in a + list of calls. That reason is about the line; it does not apply to the body + behind it, where the breaks are how the remark was written.""" + said = "First thought.\n\nSecond thought. " + "More on that. " * 20 + drawn = render_activity_plan([_said(said)], _turn("completed")) + + card = drawn.blocks[0]["tasks"][0] + assert "\n" not in card["title"] + assert "First thought.\n\nSecond thought." in _detail(card) + + +async def test_a_remark_longer_than_the_expansion_is_still_cut_somewhere() -> None: + """Fifty cards on one message, and a host that can write without limit. The + budget is what keeps a talkative turn from being the thing that makes a post + too large for Slack to accept at all.""" + drawn = render_activity_plan([_said("word " * 4000)], _turn("completed")) + + detail = _detail(drawn.blocks[0]["tasks"][0]) + assert len(detail) <= 750 + assert detail.endswith("…") + + async def test_the_header_still_counts_calls_rather_than_everything_drawn() -> None: """The collapsed header is the whole message for most readers. Counting remarks in "N tool calls" would inflate every turn the agent talked in.""" From 6fb4e3db2810c10b5c2562b8ac2a507597fb0b1c Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 09:42:54 +0100 Subject: [PATCH 106/120] Use the card-detail helper the file already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new tests defined a second `_detail` rather than reusing the one at the top of the file. Same shape, same assertion, so nothing behaved differently β€” but a later definition shadows the earlier one for every caller, so the older tests were silently running against the newer helper. Ruff does not catch it: the first is used before the second appears, so it is not a redefinition-while-unused. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/test_session_activity.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index dd5783a26..71c3ece82 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -848,14 +848,6 @@ async def test_a_paragraph_is_folded_onto_the_one_line_its_card_gives_it() -> No assert drawn.blocks[0]["tasks"][0]["title"] == "Β» First thought. Second thought." -def _detail(card: dict[str, object]) -> str: - """What Slack shows when a reader expands the card.""" - rich = card["details"] - assert isinstance(rich, dict) - section = rich["elements"][0] - return str(section["elements"][0]["text"]) - - async def test_a_remark_too_long_for_its_line_keeps_the_rest_where_it_expands() -> None: """A card title is one line and a remark is not bounded by one. Cutting it there threw the rest away, which is the part a reader opened the block for From d0215be62c81f18a6f8c56dd998bc5697fd5fa3f Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 10:08:58 +0100 Subject: [PATCH 107/120] Draw a turn in one message and delete the option not to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every adapter drew a turn as a single message carrying both its ticking status and its tool detail. `separate_activity_log` offered the other layout β€” a compact status plus a second, expandable log β€” and no platform ever asked for it, so the second message was reserved, posted and edited only in a test's stub adapter. The flag, the `tool_log` content flag, the renderer branch that drew the log, and the anchor fields that remembered where it was posted all go. A turn's activity is one message, and the one exception stays what it already was: a problem somebody has to act on, under `separate_attention_slot`. Anchors persisted by an older build still carry `log_ref` and `log_state`, so restore drops them rather than failing a turn in flight over keys nothing reads. Both were always empty β€” nothing ever set the flag β€” so no channel holds a message this leaves behind. The reviewer's regression test for a header disclosing the steps it left out is retargeted at the plan block, which is the live path. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/adapter.py | 13 +--- .../bridges/collaboration/discord/adapter.py | 5 -- .../collaboration/mattermost/adapter.py | 6 -- .../bridges/collaboration/session/outbound.py | 76 ++----------------- .../collaboration/session/renderers/slack.py | 23 ------ .../bridges/collaboration/slack/adapter.py | 17 +---- .../bridges/collaboration/teams/adapter.py | 6 -- .../bridges/collaboration/telegram/adapter.py | 9 --- .../collaboration/test_discord_sdk_only.py | 1 - .../test_session_compact_presentation.py | 13 ++-- .../test_session_review_regressions.py | 23 ++++-- .../collaboration/test_slack_sdk_only.py | 35 --------- .../collaboration/test_teams_sdk_only.py | 1 - .../collaboration/test_telegram_sdk_only.py | 1 - 14 files changed, 35 insertions(+), 194 deletions(-) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 4f688a3f4..3ba8ee7ea 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -90,7 +90,6 @@ class TurnActivity: items: list[Item] turn: TurnUpsert elapsed_seconds: float | None = None - tool_log: bool = False status_only: bool = False # Stable recovery marker for a reserved platform post, not an answer token. publication_token: str | None = None @@ -249,18 +248,14 @@ class ActivityMarkRefused(RuntimeError): class CollaborationAdapter(ABC): # Platforms opt in only when their SDK request and activity rendering is ready. publishes_sdk_sessions: ClassVar[bool] = False - # Keep the ticking status and expandable tool log in separate messages. - separate_activity_log: ClassVar[bool] = False #: Whether a problem somebody has to act on gets a message of its own. #: #: One durable reply per turn, reused as the problem changes and cleared - #: when it goes away β€” never a second one. Separate from - #: `separate_activity_log` because the two answer different questions: a - #: platform can want one compact status carrying its own tool counts (so - #: no separate log) and still want a failure to arrive as something a - #: reader is notified about rather than as an edit to a message they have - #: already scrolled past. + #: when it goes away β€” never a second one. A turn's own activity stays in + #: the one message it is drawn in; this is the exception, because a failure + #: has to arrive as something a reader is notified about rather than as an + #: edit to a message they have already scrolled past. separate_attention_slot: ClassVar[bool] = False #: Whether a mention is the only way an attention post reaches anyone. diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index e9c4178e1..3f1446b89 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -411,11 +411,6 @@ class DiscordAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True - # One status per turn, holding its own tool counts. A second message would - # be a second notification for everyone in the thread, and the thread is - # already where the detail is allowed to live. - separate_activity_log: ClassVar[bool] = False - # A problem somebody has to act on gets its own reply, because the status # it would otherwise be an edit to is a message they have already read. separate_attention_slot: ClassVar[bool] = True diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index 783dff140..1a029d70a 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -264,12 +264,6 @@ def _ephemeral(text: str) -> dict[str, Any]: class MattermostAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True - #: One message, not two. The compact status carries its own tool counts, so - #: there is nothing left for a separate log to hold that is worth a second - #: message in the thread β€” and an expandable tool history is a later piece - #: of work, not something to approximate with an extra post now. - separate_activity_log: ClassVar[bool] = False - #: A problem somebody has to act on still gets its own reply, so it #: notifies rather than arriving as a silent edit to a status the reader #: has already scrolled past. One per turn, cleared when it clears. diff --git a/core/switch_core/bridges/collaboration/session/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index e67598768..e2472814c 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -102,8 +102,6 @@ class _Anchor: thread_root_id: str | None reaction_ref: str | None agent_name: str = "" - log_ref: str | None = None - log_state: tuple[tuple[str, int], ...] | None = None session_url: str | None = None status_state: tuple[str, str, str | None] | None = None @@ -244,7 +242,6 @@ def __init__( ) self._adapter = adapter self._abandoned: OrderedDict[tuple[str, ...], None] = OrderedDict() - self._separate_activity_log = getattr(adapter, "separate_activity_log", False) self._separate_attention_slot = getattr( adapter, "separate_attention_slot", False ) @@ -346,27 +343,6 @@ def _status_state( ) return (turn.turn_id, f"{turn.status}:{drawn}", session_url) - def _log_state( - self, items: list[Item], turn: TurnUpsert - ) -> tuple[tuple[str, int], ...]: - """What a separate tool log is already showing. - - A tool log is the tool calls β€” `render_activity` drops everything else - before drawing one β€” so what the agent said is not a change to it and - redrawing for one would spend an edit on a message that would come back - identical. - - No adapter sets `separate_activity_log` today, so nothing observes this - and no test can distinguish it from the version that counts everything. - It is written for the behaviour the flag asks for rather than for the - behaviour nothing currently exercises. - """ - return tuple( - (item.item_id, item.revision) - for item in items - if item.kind == "tool-activity" - ) + ((turn.status, 0),) - async def recorded_commands(self, session_id: str) -> set[str]: return ( await self._journal.recorded_commands(session_id) @@ -469,12 +445,12 @@ async def draw() -> bool: try: saved = record.data.get("anchor") if saved: + # An older build's rows carry two keys the anchor no longer + # has. A turn in flight across that upgrade has to be taken + # up, not lost to a constructor argument nothing reads. + saved.pop("log_ref", None) + saved.pop("log_state", None) anchor = _Anchor(**saved) - anchor.log_state = ( - tuple(tuple(entry) for entry in saved["log_state"]) - if saved.get("log_state") is not None - else None - ) if saved.get("status_state") is not None: anchor.status_state = ( saved["status_state"][0], @@ -766,8 +742,8 @@ async def _publish( and stays that way as far as the adapter, which is the only thing that knows where on its own platform prose can be shown and where it would only be the reply said twice. Whatever must not see it narrows for - itself: `_status_state` and `_log_state` below, and every renderer that - draws a list of calls rather than a turn. + itself: `_status_state` below, and every renderer that draws a list + of calls rather than a turn. Retain anchors until the final summary edit succeeds. A failed final publication retries the same messages instead of posting @@ -821,8 +797,6 @@ async def _publish( elapsed_seconds=elapsed_seconds, ) - if self._separate_activity_log: - drawn = await self._draw_log(anchor, items, turn) and drawn # What the messages are now showing, so the next process to pick this # turn up can tell a redraw it owes from one nobody would see. await self._save_anchor(anchor) @@ -889,7 +863,6 @@ async def _begin( items, turn, elapsed_seconds, - status_only=self._separate_activity_log, session_url=session_url, ), thread_root_id, @@ -918,7 +891,6 @@ async def _begin( status_state=self._status_state(turn, items, elapsed_seconds, session_url) if self._journal is None else None, - log_state=self._log_state(items, turn), ) async def _edit( @@ -948,7 +920,6 @@ async def _edit( items, turn, elapsed_seconds, - status_only=self._separate_activity_log, session_url=anchor.session_url, ), anchor.thread_root_id, @@ -973,39 +944,6 @@ async def _edit( anchor.status_state = state return True - async def _draw_log( - self, anchor: _Anchor, items: list[Item], turn: TurnUpsert - ) -> bool: - # Reserve the second reply before requests arrive, even before the first tool. - state = self._log_state(items, turn) - if state == anchor.log_state and anchor.log_ref: - return True - content = TurnActivity(items, turn, tool_log=True) - try: - if anchor.log_ref is None: - anchor.log_ref = await self._post_activity( - anchor.channel_id, - anchor.agent_name, - content, - anchor.thread_root_id, - "log", - ) - else: - await self._adapter.update_rich( - anchor.channel_id, - anchor.agent_name, - anchor.log_ref, - content, - anchor.thread_root_id, - ) - except RichContentThrottled: - raise - except RichContentFailed: - logger.exception("Could not update tool log for turn %s", turn.turn_id) - return False - anchor.log_state = state - return True - def _wanted_mark(self, turn: TurnUpsert) -> ActivityMark: """Which mark this turn's current state earns. diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index c7a01c836..9555dd849 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -923,7 +923,6 @@ def render_activity( turn: TurnUpsert, *, elapsed_seconds: float | None = None, - tool_log: bool = False, status_only: bool = False, ) -> SlackMessage: """One turn, as the channel sees it: what was said, over what was done. @@ -978,28 +977,6 @@ def render_activity( ], ) return SlackMessage(text=state, blocks=[_context(state)]) - if tool_log: - did = [item for item in items if item.kind == "tool-activity"] - if not did: - text = ( - "No tool calls." if turn.status in TURN_ENDED else "No tool calls yet." - ) - return SlackMessage(text=text, blocks=[_context(text)]) - plan = _plan(did, did, turn) - count = len(did) - title = f"{count} tool {'call' if count == 1 else 'calls'}" - hidden = max(0, count - _MAX_PLAN_TASKS) - if hidden: - title += f" Β· {hidden} earlier not shown" - running = _running(did, turn) - if running: - title += f" Β· {running[1]}" - plan["title"] = _truncate(title, _MAX_PLAN_TITLE) - for task, item in zip(plan["tasks"], did[-_MAX_PLAN_TASKS:]): - _settled(task, item, turn) - return SlackMessage( - text=plan["title"] + "\n" + "\n".join(_activity_lines(did)), blocks=[plan] - ) said = [item for item in items if item.kind == "assistant-message"] did = [item for item in items if item.kind == "tool-activity"] diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 66eaaf985..ef51fe80b 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -211,12 +211,6 @@ class _ActivityStream: class SlackAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True - #: One message per turn: the plan, with the turn's state and clock as its - #: header. The status used to be a second message purely so the clock could - #: advance without rebuilding the log and collapsing a plan the reader had - #: open β€” a `plan_update` chunk moves the header without touching the cards, - #: so the split has nothing left to buy. - separate_activity_log: ClassVar[bool] = False separate_attention_slot: ClassVar[bool] = True #: Cheap on a stream in a way it never was on an edit. An append is rate #: limited at 100+/min and redraws nothing, so the clock ticks in the one @@ -685,7 +679,7 @@ def _streamable(self, content: RichContent, thread_root_id: str | None) -> bool: """ if not isinstance(content, TurnActivity): return False - if content.status_only or content.error_summary or content.tool_log: + if content.status_only or content.error_summary: return False missing = ( "no thread to open it in" @@ -943,21 +937,14 @@ def _render_rich( elapsed_seconds=content.elapsed_seconds, session_url=content.session_url, ) - if not (content.status_only or content.tool_log) + if not content.status_only else render_activity( content.items, content.turn, elapsed_seconds=content.elapsed_seconds, - tool_log=content.tool_log, status_only=content.status_only, ) ) - if content.tool_log and not content.error_summary: - # Notifications/text-only clients get the compact plan header; - # the expandable blocks retain the complete displayed tool log. - message = SlackMessage( - text=message.text.split("\n", 1)[0], blocks=message.blocks - ) message = with_session_context( message, session_url=content.session_url diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index fea87d6bb..0f5e46d7d 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -566,12 +566,6 @@ class TeamsAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True - # One compact status per turn rather than a status and a tool log. A posts - # channel shows a thread as a stack of replies with no collapsing, so a - # second message per turn is a second thing to scroll past for every turn - # in the post. - separate_activity_log: ClassVar[bool] = False - # A problem gets its own message. An edit to the status is not something # Teams notifies anyone about, so a failure folded into it reaches whoever # happens to be looking. diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index d776d439e..968eb9410 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -402,15 +402,6 @@ class TelegramAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True - #: One message for the whole of a turn's progress. - #: - #: Telegram prices edits per chat rather than per message, so a log posted - #: separately would double the edit rate of every turn and spend the chat's - #: budget on the half nobody is waiting for. It costs the reader nothing: - #: the calls travel in the status message itself once the turn has ended, - #: collapsed into a block their own client draws. - separate_activity_log: ClassVar[bool] = False - #: A problem somebody has to act on gets its own message. #: #: An edit does not notify on Telegram. Folded into the status, a failure diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py index 7e2933851..0b5378b36 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -376,7 +376,6 @@ def test_discord_names_the_asker_because_nothing_else_reaches_them() -> None: assert adapter.notifies_only_by_mention is True assert adapter.separate_attention_slot is True - assert adapter.separate_activity_log is False assert adapter.supports_activity_reactions is True assert adapter.activity_reactions_per_agent is False diff --git a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py index 3e1903337..a8cbb94f6 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py @@ -32,23 +32,22 @@ def test_waiting_link_is_visible_in_the_compact_status_without_expanding(): async def test_plan_keeps_tool_details_but_fallback_is_compact(): adapter, _ = _adapter() message = adapter._render_rich( - TurnActivity(await _items(), _turn("running"), tool_log=True, session_url=URL) + TurnActivity(await _items(), _turn("running"), session_url=URL) ) - assert len(message.blocks) == 1 assert message.blocks[0]["type"] == "plan" assert len(message.blocks[0]["tasks"]) > 1 - assert URL not in json.dumps(message.blocks) + assert URL not in json.dumps(message.blocks[0]) assert len(message.text.splitlines()) == 1 -async def test_completed_log_keeps_tools_without_console_links(): +async def test_a_finished_plan_keeps_its_tools_and_stays_off_the_fallback(): adapter, _ = _adapter() message = adapter._render_rich( - TurnActivity(await _items(), _turn("completed"), tool_log=True, session_url=URL) + TurnActivity(await _items(), _turn("completed"), session_url=URL) ) - assert len(message.blocks) == 1 assert message.blocks[0]["type"] == "plan" - assert URL not in json.dumps(message.blocks) + assert len(message.blocks[0]["tasks"]) > 1 + assert URL not in json.dumps(message.blocks[0]) assert "Worked for" not in message.text diff --git a/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py b/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py index 8fff5c1f3..08f730129 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py @@ -9,7 +9,7 @@ from switch_core.bridges.collaboration.adapter import RichContentThrottled, TurnActivity from switch_core.bridges.collaboration.session.renderers.slack import ( - render_activity, + render_activity_plan, render_request, ) from switch_core.bridges.collaboration.slack import adapter as slack_module @@ -30,16 +30,25 @@ @pytest.mark.parametrize("status", ["running", "completed"]) -def test_tool_log_header_always_discloses_omitted_steps(status): - items = [_item(itemId=f"step-{i}", title="x" * 500) for i in range(60)] - message = render_activity(items, _turn(status), tool_log=True, elapsed_seconds=90) - plan = message.blocks[0] +def test_a_plan_header_always_discloses_the_steps_it_left_out(status): + """A list cut to what Slack takes says so, whatever the turn is doing.""" + items = [_item(itemId=f"step-{i}", title=f"Step {i}") for i in range(60)] + + plan = render_activity_plan(items, _turn(status), elapsed_seconds=90).blocks[0] + assert len(plan["tasks"]) == 50 - assert "60 tool calls" in plan["title"] - assert "10 earlier not shown" in plan["title"] + assert "10 earlier lines not shown" in plan["title"] assert plan["tasks"][0]["task_id"] == "step-10" +def test_a_finished_plan_counts_the_steps_it_ran_not_the_ones_it_shows(): + items = [_item(itemId=f"step-{i}", title=f"Step {i}") for i in range(60)] + + plan = render_activity_plan(items, _turn("completed"), elapsed_seconds=90).blocks[0] + + assert "60 tool calls" in plan["title"] + + def text_size(value): if isinstance(value, dict): return len(value.get("text", "")) + sum( diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py index 261cae83a..db7774d40 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py @@ -6,14 +6,12 @@ import pytest from slack_sdk.socket_mode.request import SocketModeRequest -from switch_core.bridges.collaboration.session.outbound import SessionTurnActivity from switch_core.bridges.collaboration.slack.adapter import ( SlackAdapter, SlackConnectionConfig, ) from .slack_fakes import FakeWebClient -from .test_session_activity import _turn def adapter(): @@ -76,39 +74,6 @@ def test_native_progress_setting_is_absent_from_registration(): ) -async def test_activity_layout_is_an_adapter_capability_not_a_slack_type_check(): - platform = SimpleNamespace( - separate_activity_log=True, - supports_activity_reactions=True, - post_rich=AsyncMock(side_effect=["C1:status", "C1:log"]), - update_rich=AsyncMock(), - mark_activity=AsyncMock(), - notify_working=AsyncMock(), - ) - activity = SessionTurnActivity(platform) - kwargs = dict( - session_id="s", - channel_id="C1", - thread_root_id="C1:root", - asked_on="C1:asker", - agent_name="worker", - elapsed_seconds=5, - ) - await activity.publish([], _turn("running"), **kwargs) - await activity.publish([], _turn("completed"), **kwargs) - status, log = platform.post_rich.call_args_list - assert status.args[2].status_only - assert log.args[2].tool_log - assert [call.args[1:3] for call in platform.update_rich.call_args_list] == [ - ("worker", "C1:status"), - ("worker", "C1:log"), - ] - assert [call.kwargs["on"] for call in platform.mark_activity.call_args_list] == [ - True, - False, - ] - - async def test_typed_interrupt_still_routes_to_the_global_command(): slack, _ = adapter() slack._on_command = AsyncMock() diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py index 1e80649d0..9b0b4e1af 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py @@ -207,7 +207,6 @@ def test_teams_reaches_a_reader_by_naming_them_and_in_no_other_way() -> None: assert adapter.notifies_only_by_mention is True assert adapter.separate_attention_slot is True - assert adapter.separate_activity_log is False assert adapter.redraws_for_elapsed_time is False # The Bot Connector gives a bot no way to react to a message at all. assert adapter.supports_activity_reactions is False diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py index 8b7800935..aa28239ff 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -148,7 +148,6 @@ def test_telegram_notifies_a_chat_without_anybody_being_named() -> None: assert adapter.notifies_only_by_mention is False assert adapter.separate_attention_slot is True - assert adapter.separate_activity_log is False assert adapter.redraws_for_elapsed_time is False assert adapter.supports_activity_reactions is True assert adapter.activity_reactions_per_agent is False From d0bd90143edbcf9f425b76f15de9047b161c2431 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 10:13:25 +0100 Subject: [PATCH 108/120] Give a remark the room Slack turns out to have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_MAX_SAID_DETAILS` was 750 because fifty cards at 750 stay inside the 39,000 the text fallback is held to. That reasoning does not apply: a remark's detail only ever reaches a plan block, and a plan message's fallback text is its header alone, so the 39,000 was never what a long remark could overrun. Measured against a real workspace instead. Slack took a single card's detail to 99,999 characters without complaint; it took fifty cards of 4,743 characters each and refused the message above that with `msg_blocks_too_long`; and an expanded card showed all 12,000 characters it was given rather than cutting the text itself. So the whole message is what binds, the reader is not what binds, and 2,000 leaves the worst case β€” fifty cards all remarks, all at the budget β€” clear of the refusal by more than a factor of two. The margin is now a test rather than a comment, because nothing bounds the plan as a whole and a later edit to the budget would otherwise fail in a channel instead of a suite. Slack documents none of these numbers and can revoke them, which is the reason for the margin. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 12 ++++++---- .../collaboration/test_session_activity.py | 24 ++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 9555dd849..49bef288d 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -131,10 +131,14 @@ _MAX_PLAN_TASK_DETAILS = 200 # Prose gets more room than a tool result because it is read rather than # scanned, and because a card's detail is the one part of this block Slack will -# expand on request. Fifty cards at this budget stay inside the same 39,000 the -# text fallback is held to, so a talkative turn cannot be what makes a post too -# large to send. -_MAX_SAID_DETAILS = 750 +# expand on request. Measured rather than reasoned about: Slack took a single +# card's detail to 99,999 characters without complaint, took fifty cards of +# 4,743 each and refused the message above that with `msg_blocks_too_long`, and +# showed all 12,000 characters of an expanded card rather than cutting the text +# itself. So the binding constraint is the whole message, and fifty cards at +# this budget leave a margin of more than two under where it was refused β€” +# worth keeping, because Slack documents none of this and can tighten it. +_MAX_SAID_DETAILS = 2000 # A local display budget, not a claimed Slack rich_text protocol limit. # Preserve the decision/answer before spending the remainder on context. _MAX_RESOLVED_DETAILS = 2800 diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 71c3ece82..40066bf5c 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -23,6 +23,8 @@ turn_state, ) from switch_core.bridges.collaboration.session.renderers.slack import ( + _MAX_PLAN_TASKS, + _MAX_SAID_DETAILS, render_activity, render_activity_plan, render_activity_stream, @@ -55,6 +57,12 @@ TURN = "turn-activity" +# Detail per card, with fifty cards in the message, at which Slack stopped +# accepting the post: measured against a real workspace, not documented by +# Slack. A single card was taken to 99,999 characters, and an expanded card +# showed all 12,000 it was given, so this is the one number that binds. +REFUSED_ABOVE = 4743 + async def _projection(*streams: str) -> SessionProjection: source = FixtureEventSource.from_examples( @@ -889,10 +897,24 @@ async def test_a_remark_longer_than_the_expansion_is_still_cut_somewhere() -> No drawn = render_activity_plan([_said("word " * 4000)], _turn("completed")) detail = _detail(drawn.blocks[0]["tasks"][0]) - assert len(detail) <= 750 + assert len(detail) <= _MAX_SAID_DETAILS assert detail.endswith("…") +async def test_fifty_talkative_cards_stay_clear_of_what_slack_refused() -> None: + """Nothing bounds the plan as a whole, so the per-card budget is what has to + hold the worst case: every one of the fifty cards a remark at the budget. + + Slack accepted fifty cards carrying `REFUSED_ABOVE` characters of detail + each and refused the message above that. It documents neither number and + can revoke both, so the worst case has to clear the measurement with room + to spare rather than merely fit inside it. + """ + worst_case = _MAX_PLAN_TASKS * _MAX_SAID_DETAILS + + assert worst_case * 2 < _MAX_PLAN_TASKS * REFUSED_ABOVE + + async def test_the_header_still_counts_calls_rather_than_everything_drawn() -> None: """The collapsed header is the whole message for most readers. Counting remarks in "N tool calls" would inflate every turn the agent talked in.""" From e4ecaa99b716041d93a6a052fcca0e92dc5d5697 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 11:34:00 +0100 Subject: [PATCH 109/120] Bound a Slack turn by what the message weighs, not by the card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-card detail budget could never hold the worst case. A streamed turn draws two fifty-card pages, so a hundred details at any budget worth giving prose is past what Slack takes β€” 3,000 characters a card is 1.9 MB once the serialiser has escaped Japanese, against a ceiling measured at 257,615 bytes. Sizing the per-card number against that would have cut a tenth of real remarks to defend a turn that has never occurred: over 445 real turns the largest message reached a tenth of the ceiling, and removing the cap entirely left that figure unchanged. So the budget is set where the evidence puts it, 3,000, and the worst case is caught on the assembled message instead β€” after the turn is drawn and the card count is finally known. Details are pulled back in steps until the payload fits; both draw paths are guarded, because an ordinary post refuses with a different error at a different size than a stream does. Neither number is documented by Slack, so both sit under what was observed. A shortened detail now ends in a notice rather than an ellipsis. A card's detail has nothing below it, so a cut there loses words for good, and an ellipsis on the end of a sentence is indistinguishable from the author's own punctuation. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 142 +++++++++++++++--- .../collaboration/test_session_activity.py | 135 ++++++++++++++--- 2 files changed, 238 insertions(+), 39 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 49bef288d..218117e89 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -27,6 +27,7 @@ from __future__ import annotations import hashlib +import json import re from dataclasses import dataclass from html import unescape @@ -131,14 +132,46 @@ _MAX_PLAN_TASK_DETAILS = 200 # Prose gets more room than a tool result because it is read rather than # scanned, and because a card's detail is the one part of this block Slack will -# expand on request. Measured rather than reasoned about: Slack took a single -# card's detail to 99,999 characters without complaint, took fifty cards of -# 4,743 each and refused the message above that with `msg_blocks_too_long`, and -# showed all 12,000 characters of an expanded card rather than cutting the text -# itself. So the binding constraint is the whole message, and fifty cards at -# this budget leave a margin of more than two under where it was refused β€” -# worth keeping, because Slack documents none of this and can tighten it. -_MAX_SAID_DETAILS = 2000 +# expand on request. +# +# Where the ceiling is, observed against a real workspace because Slack +# documents none of it. A streamed turn draws up to two fifty-card pages into +# one message; a hundred cards carrying 2,180 ASCII characters of detail each +# was accepted at 257,615 bytes and 2,181 was refused. That is a boundary +# someone watched, not a published contract β€” it sits near 256 KiB, and nothing +# says it is exactly that or that it will hold. The unit is bytes on the wire +# rather than characters: the payload is serialised with `ensure_ascii=True`, so +# a non-ASCII character leaves as a six-byte `\uXXXX` escape and the same +# message would tolerate only about 200 characters a card in Japanese. +# +# Why this number is not sized against that worst case: it does not occur. Over +# 445 real turns the largest message reached a tenth of the ceiling, and +# removing this cap altogether left that figure unchanged, because no turn has +# both many cards and long remarks. 3,000 truncated none of the 1,625 remarks +# measured, where 750 truncated a tenth of them. A turn unlike any of those is +# still possible, and the thing that would catch it is a check on the assembled +# message rather than a smaller number here. +_MAX_SAID_DETAILS = 3000 +# That check's budgets: what a whole drawn message may weigh. This is the bound +# the per-card numbers cannot enforce between them, because how many cards carry +# a detail is not known until a turn is drawn. A hundred cards at 3,000 is +# 300,000 characters, and nearly two million bytes of it in Japanese β€” no +# per-card budget both leaves prose room to breathe and holds that, so the worst +# case is caught on the assembled message instead. +# +# Two numbers because the two paths refuse differently, and both are boundaries +# someone watched rather than published contracts. A stream took a hundred cards +# at 257,615 bytes and refused the next; an ordinary post refused fifty cards of +# 4,743 characters each with a different error, `msg_blocks_too_long`. Each +# budget sits under its measurement, which buys room for the request envelope +# weighed nowhere here β€” channel, timestamp, chunk wrappers β€” and for Slack +# tightening a limit it never published in the first place. +_MAX_STREAM_BYTES = 250_000 +_MAX_POST_BYTES = 220_000 +# How far a detail is pulled back when the assembled message is too big, in +# order. The last step is small rather than absent: a card that quietly lost its +# expansion looks exactly like a remark that never had more to say. +_DETAIL_RETREAT = (1500, 750, 300, 120) # A local display budget, not a claimed Slack rich_text protocol limit. # Preserve the decision/answer before spending the remainder on context. _MAX_RESOLVED_DETAILS = 2800 @@ -149,6 +182,10 @@ _MAX_TASK_ID = 64 +# What a cut detail ends with. Words the reader will not get to see are worth a +# few characters saying so, in language no agent would have written itself. +_TRUNCATED = " […truncated]" + # The three blocks a stream draws its steps in, top to bottom. Slack fixes a # block at the position it was first written and has no call that removes one, # so where each one sits is decided by the order they are created in and cannot @@ -1164,6 +1201,7 @@ def render_activity_plan( blocks.append(_context(title)) if session_url and urlsplit(session_url).scheme in {"https", "http", "switchdash"}: blocks.append(_context(f"<{session_url}|Open in Console app>")) + _fit_details(blocks, _MAX_POST_BYTES) return SlackMessage(text=title, blocks=blocks) @@ -1209,17 +1247,19 @@ def render_activity_stream( glyph beside it is already the thing that says work is happening there. """ shown = [item for item in items if in_activity_log(item)] - return StreamedActivity( - title=_truncate( - turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds), - _MAX_PLAN_TITLE, - ), - session=_session_card(session_url, turn), - blocks=_step_blocks( - [_settled(_plan_task(item), item, turn) for item in shown], - _running(shown, turn), - ), + title = _truncate( + turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds), + _MAX_PLAN_TITLE, + ) + session = _session_card(session_url, turn) + blocks = _step_blocks( + [_settled(_plan_task(item), item, turn) for item in shown], + _running(shown, turn), ) + # The message the steps accumulate into carries the header and the session + # card too, so what they weigh is not available to the steps to spend. + _fit_details(blocks, _MAX_STREAM_BYTES - _weigh([title, session])) + return StreamedActivity(title=title, session=session, blocks=blocks) def _session_card(session_url: str | None, turn: TurnUpsert) -> dict[str, Any]: @@ -1494,7 +1534,7 @@ def _plan_task(item: Item) -> dict[str, Any]: "status": "complete", } if len(line) > _MAX_PLAN_TASK_TITLE: - card["details"] = _rich_text(_truncate(said, _MAX_SAID_DETAILS)) + card["details"] = _rich_text(_truncate_prose(said, _MAX_SAID_DETAILS)) return card title = plain_text(item.title) if item.title else "" if item.status in ("failed", "declined"): @@ -1506,7 +1546,7 @@ def _plan_task(item: Item) -> dict[str, Any]: } details = plain_text(item.text) if item.text else "" if details: - task["details"] = _rich_text(_truncate(details, _MAX_PLAN_TASK_DETAILS)) + task["details"] = _rich_text(_truncate_prose(details, _MAX_PLAN_TASK_DETAILS)) return task @@ -1546,6 +1586,68 @@ def _truncate(text: str, limit: int) -> str: return text if len(text) <= limit else text[: limit - 1] + "…" +def _truncate_prose(text: str, limit: int) -> str: + """`text` inside `limit`, saying plainly when some of it did not fit. + + `_truncate` marks a cut with an ellipsis, which is right for a title: the + full text sits in the detail directly below it, so nothing is lost and a + trailing `…` reads as the preview it is. A detail has nothing below it. A + cut there loses words, and an ellipsis on the end of a sentence is + indistinguishable from the author's own punctuation β€” the reader is left + with prose that looks complete and is not. + """ + keep = limit - len(_TRUNCATED) + if keep <= 0: + return _truncate(text, limit) + return text if len(text) <= limit else text[:keep] + _TRUNCATED + + +def _weigh(payload: object) -> int: + """What `payload` costs Slack, in the bytes its own serialisation produces. + + Characters are the wrong unit and the difference is not small. The request + body goes out with `ensure_ascii=True`, so a character outside ASCII leaves + as a six-byte `\\uXXXX` escape: a Japanese remark costs six times what its + length suggests, and an emoji twelve. + """ + return len(json.dumps(payload).encode()) + + +def _fit_details(blocks: list[dict[str, Any]], limit: int) -> None: + """Pull card details back until `blocks` weighs less than `limit`. + + Nothing is dropped. A detail is shortened, and a shortened detail says so in + the place the missing words would have been, so a reader who opens one is + told rather than left with prose that looks whole. Removing the expansion + outright is the one outcome that would say nothing at all. + + Titles are left alone. They are bounded already, they are what a reader sees + without opening anything, and between them they cannot reach the budget β€” + which is why running out of retreat here is an exception rather than a + smaller cut: it would mean cards arriving from somewhere this was not + written to bound. + """ + if _weigh(blocks) <= limit: + return + details = [ + task["details"]["elements"][0]["elements"][0] + for block in blocks + if block.get("type") == "plan" + for task in block["tasks"] + if "details" in task + ] + for budget in _DETAIL_RETREAT: + for element in details: + element["text"] = _truncate_prose(element["text"], budget) + if _weigh(blocks) <= limit: + return + raise ValueError( + f"A turn drew {len(details)} card details into {_weigh(blocks)} bytes, over " + f"the {limit} Slack will take even with every one cut to " + f"{_DETAIL_RETREAT[-1]} characters." + ) + + def _fit(text: str, limit: int) -> str: """Escape `text` for mrkdwn and keep the result inside `limit`. diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 40066bf5c..0ed29a5e0 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -25,6 +25,7 @@ from switch_core.bridges.collaboration.session.renderers.slack import ( _MAX_PLAN_TASKS, _MAX_SAID_DETAILS, + StreamedActivity, render_activity, render_activity_plan, render_activity_stream, @@ -57,11 +58,20 @@ TURN = "turn-activity" -# Detail per card, with fifty cards in the message, at which Slack stopped -# accepting the post: measured against a real workspace, not documented by -# Slack. A single card was taken to 99,999 characters, and an expanded card -# showed all 12,000 it was given, so this is the one number that binds. -REFUSED_ABOVE = 4743 +# What a whole streamed message may weigh, measured against a real workspace +# and documented by Slack nowhere. A hundred cards β€” the two fifty-card pages a +# stream draws β€” carrying 2,180 ASCII characters of detail each was accepted at +# 257,615 bytes on the wire, and a hundred bytes more was refused. Bytes rather +# than characters: the payload is serialised with `ensure_ascii=True`, so a +# non-ASCII character leaves as a six-byte escape. +ACCEPTED_BYTES = 257_615 + +# The same question asked of an ordinary post, which refuses with a different +# error β€” `msg_blocks_too_long` β€” and so is a separate measurement. Fifty cards +# of 4,743 ASCII characters of detail were accepted and the message above that +# was not, so this many bytes of detail is known to have gone through on that +# path while the blocks around it did too. +POST_ACCEPTED_BYTES = 50 * 4_743 async def _projection(*streams: str) -> SessionProjection: @@ -890,29 +900,116 @@ async def test_the_expansion_keeps_the_breaks_the_line_had_to_fold_out() -> None assert "First thought.\n\nSecond thought." in _detail(card) -async def test_a_remark_longer_than_the_expansion_is_still_cut_somewhere() -> None: - """Fifty cards on one message, and a host that can write without limit. The - budget is what keeps a talkative turn from being the thing that makes a post - too large for Slack to accept at all.""" +async def test_a_remark_longer_than_the_expansion_is_says_so_where_it_was_cut() -> None: + """A detail has nothing below it, so a cut there loses words for good. + + An ellipsis would not tell the reader that: agents write them, and one on + the end of a sentence reads as punctuation rather than as a warning. The + notice has to be something no agent would have typed. + """ drawn = render_activity_plan([_said("word " * 4000)], _turn("completed")) detail = _detail(drawn.blocks[0]["tasks"][0]) assert len(detail) <= _MAX_SAID_DETAILS - assert detail.endswith("…") + assert detail.endswith("[…truncated]") + + +async def test_a_remark_that_fits_is_not_accused_of_being_cut() -> None: + """The notice is a claim about missing text, so it has to be false silently.""" + drawn = render_activity_plan([_said("A short remark. " * 30)], _turn("completed")) + + assert "truncated" not in _detail(drawn.blocks[0]["tasks"][0]) + + +def _talkative(count: int, text: str) -> list[Item]: + """`count` remarks, every one of them at the per-card budget.""" + return [_said(text, item_id=f"said-{index}") for index in range(count)] + +def _streamed_bytes(drawn: StreamedActivity) -> int: + """The turn as the adapter sends it, weighed the way Slack weighs it.""" + return len( + json.dumps( + [ + {"type": "plan_update", "title": drawn.title}, + drawn.session, + *({"type": "blocks", "blocks": [block]} for block in drawn.blocks), + ] + ).encode() + ) + + +async def test_the_worst_streamed_message_is_one_slack_would_accept() -> None: + """A hundred cards, all at the budget, in the language that costs the most. -async def test_fifty_talkative_cards_stay_clear_of_what_slack_refused() -> None: - """Nothing bounds the plan as a whole, so the per-card budget is what has to - hold the worst case: every one of the fifty cards a remark at the budget. + No per-card number holds this. A stream draws two fifty-card pages, and a + hundred details at 3,000 characters is nearly two million bytes once the + serialiser has escaped them β€” so the budget that has to bind is the one on + the assembled message, applied after the turn is drawn and the card count + is finally known. - Slack accepted fifty cards carrying `REFUSED_ABOVE` characters of detail - each and refused the message above that. It documents neither number and - can revoke both, so the worst case has to clear the measurement with room - to spare rather than merely fit inside it. + CJK rather than English because the escaping is what makes the arithmetic + counter-intuitive: six bytes a character is the worst any prose can cost, + and a margin that only survives ASCII is not a margin. """ - worst_case = _MAX_PLAN_TASKS * _MAX_SAID_DETAILS + drawn = render_activity_stream( + _talkative(2 * _MAX_PLAN_TASKS, "ζΌ’" * _MAX_SAID_DETAILS), + _turn("completed"), + elapsed_seconds=90, + session_url="switchdash://session?server=https%3A%2F%2Fswitch.example&session=s", + ) + + assert sum(len(block["tasks"]) for block in drawn.blocks) == 2 * _MAX_PLAN_TASKS + assert _streamed_bytes(drawn) < ACCEPTED_BYTES + + +async def test_the_worst_ordinary_post_is_one_slack_would_accept() -> None: + """The same defect on the path a thread without a stream falls back to. + + Half the cards, so it is the easier case β€” but it is refused by a different + guard with a different error, so it is a separate measurement and gets a + separate check. Fifty cards of 3,000-character details is 900,000 bytes in + CJK; the post that was seen to go through carried a quarter of that. + """ + drawn = render_activity_plan( + _talkative(_MAX_PLAN_TASKS, "ζΌ’" * _MAX_SAID_DETAILS), + _turn("completed"), + elapsed_seconds=90, + ) + + assert len(drawn.blocks[0]["tasks"]) == _MAX_PLAN_TASKS + assert len(json.dumps(drawn.blocks).encode()) < POST_ACCEPTED_BYTES + + +async def test_a_message_cut_down_to_fit_says_so_on_every_card_it_cut() -> None: + """Shrinking to fit is still losing words, so it is still disclosed. + + The alternative is a card that quietly dropped its expansion, or one whose + remark simply stops β€” either reads as a remark that had no more to say. + """ + drawn = render_activity_stream( + _talkative(2 * _MAX_PLAN_TASKS, "ζΌ’" * _MAX_SAID_DETAILS), + _turn("completed"), + elapsed_seconds=90, + ) + + details = [_detail(task) for block in drawn.blocks for task in block["tasks"]] + assert len(details) == 2 * _MAX_PLAN_TASKS + assert all(detail.endswith("[…truncated]") for detail in details) + + +async def test_a_turn_that_fits_keeps_every_word_the_per_card_budget_allows() -> None: + """The message-wide budget is a backstop, not a second cap on every turn. + + A hundred cards is the shape that breaks it; one talkative card is not, and + a reader of that card should get the whole 3,000 characters the per-card + budget was widened to give them. + """ + drawn = render_activity_stream( + [_said("ζΌ’" * 10_000)], _turn("completed"), elapsed_seconds=90 + ) - assert worst_case * 2 < _MAX_PLAN_TASKS * REFUSED_ABOVE + assert len(_detail(drawn.blocks[0]["tasks"][0])) == _MAX_SAID_DETAILS async def test_the_header_still_counts_calls_rather_than_everything_drawn() -> None: From da2ca942f761c2931a68277447171dcb357abbde Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 11:42:29 +0100 Subject: [PATCH 110/120] Correct which arm of the stream ceiling measurement binds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceiling probe ran two arms. A hundred cards sent as one append took 2,180 ASCII characters of detail each, 257,715 bytes on the wire; the same hundred delivered a chunk at a time took 2,179, 257,615 bytes. The comments cited 257,615 as the size of the 2,180 payload, pairing the accepted byte count with the character count from the other arm. The constant is unchanged and still right: the adapter appends as a turn grows, so the chunked arm is the one production takes and 257,615 is its accepted figure. Only the justification was wrong. The two arms landing a hundred bytes apart also answers a question the comments left open β€” the ceiling is on what the message currently holds, not on the volume delivered to build it. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 27 ++++++++++++------- .../collaboration/test_session_activity.py | 11 +++++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 218117e89..a419b4564 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -136,13 +136,19 @@ # # Where the ceiling is, observed against a real workspace because Slack # documents none of it. A streamed turn draws up to two fifty-card pages into -# one message; a hundred cards carrying 2,180 ASCII characters of detail each -# was accepted at 257,615 bytes and 2,181 was refused. That is a boundary -# someone watched, not a published contract β€” it sits near 256 KiB, and nothing -# says it is exactly that or that it will hold. The unit is bytes on the wire -# rather than characters: the payload is serialised with `ensure_ascii=True`, so -# a non-ASCII character leaves as a six-byte `\uXXXX` escape and the same -# message would tolerate only about 200 characters a card in Japanese. +# one message, and a hundred cards of ASCII detail were pushed at it two ways: +# sent as one append it took 2,180 characters a card, 257,715 bytes, and refused +# 2,181; delivered a chunk at a time, the way a turn that is still running +# arrives, it took 2,179 and refused 2,180. So the binding figure is the second, +# 257,615 bytes β€” and the two arms landing 100 bytes apart say the ceiling is on +# what the message now holds rather than on how much was sent to build it. +# +# That is a boundary someone watched, not a published contract: it sits near +# 256 KiB, and nothing says it is exactly that or that it will hold. The unit is +# bytes on the wire rather than characters, because the payload is serialised +# with `ensure_ascii=True` β€” a non-ASCII character leaves as a six-byte +# `\uXXXX` escape, so the same message would tolerate only about 200 characters +# a card in Japanese. # # Why this number is not sized against that worst case: it does not occur. Over # 445 real turns the largest message reached a tenth of the ceiling, and @@ -160,9 +166,10 @@ # case is caught on the assembled message instead. # # Two numbers because the two paths refuse differently, and both are boundaries -# someone watched rather than published contracts. A stream took a hundred cards -# at 257,615 bytes and refused the next; an ordinary post refused fifty cards of -# 4,743 characters each with a different error, `msg_blocks_too_long`. Each +# someone watched rather than published contracts. A stream grown a chunk at a +# time took a hundred cards at 257,615 bytes and refused a hundred bytes more +# with `msg_too_long`; an ordinary post took fifty cards of 4,743 characters +# each and refused the message above that with `msg_blocks_too_long`. Each # budget sits under its measurement, which buys room for the request envelope # weighed nowhere here β€” channel, timestamp, chunk wrappers β€” and for Slack # tightening a limit it never published in the first place. diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 0ed29a5e0..b86a13bbe 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -60,10 +60,13 @@ # What a whole streamed message may weigh, measured against a real workspace # and documented by Slack nowhere. A hundred cards β€” the two fifty-card pages a -# stream draws β€” carrying 2,180 ASCII characters of detail each was accepted at -# 257,615 bytes on the wire, and a hundred bytes more was refused. Bytes rather -# than characters: the payload is serialised with `ensure_ascii=True`, so a -# non-ASCII character leaves as a six-byte escape. +# stream draws β€” were pushed at it two ways. Delivered a chunk at a time, the +# way a turn that is still running arrives, 2,179 ASCII characters of detail a +# card went through at 257,615 bytes on the wire and a hundred bytes more was +# refused. Sent as a single append it took one character a card more, 257,715. +# The binding figure is the first, because the adapter appends as the turn +# grows. Bytes rather than characters: the payload is serialised with +# `ensure_ascii=True`, so a non-ASCII character leaves as a six-byte escape. ACCEPTED_BYTES = 257_615 # The same question asked of an ordinary post, which refuses with a different From f670043419264a9453a148c31389679552c5f394 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 11:53:04 +0100 Subject: [PATCH 111/120] Draw a remark as prose with a glyph, not as a truncated line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remark was drawn as a one-line preview with the rest hidden behind an expansion, so the thing a reader wanted was the thing they had to go looking for. It now hides the title and puts the prose at the top of the card, where it is met whole. The title is still written, because hiding it is the card's request and a client that ignores it should find a sentence there rather than an empty card. The prose keeps the markdown the agent wrote. Slack renders none of it in this slot, so it arrives literally β€” readable, and less lossy than stripping the marks and leaving a reader unable to tell a code span from a word. Cards now carry Slack's own glyph: `comment` on a remark, `code` on a call. That slot takes a closed set of 54 names β€” `bolt`, `wrench` and `terminal` are all refused as invalid enum values β€” so both are chosen from what exists. The speech marker stays in the hidden title as the same distinction in text. Whether the glyph displaces the error mark on a failed call or the spinner on a running one is posted for a human to look at and not yet answered. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 46 +++++++++++------- .../collaboration/test_session_activity.py | 47 +++++++++++++++++-- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index a419b4564..6dca2a478 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -220,6 +220,14 @@ "declined": "error", } +# What a card draws in its own glyph slot. Not an emoji field: Slack takes a +# closed set of 54 names here and refuses anything outside it β€” `bolt`, `wrench` +# and `terminal` are all rejected as invalid enum values β€” so these two are +# chosen from what exists rather than from what a speech bubble or a shell +# prompt would ideally be. +_SAID_ICON = "comment" +_TOOL_ICON = "code" + _DANGEROUS = {"decline", "cancel"} # How a tool call went, in one character, because it is read at a glance and @@ -1521,34 +1529,38 @@ def _plan_task(item: Item) -> dict[str, Any]: Slack draws a settled card with a check, which beside a sentence is the one thing the marker exists to deny. There is no fourth status to reach for β€” Slack has three and the other two are a spinner and an error, both of which - say something worse β€” so the marker carries the distinction alone here, - where on every other platform the glyph column carries it. - - A remark longer than its title keeps the rest in the card's detail, which is - the part Slack offers to expand. The title is a one-line preview and folds - the paragraph breaks out, because four lines of sentence in a list of calls - reads as four things happening; the detail keeps them, because that is where - the remark is read rather than scanned. A remark that fits its title gets no - detail at all β€” an expansion holding what is already on the line is a - control that does nothing. + say something worse β€” so a remark carries a `comment` glyph and a call + carries `code`, which is what the glyph column does on every other platform. + The marker stays in the title behind it as the same distinction in text. + + A remark is drawn as its detail rather than as its title: the title is + hidden, and the prose sits at the top of the card where a reader meets it + whole instead of meeting a one-line preview of it. The title is still + written, because hiding it is the card's choice and a client that does not + honour that should find a sentence there rather than nothing. + + The prose keeps the markdown the agent wrote. Slack renders none of it in + this slot, so asterisks and backticks arrive literally β€” which is readable, + and is less lossy than stripping the marks out and leaving a reader unable + to tell a code span from a word. """ if item.kind == "assistant-message": - said = plain_text(item.text) - line = f"{SAID_MARKER} {' '.join(said.split())}" - card: dict[str, Any] = { + preview = f"{SAID_MARKER} {' '.join(plain_text(item.text).split())}" + return { "task_id": _task_id(item.item_id), - "title": _truncate(line, _MAX_PLAN_TASK_TITLE), + "title": _truncate(preview, _MAX_PLAN_TASK_TITLE), + "hide_title": True, + "icon": {"type": "icon", "name": _SAID_ICON}, "status": "complete", + "details": _rich_text(_truncate_prose(item.text, _MAX_SAID_DETAILS)), } - if len(line) > _MAX_PLAN_TASK_TITLE: - card["details"] = _rich_text(_truncate_prose(said, _MAX_SAID_DETAILS)) - return card title = plain_text(item.title) if item.title else "" if item.status in ("failed", "declined"): title = f"{_ACTIVITY[item.status]} {title}".strip() task: dict[str, Any] = { "task_id": _task_id(item.item_id), "title": _truncate(title, _MAX_PLAN_TASK_TITLE) or "(untitled)", + "icon": {"type": "icon", "name": _TOOL_ICON}, "status": _TASK_STATUS[item.status], } details = plain_text(item.text) if item.text else "" diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index b86a13bbe..472cb8a2f 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -883,12 +883,51 @@ async def test_a_remark_too_long_for_its_line_keeps_the_rest_where_it_expands() assert len(_detail(card)) > len(card["title"]) -async def test_a_remark_that_fits_its_line_is_not_given_an_expansion() -> None: - """An expansion holding what is already on the line is a control that does - nothing, and a reader who opens one learns that the hard way.""" +async def test_even_a_remark_that_would_fit_a_line_is_drawn_in_the_detail() -> None: + """The detail used to be the overflow and is now the whole card: the title + is hidden, so a remark left out of the detail is a remark nobody sees.""" drawn = render_activity_plan([_said("All green.")], _turn("completed")) - assert "details" not in drawn.blocks[0]["tasks"][0] + card = drawn.blocks[0]["tasks"][0] + assert card["hide_title"] is True + assert _detail(card) == "All green." + + +async def test_a_hidden_title_still_says_what_the_card_is() -> None: + """Hiding is the card asking; a client that does not honour it falls back to + the title, and an empty one would leave the remark showing as nothing.""" + drawn = render_activity_plan([_said("All green.")], _turn("completed")) + + assert drawn.blocks[0]["tasks"][0]["title"] == "Β» All green." + + +async def test_prose_and_calls_are_told_apart_by_the_glyph_not_by_the_status() -> None: + """Slack settles both with the same check, and a check beside a sentence + claims an outcome the sentence never had. The glyph is what separates them, + now that the marker carrying it sits in a title nobody is shown.""" + drawn = render_activity_plan( + [_said("Looking now."), _item(status="completed", title="Read backoff.py")], + _turn("completed"), + ) + + said, call = drawn.blocks[0]["tasks"] + assert said["icon"] == {"type": "icon", "name": "comment"} + assert call["icon"] == {"type": "icon", "name": "code"} + + +async def test_the_markdown_an_agent_wrote_reaches_the_reader_unstripped() -> None: + """Slack renders none of it in this slot, so it arrives literally. Stripping + it instead loses the distinction between a code span and a word, which is + worse than a reader seeing the backticks that were always there.""" + drawn = render_activity_plan( + [_said("The failure is in **retry** β€” see `backoff.reset()`.")], + _turn("completed"), + ) + + assert ( + _detail(drawn.blocks[0]["tasks"][0]) + == "The failure is in **retry** β€” see `backoff.reset()`." + ) async def test_the_expansion_keeps_the_breaks_the_line_had_to_fold_out() -> None: From 97adc8cbd67ea4166eba47588e43489696c9d80e Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 12:06:11 +0100 Subject: [PATCH 112/120] Let the Console link be the session card rather than a line under a label A row reading "Switch session" above a row reading "Open in Console app" says the same thing twice, and costs a reader a line before they reach the one control the stream offers them. The title is hidden when the link is there and kept when it is not, because a card that hides the only thing it holds is a blank row in a plan that exists to keep the status line drawn. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 12 +++++- .../test_session_slack_streaming.py | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 6dca2a478..55dd747df 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -1294,6 +1294,12 @@ def _session_card(session_url: str | None, turn: TurnUpsert) -> dict[str, Any]: agent, so its length is the deployment's, not something to defend against β€” and half a url is not a link, it is a line of text that looks like one and goes nowhere. + + With the link there, the title is hidden and the link is the whole card: a + row reading "Switch session" above a row reading "Open in Console app" says + the same thing twice, and the second row says it better. Without the link + the title is all there is, so it stays β€” the card still has to hold the + plan open for the status line above it. """ card: dict[str, Any] = { "type": "task_update", @@ -1307,7 +1313,11 @@ def _session_card(session_url: str | None, turn: TurnUpsert) -> dict[str, Any]: "switchdash", }: return card - return {**card, "details": f"<{session_url}|Open in Console app>"} + return { + **card, + "hide_title": True, + "details": f"<{session_url}|Open in Console app>", + } def _step_blocks( diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py index c164d8dcf..759aa9651 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -306,6 +306,47 @@ async def test_a_detail_takes_the_shape_of_the_place_it_is_sent_to() -> None: } +async def test_the_link_is_the_card_rather_than_a_line_under_a_label() -> None: + """A row reading "Switch session" above one reading "Open in Console app" + says the same thing twice. + + The link names what it opens, so the title above it is a row of nothing β€” + and it is a row a reader has to look past to reach the one control the + stream offers them. + """ + client = FakeWebClient() + adapter = _adapter(client) + + await adapter.post_rich( + CHANNEL, + "Agent", + TurnActivity( + [_tool("t1", "Read")], + _turn(), + session_url="https://switch.example/session", + ), + THREAD, + ) + + assert _cards(client)[0]["hide_title"] is True + + +async def test_a_card_with_no_link_in_it_still_says_what_it_is() -> None: + """Hiding the title is the link earning the row. With no link there is no + second row to earn it, and a card hiding the only thing it holds is blank β€” + in a plan that exists to keep the status line above it drawn.""" + client = FakeWebClient() + adapter = _adapter(client) + + await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([_tool("t1", "Read")], _turn()), THREAD + ) + + card = _cards(client)[0] + assert "hide_title" not in card + assert card["title"] == "Switch session" + + @pytest.mark.parametrize("renders_custom_schemes", [True, False]) async def test_a_real_console_link_arrives_whole(renders_custom_schemes: bool) -> None: """Built by the code that builds it in production, not by hand. From 576dc9cdaf314a7db1564d02d875c4882e277343 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 13:13:24 +0100 Subject: [PATCH 113/120] Slack: draw a turn as sections with the Console link inside each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity message was three things down the page: a status line, the steps, and the Console link on a line of its own under them. Two of those are gone. The link becomes the first card of every section, so it is inside the collapsible a reader already opens rather than a line everyone pays for and few use. It is repeated in both sections on purpose: a reader opens one of them, and a link that is only in the other is a link they have to go looking for. A section therefore holds forty-nine of the turn's cards, the fiftieth row being that card, because Slack caps a plan block at fifty. The turn's state and clock move onto the newest section's heading. A heading is the whole of a collapsed section, so this is what says which of two sections is still moving and which is history β€” the one above now reads "Activity 50-98" rather than looking like a second thing to read. The live step is still named on the section actually holding it, which is usually but not always the newest one. That removes the stream's own plan entirely, and with it the append-once bookkeeping the session card needed there. Measured against the live API first: a stream carrying only `blocks` chunks still draws them, and the title of a plan with no cards in it draws nothing at all, so there is no invisible header left behind. One divergence the API forces: `chat.postMessage` refuses a message holding two plan blocks, where a stream accepts any number. The no-thread fallback path therefore draws one section and discloses in its header what it left out. Co-Authored-By: Claude Opus 5 --- .../collaboration/session/renderers/slack.py | 253 +++++++----- .../bridges/collaboration/slack/adapter.py | 68 +--- .../collaboration/test_session_activity.py | 101 +++-- .../test_session_card_posting.py | 4 +- .../test_session_compact_presentation.py | 6 +- .../test_session_review_regressions.py | 5 +- .../test_session_slack_streaming.py | 365 +++++++++--------- .../test_session_turn_messages.py | 3 +- 8 files changed, 419 insertions(+), 386 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 55dd747df..c62768ff6 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/slack.py +++ b/core/switch_core/bridges/collaboration/session/renderers/slack.py @@ -127,6 +127,11 @@ # dropped. The per-task budgets are ours: nothing here is near a documented # limit, and a card is read at a glance. _MAX_PLAN_TASKS = 50 +# What a section spends on the turn, the remaining row being the session card. +# Fixed rather than widened when there is no session url to put in that card: +# the url can arrive after the stream has opened, and a boundary that moved with +# it would re-cut every page already drawn. +_MAX_SECTION_ITEMS = _MAX_PLAN_TASKS - 1 _MAX_PLAN_TITLE = 150 _MAX_PLAN_TASK_TITLE = 200 _MAX_PLAN_TASK_DETAILS = 200 @@ -202,11 +207,9 @@ # that line when there is something to disclose. _STEP_BLOCKS = ("switch-steps-top", "switch-steps-middle", "switch-steps-bottom") -# The single card in the stream's own plan. Its `details` is sent once and -# never again: `details` on a `task_update` appends to what the card already -# has rather than replacing it, so a card re-sent with the same link shows the -# link twice. Title and status can be re-sent freely, and have to be together β€” -# an update that leaves the title out stores an empty one. +# The first card of every section, carrying the Console link. The same id in +# both sections is deliberate and Slack takes it: a reader opens one section or +# the other, and the link has to be in whichever one they chose. _SESSION_CARD = "switch-session" # Slack's three task states against the contract's four. `declined` is not an @@ -1185,53 +1188,51 @@ def render_activity_plan( posted to the room unless the agent posts it β€” so without this the turn's reasoning is simply not available to a reader who wants it. - A turn that has neither called nor said anything has no plan to show, so it - falls back to the spinning card the status line used to be β€” this one - message stands in for both of the two it replaced. + One section and no more, which is the one place this path cannot follow the + streamed one: `chat.postMessage` refuses a message holding two plan blocks + outright, where a stream accepts any number of them. So a long turn shows + its newest section and says in the header what it dropped, rather than the + previous section a streamed turn keeps. + + A turn that has neither called nor said anything still draws the section, + because the session card in it is a card β€” this one message stands in for + both of the two it replaced, and the second of those was a status line that + appeared before the turn had done anything. """ shown = [item for item in items if in_activity_log(item)] - kept = shown[len(shown) - _MAX_PLAN_TASKS :] + kept = shown[len(shown) - _MAX_SECTION_ITEMS :] title = _activity_title( items, turn, elapsed_seconds=elapsed_seconds, omitted=len(shown) - len(kept) ) - blocks: list[dict[str, Any]] = [] - if kept: - blocks.append( - { - "type": "plan", - "title": _truncate(title, _MAX_PLAN_TITLE), - "tasks": [_settled(_plan_task(item), item, turn) for item in kept], - } - ) - elif turn.status not in TURN_ENDED: - blocks.append( - { - "type": "task_card", - "task_id": _task_id(turn.turn_id), - "title": _truncate(title, _MAX_PLAN_TASK_TITLE), - "status": "in_progress", - } - ) - else: - blocks.append(_context(title)) - if session_url and urlsplit(session_url).scheme in {"https", "http", "switchdash"}: - blocks.append(_context(f"<{session_url}|Open in Console app>")) + blocks: list[dict[str, Any]] = [ + { + "type": "plan", + "title": _truncate(title, _MAX_PLAN_TITLE), + "tasks": [ + _session_card(session_url, live=turn.status not in TURN_ENDED), + *(_settled(_plan_task(item), item, turn) for item in kept), + ], + } + ] _fit_details(blocks, _MAX_POST_BYTES) return SlackMessage(text=title, blocks=blocks) @dataclass class StreamedActivity: - """A turn's activity as the pieces a stream is built from. + """A turn's activity as the blocks a stream is built from. The whole turn every time, not a delta: which of these Slack has already been told is the streaming adapter's bookkeeping, because only it knows what its own appends landed. Keeping that out of here leaves this a pure function of the turn, testable without a stream. + + `title` is not one of the blocks and is never sent. It is the one line the + message amounts to, which is what a failed publication has to report to a + caller that cannot know how Slack was going to draw it. """ title: str - session: dict[str, Any] blocks: list[dict[str, Any]] @@ -1242,52 +1243,55 @@ def render_activity_stream( elapsed_seconds: float | None = None, session_url: str | None = None, ) -> StreamedActivity: - """The same turn as `render_activity`, shaped for `chat.appendStream`. - - Three pieces, because a streamed message is drawn in two different ways at - once. The header and the card under it belong to the stream's own plan, - which is addressed with chunks and can only ever be added to. The steps - belong to ordinary `plan` blocks carried inside the stream, which are - addressed by `block_id` and are replaced whole β€” so unlike the stream's - plan they can drop a step, reorder one, or hold a different fifty than - they held a minute ago. - - That is what lets a long turn stay one readable message. The stream's plan - holds the status line and nothing that grows; the steps live in three - blocks that rotate, so a turn of any length draws the same four blocks. - - The header says where the turn is, and the section holding the live step - says what it is doing. Naming the step in both would say it twice, and the - section is the useful half: it is the one a reader wants to open, and the - glyph beside it is already the thing that says work is happening there. + """The same turn as `render_activity_plan`, shaped for `chat.appendStream`. + + Sections and nothing else. A stream can carry its own plan, addressed with + chunks, and that is what used to hold the status line β€” but a plan that + grows cannot take a card back, so it could never hold the steps, and it + cost the message a line of its own above them. The whole turn is drawn + instead in ordinary `plan` blocks carried inside the stream, addressed by + `block_id` and replaced whole, which can hold a different fifty than they + held a minute ago. + + So the message collapses to the heading of its newest section: where the + turn got to and how long it has been there, on the section a reader would + open to watch it carry on. A settled section above it is headed by the range + it holds, which is what makes it legible as history rather than as a second + thing to read. + + Measured before it was built: a stream with no plan chunks at all still + draws its blocks, and the title of a plan with no cards in it draws nothing + β€” so there is no invisible header left behind by dropping it. """ shown = [item for item in items if in_activity_log(item)] - title = _truncate( - turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds), - _MAX_PLAN_TITLE, - ) - session = _session_card(session_url, turn) blocks = _step_blocks( [_settled(_plan_task(item), item, turn) for item in shown], _running(shown, turn), + turn_state(items, turn, tool_detail=True, elapsed_seconds=elapsed_seconds), + session_url, + turn.status not in TURN_ENDED, ) - # The message the steps accumulate into carries the header and the session - # card too, so what they weigh is not available to the steps to spend. - _fit_details(blocks, _MAX_STREAM_BYTES - _weigh([title, session])) - return StreamedActivity(title=title, session=session, blocks=blocks) + _fit_details(blocks, _MAX_STREAM_BYTES) + return StreamedActivity(title=blocks[-1]["title"], blocks=blocks) -def _session_card(session_url: str | None, turn: TurnUpsert) -> dict[str, Any]: - """The one card in the stream's own plan, and where the link lives. +def _session_card(session_url: str | None, *, live: bool) -> dict[str, Any]: + """The first card of a section, and where the Console link lives. - A streamed plan with no cards in it does not draw at all, so without this - the status line would have nowhere to appear. It doubles as the place the - Console link is asked to be: first row of the first block, visible in the - same expansion that opens the plan. + Asked for as the first row of the block rather than a line beside it: a + link on its own line is a line every reader pays for and few use, and the + same expansion that opens the turn's activity is the one a reader reaches + for when they want the session itself. - Its status is the turn's, so the block it sits in shows a spinner while the - turn runs rather than the check a settled card would give it. Slack draws - that glyph from the cards, and this is the only card in there. + It is repeated in every section on purpose. A reader opens one section, and + a link that is only in the other one is a link they have to go looking for. + + `live` rather than the turn's status, because only the section holding the + live end of the turn should spin: Slack draws a block's glyph from the cards + in it, and a settled section showing a spinner would point at a place where + nothing is happening. On the live section it is this card that guarantees + the spinner, which the steps cannot β€” between two calls every step card is + settled, and the heading would show a check beside "Working…". The whole url goes in or the card carries no link at all. A session url is built from a configured origin and three ids rather than written by an @@ -1298,14 +1302,14 @@ def _session_card(session_url: str | None, turn: TurnUpsert) -> dict[str, Any]: With the link there, the title is hidden and the link is the whole card: a row reading "Switch session" above a row reading "Open in Console app" says the same thing twice, and the second row says it better. Without the link - the title is all there is, so it stays β€” the card still has to hold the - plan open for the status line above it. + the title is all there is, so it stays β€” the card is still what holds the + section's glyph, and a section is still drawn for a turn that has not done + anything yet. """ card: dict[str, Any] = { - "type": "task_update", - "id": _SESSION_CARD, + "task_id": _SESSION_CARD, "title": "Switch session", - "status": "complete" if turn.status in TURN_ENDED else "in_progress", + "status": "in_progress" if live else "complete", } if not session_url or urlsplit(session_url).scheme not in { "https", @@ -1316,41 +1320,64 @@ def _session_card(session_url: str | None, turn: TurnUpsert) -> dict[str, Any]: return { **card, "hide_title": True, - "details": f"<{session_url}|Open in Console app>", + "details": { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "link", + "url": session_url, + "text": "Open in Console app", + } + ], + } + ], + }, } def _step_blocks( - steps: list[dict[str, Any]], running: tuple[int, str] | None + steps: list[dict[str, Any]], + running: tuple[int, str] | None, + header: str, + session_url: str | None, + live: bool, ) -> list[dict[str, Any]]: - """The steps as the three blocks that hold them: what is gone, then two pages. + """The turn as the three blocks that hold it: what is gone, then two sections. - Pages are cut on fixed boundaries β€” the first fifty, the next fifty β€” so a - step never moves between pages once it has landed in one, which keeps a - settled page from being rewritten under a reader who has it open. + Sections are cut on fixed boundaries β€” the first forty-nine, the next + forty-nine β€” so a step never moves between them once it has landed in one, + which keeps a settled section from being rewritten under a reader who has it + open. Forty-nine rather than fifty because Slack caps a plan block at fifty + cards and the session card takes the first of them. - Only the newest two pages are drawn, and everything before them is gone from - the message. That is said on one line of its own above them, naming the + Only the newest two sections are drawn, and everything before them is gone + from the message. That is said on one line of its own above them, naming the whole range rather than only the most recent thing dropped, so the reader is - never left to add up several disclosures to find out what is missing. + never left to add up several disclosures to find out what is missing. Below + two sections there is nothing to disclose and no line: a turn that has not + overflowed twice collapses to a single heading. The line has to be the first of the three blocks written, because Slack fixes a block where it was created and one made later would render *below* - the pages it is describing. So the top block starts life as the first page - of steps and is replaced by the line when there is finally something to + the sections it is describing. So the top block starts life as the first + section and is replaced by the line when there is finally something to disclose β€” a substitution in place, which keeps its position. There is no call that removes a block, and this needs none. """ - if not steps: - return [] top, middle, bottom = _STEP_BLOCKS - last = (len(steps) - 1) // _MAX_PLAN_TASKS + last = max(len(steps) - 1, 0) // _MAX_SECTION_ITEMS if last < 2: - pages = [top, middle] + pages = (top, middle) return [ - _step_page(steps, page, pages[page], running) for page in range(last + 1) + _step_page( + steps, page, pages[page], running, header, session_url, live, last + ) + for page in range(last + 1) ] - gone = (last - 1) * _MAX_PLAN_TASKS + gone = (last - 1) * _MAX_SECTION_ITEMS return [ { "type": "context", @@ -1359,8 +1386,8 @@ def _step_blocks( {"type": "mrkdwn", "text": f"_Activity 1–{gone} no longer shown_"} ], }, - _step_page(steps, last - 1, middle, running), - _step_page(steps, last, bottom, running), + _step_page(steps, last - 1, middle, running, header, session_url, live, last), + _step_page(steps, last, bottom, running, header, session_url, live, last), ] @@ -1369,27 +1396,37 @@ def _step_page( page: int, block_id: str, running: tuple[int, str] | None, + header: str, + session_url: str | None, + live: bool, + newest: int, ) -> dict[str, Any]: - """One fifty-card page as the plan block that draws it. + """One section as the plan block that draws it. - A page holds the turn as it happened, so a card in it is a call or a thing - the agent said. It is headed "Activity" rather than "Steps" because half of - what can be in there is not a step. + A section holds the turn as it happened, so a card in it is a call or a + thing the agent said. It is headed "Activity" rather than "Steps" because + half of what can be in there is not a step. - The page holding the live step names it, so the heading a reader is drawn - to is the one where something is happening. Only that page: the same - sentence on a settled page would be pointing somewhere the step is not. + The newest section carries the header instead: the turn's state and its + clock. That is where a reader looking for the live end of the turn should be + sent, and putting it on the section rather than on a line above them is what + says which of two sections is still moving. + + The live step is named on the section actually holding it, which is usually + but not always that one β€” a call left open while the agent talks past it + stays where it landed. Naming it on the newest section regardless would + point a reader at a section they can see it is not in. """ - start = page * _MAX_PLAN_TASKS - shown = steps[start : start + _MAX_PLAN_TASKS] - title = f"Activity {start + 1}–{start + len(shown)}" + start = page * _MAX_SECTION_ITEMS + shown = steps[start : start + _MAX_SECTION_ITEMS] + title = header if page == newest else f"Activity {start + 1}–{start + len(shown)}" if running and start <= running[0] < start + len(shown): title += f" Β· {running[1]}" return { "type": "plan", "block_id": block_id, "title": _truncate(title, _MAX_PLAN_TITLE), - "tasks": shown, + "tasks": [_session_card(session_url, live=live and page == newest), *shown], } @@ -1655,15 +1692,21 @@ def _fit_details(blocks: list[dict[str, Any]], limit: int) -> None: which is why running out of retreat here is an exception rather than a smaller cut: it would mean cards arriving from somewhere this was not written to bound. + + So is the session card, whose detail is a link rather than prose. Cutting + its label would leave a link reading "Open in Cons…", and cutting two of + them buys back nothing worth having. """ if _weigh(blocks) <= limit: return details = [ - task["details"]["elements"][0]["elements"][0] + element for block in blocks if block.get("type") == "plan" for task in block["tasks"] if "details" in task + for element in [task["details"]["elements"][0]["elements"][0]] + if element["type"] == "text" ] for budget in _DETAIL_RETREAT: for element in details: diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index ef51fe80b..e75a3f9dc 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -195,17 +195,12 @@ class _ActivityStream: said to work out what is worth saying next β€” resending a page of fifty cards that has not changed costs an append and risks nothing useful. - `session` is the card the status line is drawn around, held here because it - can only be sent once β€” and holding what was *sent* rather than what was - last drawn, so a redraw that happens to be missing the link cannot make the - stream forget the link it already sent. `blocks` is the last thing written - to each step block, by `block_id`, so a redraw sends only what moved. + `blocks` is the last thing written to each section, by `block_id`, so a + redraw sends only what moved. """ channel_id: str ts: str - title: str = "" - session: dict[str, Any] | None = None blocks: dict[str, dict[str, Any]] = field(default_factory=dict) @@ -794,16 +789,20 @@ async def _open_stream( async def _extend_stream( self, stream: _ActivityStream, message_ref: str, content: TurnActivity ) -> None: - """Send what changed since the last append, and close a finished turn. + """Send the sections that moved, and close a finished turn. - Only the header, the session card while it is still owed, and the step - blocks that actually moved. Each goes in its own `blocks` chunk: Slack - replaces the block it names and leaves the rest of the message β€” and - whatever the reader has open β€” alone. + Each goes in its own `blocks` chunk: Slack replaces the block it names + and leaves the rest of the message β€” and whatever the reader has open β€” + alone. One chunk per plan. Slack refuses a `blocks` chunk holding more than a - single plan block, though any number of such chunks ride in one append - alongside the header and the card. + single plan block, though any number of such chunks ride in one append. + + The clock lives in the newest section's heading, so a tick rewrites that + section rather than a header of its own. It is the price of the heading + being the whole collapsed message: a block has no title to move on its + own. Settled sections above it do not move, so what a tick costs is the + section still being filled and never more than one of them. """ client = self._web_client if client is None: @@ -817,61 +816,28 @@ async def _extend_stream( elapsed_seconds=content.elapsed_seconds, session_url=content.session_url, ) - chunks: list[dict[str, Any]] = [] - if drawn.title != stream.title: - chunks.append({"type": "plan_update", "title": drawn.title}) - owed = self._session_owed(stream.session, drawn.session) - if owed: - chunks.append(owed) moved = [ block for block in drawn.blocks if stream.blocks.get(block["block_id"]) != block ] - chunks.extend({"type": "blocks", "blocks": [block]} for block in moved) - - if chunks: + if moved: try: await client.chat_appendStream( - channel=stream.channel_id, ts=stream.ts, chunks=chunks + channel=stream.channel_id, + ts=stream.ts, + chunks=[{"type": "blocks", "blocks": [block]} for block in moved], ) except SlackApiError as error: # Nothing below runs: every path out of here raises. The # stream's record of what Slack holds stays as it was, so a # retry sends the same chunks rather than assuming they landed. self._stream_failed(error, message_ref, drawn.title) - stream.title = drawn.title - if owed: - stream.session = {**(stream.session or {}), **owed} for block in moved: stream.blocks[block["block_id"]] = block if content.turn.status in TURN_ENDED: await self._close_stream(client, stream, message_ref) - @staticmethod - def _session_owed( - sent: dict[str, Any] | None, drawn: dict[str, Any] - ) -> dict[str, Any] | None: - """The part of the session card Slack has not been told, or nothing. - - The card is not written once and left. Its status follows the turn, so - it goes out spinning and comes back complete, and the link may only - turn up after the stream has opened. - - What can only happen once is `details`. It *appends* to what the card - already holds rather than replacing it, so the link is dropped from - every chunk after the one that carried it β€” otherwise the card comes - back holding the link twice. Everything else is compared against what - Slack was actually sent, which is why a redraw that happens to arrive - without the url cannot make the stream forget it already sent one. - """ - if sent is None: - return drawn - owed = { - k: v for k, v in drawn.items() if k != "details" or "details" not in sent - } - return owed if {**sent, **owed} != sent else None - async def _close_stream( self, client: AsyncWebClient, stream: _ActivityStream, message_ref: str ) -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 472cb8a2f..61d6bb18a 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -23,8 +23,8 @@ turn_state, ) from switch_core.bridges.collaboration.session.renderers.slack import ( - _MAX_PLAN_TASKS, _MAX_SAID_DETAILS, + _MAX_SECTION_ITEMS, StreamedActivity, render_activity, render_activity_plan, @@ -124,6 +124,18 @@ def _plan(items: list[Item]) -> dict[str, Any]: return block +def _turn_cards(block: dict[str, Any]) -> list[dict[str, Any]]: + """A section's cards without the session card that heads every one of them. + + The Console link is the first row of every section by design, so a test + about what the turn drew has to say which cards it means β€” otherwise every + one of them is also a test of where the link sits. + """ + tasks = block["tasks"] + assert tasks[0]["task_id"] == "switch-session" + return list(tasks[1:]) + + def _cards(items: list[Item]) -> dict[str, dict[str, Any]]: """The plan's tasks by title, which is what a reader picks one out by.""" return {str(task["title"]): task for task in _plan(items)["tasks"]} @@ -800,18 +812,22 @@ async def test_the_one_message_leads_with_the_state_and_the_step_it_is_on() -> N assert plan["type"] == "plan" assert plan["title"].endswith("Β· Running: Read") assert "40s" in plan["title"] - assert plan["tasks"][-1]["title"] == "Read" + assert _turn_cards(plan)[-1]["title"] == "Read" async def test_a_turn_with_nothing_to_plan_yet_still_shows_it_is_working() -> None: - """A plan block with no tasks says nothing, and the message this replaced - spun a card from the moment the turn opened.""" + """The message this replaced spun a card from the moment the turn opened. + + A plan block with no tasks says nothing, and the section is never empty: + the session card is in it before the turn has done anything, which is what + lets one shape serve a turn at both ends of its life. + """ running = render_activity_plan([], _turn(), elapsed_seconds=3) ended = render_activity_plan([], _turn("completed")) - assert running.blocks[0]["type"] == "task_card" - assert running.blocks[0]["status"] == "in_progress" - assert ended.blocks[0]["type"] == "context" + assert running.blocks[0]["type"] == "plan" + assert [task["status"] for task in running.blocks[0]["tasks"]] == ["in_progress"] + assert [task["status"] for task in ended.blocks[0]["tasks"]] == ["complete"] # ── What the agent said, in the one block a reader opens ───────────────────── @@ -833,7 +849,7 @@ async def test_what_the_agent_said_is_a_card_in_the_plan_marked_as_speech() -> N _turn("completed"), ) - titles = [task["title"] for task in drawn.blocks[0]["tasks"]] + titles = [task["title"] for task in _turn_cards(drawn.blocks[0])] assert titles == ["Ran the tests", "Β» All green."] @@ -845,7 +861,7 @@ async def test_a_sentence_is_never_marked_unfinished_when_the_turn_stops() -> No [_said("Handing it over.", status="in-progress")], _turn("completed") ) - card = drawn.blocks[0]["tasks"][0] + card = _turn_cards(drawn.blocks[0])[0] assert card["title"] == "Β» Handing it over." assert "Unfinished" not in card["title"] @@ -856,7 +872,7 @@ async def test_a_turn_that_only_talked_has_a_plan_rather_than_a_bare_line() -> N drawn = render_activity_plan([_said("Fixed that yesterday.")], _turn("completed")) assert drawn.blocks[0]["type"] == "plan" - assert drawn.blocks[0]["tasks"][0]["title"] == "Β» Fixed that yesterday." + assert _turn_cards(drawn.blocks[0])[0]["title"] == "Β» Fixed that yesterday." async def test_a_paragraph_is_folded_onto_the_one_line_its_card_gives_it() -> None: @@ -866,7 +882,9 @@ async def test_a_paragraph_is_folded_onto_the_one_line_its_card_gives_it() -> No [_said("First thought.\n\nSecond thought.")], _turn("completed") ) - assert drawn.blocks[0]["tasks"][0]["title"] == "Β» First thought. Second thought." + assert ( + _turn_cards(drawn.blocks[0])[0]["title"] == "Β» First thought. Second thought." + ) async def test_a_remark_too_long_for_its_line_keeps_the_rest_where_it_expands() -> None: @@ -876,7 +894,7 @@ async def test_a_remark_too_long_for_its_line_keeps_the_rest_where_it_expands() said = "Right. " + "The failure is in the retry path. " * 12 drawn = render_activity_plan([_said(said)], _turn("completed")) - card = drawn.blocks[0]["tasks"][0] + card = _turn_cards(drawn.blocks[0])[0] assert card["title"].endswith("…") assert len(card["title"]) <= 200 assert _detail(card).startswith("Right. The failure is in the retry path.") @@ -888,7 +906,7 @@ async def test_even_a_remark_that_would_fit_a_line_is_drawn_in_the_detail() -> N is hidden, so a remark left out of the detail is a remark nobody sees.""" drawn = render_activity_plan([_said("All green.")], _turn("completed")) - card = drawn.blocks[0]["tasks"][0] + card = _turn_cards(drawn.blocks[0])[0] assert card["hide_title"] is True assert _detail(card) == "All green." @@ -898,7 +916,7 @@ async def test_a_hidden_title_still_says_what_the_card_is() -> None: the title, and an empty one would leave the remark showing as nothing.""" drawn = render_activity_plan([_said("All green.")], _turn("completed")) - assert drawn.blocks[0]["tasks"][0]["title"] == "Β» All green." + assert _turn_cards(drawn.blocks[0])[0]["title"] == "Β» All green." async def test_prose_and_calls_are_told_apart_by_the_glyph_not_by_the_status() -> None: @@ -910,7 +928,7 @@ async def test_prose_and_calls_are_told_apart_by_the_glyph_not_by_the_status() - _turn("completed"), ) - said, call = drawn.blocks[0]["tasks"] + said, call = _turn_cards(drawn.blocks[0]) assert said["icon"] == {"type": "icon", "name": "comment"} assert call["icon"] == {"type": "icon", "name": "code"} @@ -925,7 +943,7 @@ async def test_the_markdown_an_agent_wrote_reaches_the_reader_unstripped() -> No ) assert ( - _detail(drawn.blocks[0]["tasks"][0]) + _detail(_turn_cards(drawn.blocks[0])[0]) == "The failure is in **retry** β€” see `backoff.reset()`." ) @@ -937,7 +955,7 @@ async def test_the_expansion_keeps_the_breaks_the_line_had_to_fold_out() -> None said = "First thought.\n\nSecond thought. " + "More on that. " * 20 drawn = render_activity_plan([_said(said)], _turn("completed")) - card = drawn.blocks[0]["tasks"][0] + card = _turn_cards(drawn.blocks[0])[0] assert "\n" not in card["title"] assert "First thought.\n\nSecond thought." in _detail(card) @@ -951,7 +969,7 @@ async def test_a_remark_longer_than_the_expansion_is_says_so_where_it_was_cut() """ drawn = render_activity_plan([_said("word " * 4000)], _turn("completed")) - detail = _detail(drawn.blocks[0]["tasks"][0]) + detail = _detail(_turn_cards(drawn.blocks[0])[0]) assert len(detail) <= _MAX_SAID_DETAILS assert detail.endswith("[…truncated]") @@ -960,7 +978,7 @@ async def test_a_remark_that_fits_is_not_accused_of_being_cut() -> None: """The notice is a claim about missing text, so it has to be false silently.""" drawn = render_activity_plan([_said("A short remark. " * 30)], _turn("completed")) - assert "truncated" not in _detail(drawn.blocks[0]["tasks"][0]) + assert "truncated" not in _detail(_turn_cards(drawn.blocks[0])[0]) def _talkative(count: int, text: str) -> list[Item]: @@ -972,54 +990,53 @@ def _streamed_bytes(drawn: StreamedActivity) -> int: """The turn as the adapter sends it, weighed the way Slack weighs it.""" return len( json.dumps( - [ - {"type": "plan_update", "title": drawn.title}, - drawn.session, - *({"type": "blocks", "blocks": [block]} for block in drawn.blocks), - ] + [{"type": "blocks", "blocks": [block]} for block in drawn.blocks] ).encode() ) async def test_the_worst_streamed_message_is_one_slack_would_accept() -> None: - """A hundred cards, all at the budget, in the language that costs the most. + """Every card a stream can draw, all at the budget, in the costliest language. - No per-card number holds this. A stream draws two fifty-card pages, and a - hundred details at 3,000 characters is nearly two million bytes once the - serialiser has escaped them β€” so the budget that has to bind is the one on - the assembled message, applied after the turn is drawn and the card count - is finally known. + No per-card number holds this. A stream draws two sections of forty-nine, + and ninety-eight details at 3,000 characters is over a million bytes once + the serialiser has escaped them β€” so the budget that has to bind is the one + on the assembled message, applied after the turn is drawn and the card + count is finally known. CJK rather than English because the escaping is what makes the arithmetic counter-intuitive: six bytes a character is the worst any prose can cost, and a margin that only survives ASCII is not a margin. """ drawn = render_activity_stream( - _talkative(2 * _MAX_PLAN_TASKS, "ζΌ’" * _MAX_SAID_DETAILS), + _talkative(2 * _MAX_SECTION_ITEMS, "ζΌ’" * _MAX_SAID_DETAILS), _turn("completed"), elapsed_seconds=90, session_url="switchdash://session?server=https%3A%2F%2Fswitch.example&session=s", ) - assert sum(len(block["tasks"]) for block in drawn.blocks) == 2 * _MAX_PLAN_TASKS + assert ( + sum(len(_turn_cards(block)) for block in drawn.blocks) == 2 * _MAX_SECTION_ITEMS + ) assert _streamed_bytes(drawn) < ACCEPTED_BYTES async def test_the_worst_ordinary_post_is_one_slack_would_accept() -> None: """The same defect on the path a thread without a stream falls back to. - Half the cards, so it is the easier case β€” but it is refused by a different - guard with a different error, so it is a separate measurement and gets a - separate check. Fifty cards of 3,000-character details is 900,000 bytes in - CJK; the post that was seen to go through carried a quarter of that. + One section rather than two, because `chat.postMessage` refuses a second + plan block outright β€” so it is the easier case, and it is refused by a + different guard with a different error, which makes it a separate + measurement. Forty-nine cards of 3,000-character details is 880,000 bytes + in CJK; the post that was seen to go through carried a quarter of that. """ drawn = render_activity_plan( - _talkative(_MAX_PLAN_TASKS, "ζΌ’" * _MAX_SAID_DETAILS), + _talkative(_MAX_SECTION_ITEMS, "ζΌ’" * _MAX_SAID_DETAILS), _turn("completed"), elapsed_seconds=90, ) - assert len(drawn.blocks[0]["tasks"]) == _MAX_PLAN_TASKS + assert len(_turn_cards(drawn.blocks[0])) == _MAX_SECTION_ITEMS assert len(json.dumps(drawn.blocks).encode()) < POST_ACCEPTED_BYTES @@ -1030,13 +1047,13 @@ async def test_a_message_cut_down_to_fit_says_so_on_every_card_it_cut() -> None: remark simply stops β€” either reads as a remark that had no more to say. """ drawn = render_activity_stream( - _talkative(2 * _MAX_PLAN_TASKS, "ζΌ’" * _MAX_SAID_DETAILS), + _talkative(2 * _MAX_SECTION_ITEMS, "ζΌ’" * _MAX_SAID_DETAILS), _turn("completed"), elapsed_seconds=90, ) - details = [_detail(task) for block in drawn.blocks for task in block["tasks"]] - assert len(details) == 2 * _MAX_PLAN_TASKS + details = [_detail(task) for block in drawn.blocks for task in _turn_cards(block)] + assert len(details) == 2 * _MAX_SECTION_ITEMS assert all(detail.endswith("[…truncated]") for detail in details) @@ -1051,7 +1068,7 @@ async def test_a_turn_that_fits_keeps_every_word_the_per_card_budget_allows() -> [_said("ζΌ’" * 10_000)], _turn("completed"), elapsed_seconds=90 ) - assert len(_detail(drawn.blocks[0]["tasks"][0])) == _MAX_SAID_DETAILS + assert len(_detail(_turn_cards(drawn.blocks[0])[0])) == _MAX_SAID_DETAILS async def test_the_header_still_counts_calls_rather_than_everything_drawn() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py index 6cc29f261..50bc101ea 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py @@ -163,13 +163,15 @@ def _steps(message: dict[str, Any]) -> list[str]: The recording has nine calls and one remark, and the count is the claim: one message now carries the turn's state, its tool calls and what the agent said, so a title matching says a card is drawn and nothing about the nine - that should be beside it. + that should be beside it. The Console link heads every section as a card of + its own and is not one of the turn's, so it is left out of the count. """ return [ task["title"] for block in message["blocks"] if block.get("type") == "plan" for task in block["tasks"] + if task["task_id"] != "switch-session" ] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py index a8cbb94f6..e2d603e06 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py @@ -36,7 +36,8 @@ async def test_plan_keeps_tool_details_but_fallback_is_compact(): ) assert message.blocks[0]["type"] == "plan" assert len(message.blocks[0]["tasks"]) > 1 - assert URL not in json.dumps(message.blocks[0]) + assert URL in json.dumps(message.blocks[0]["tasks"][0]) + assert URL not in message.text assert len(message.text.splitlines()) == 1 @@ -47,7 +48,8 @@ async def test_a_finished_plan_keeps_its_tools_and_stays_off_the_fallback(): ) assert message.blocks[0]["type"] == "plan" assert len(message.blocks[0]["tasks"]) > 1 - assert URL not in json.dumps(message.blocks[0]) + assert URL in json.dumps(message.blocks[0]["tasks"][0]) + assert URL not in message.text assert "Worked for" not in message.text diff --git a/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py b/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py index 08f730129..ad8ea60ee 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_review_regressions.py @@ -37,8 +37,9 @@ def test_a_plan_header_always_discloses_the_steps_it_left_out(status): plan = render_activity_plan(items, _turn(status), elapsed_seconds=90).blocks[0] assert len(plan["tasks"]) == 50 - assert "10 earlier lines not shown" in plan["title"] - assert plan["tasks"][0]["task_id"] == "step-10" + assert "11 earlier lines not shown" in plan["title"] + assert plan["tasks"][0]["task_id"] == "switch-session" + assert plan["tasks"][1]["task_id"] == "step-11" def test_a_finished_plan_counts_the_steps_it_ran_not_the_ones_it_shows(): diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py index 759aa9651..b97b87d27 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -5,22 +5,24 @@ That is why the clock used to live in a message of its own: at one redraw every five seconds, anything open collapsed before it could be read. -`chat.appendStream` does not replace the message. A `plan_update` moves the -header without touching anything under it, and a `blocks` chunk replaces the one -block it names and leaves the rest of the message β€” and whatever the reader has -open β€” alone. So the two messages become one, the clock ticks in its header, and -an expanded step stays expanded. Measured against the live API before it was -built, not assumed. - -A streamed message is drawn in two ways at once, and the difference is the whole -shape of this. The stream's own plan is addressed with chunks and can only ever -be added to β€” a card cannot be taken back, and `details` on a `task_update` -*appends* to what the card already holds rather than replacing it. So that plan -carries the status line and one card that is sent once, and nothing that grows. -The steps live in ordinary `plan` blocks carried inside the stream, addressed by -`block_id` and replaced whole, so they can hold a different fifty than they held -a minute ago. Three of those blocks rotate β€” a line saying what is no longer -shown, then the newest two pages β€” and a turn of any length draws the same four. +`chat.appendStream` does not replace the message. A `blocks` chunk replaces the +one block it names and leaves the rest of the message β€” and whatever the reader +has open β€” alone. So the two messages become one, the clock ticks in it, and an +expanded step stays expanded. Measured against the live API before it was built, +not assumed. + +The whole turn is drawn in those blocks and in nothing else. A stream can carry +a plan of its own, addressed with chunks, and that is where the status line used +to live β€” but such a plan can only ever be added to, so it could never hold the +steps, and it cost the message a line above them. Measured: a stream with no +plan chunks at all still draws its blocks, and a plan with no cards in it draws +neither itself nor its title. So the header moved onto the newest section, and +the Console link moved into the first row of every section. + +Three blocks rotate β€” a line saying what is no longer shown, then the newest two +sections β€” and a turn of any length draws the same three. A section holds +forty-nine of the turn's cards, the fiftieth row being the session card, because +Slack caps a plan block at fifty. What these cover is the adapter's half: that it opens a stream where it can, sends only what moved, discloses what it had to leave out, stops at the end of @@ -99,16 +101,6 @@ def _chunks(client: FakeWebClient) -> list[list[dict[str, Any]]]: return [call["chunks"] for call in client.appended] -def _cards(client: FakeWebClient) -> list[dict[str, Any]]: - """Every `task_update` sent: the stream's own plan, which is the status.""" - return [ - chunk - for call in client.appended - for chunk in call["chunks"] - if chunk["type"] == "task_update" - ] - - def _drawn(client: FakeWebClient) -> dict[str, dict[str, Any]]: """The last block written to each block id, in the order the ids appeared. @@ -125,13 +117,25 @@ def _drawn(client: FakeWebClient) -> dict[str, dict[str, Any]]: def _pages(client: FakeWebClient) -> list[dict[str, Any]]: - """The step pages the message is currently showing, oldest first.""" + """The sections the message is currently showing, oldest first.""" return [block for block in _drawn(client).values() if block["type"] == "plan"] +def _session(page: dict[str, Any]) -> dict[str, Any]: + """A section's session card, which is the first row of every one of them.""" + card = page["tasks"][0] + assert card["task_id"] == "switch-session" + return dict(card) + + +def _cards(client: FakeWebClient) -> list[dict[str, Any]]: + """The session card as each section currently holds it, oldest section first.""" + return [_session(page) for page in _pages(client)] + + def _steps(client: FakeWebClient) -> list[dict[str, Any]]: """Every step card the message is currently showing, oldest first.""" - return [task for page in _pages(client) for task in page["tasks"]] + return [task for page in _pages(client) for task in page["tasks"][1:]] # ── Opening ────────────────────────────────────────────────────────────────── @@ -179,12 +183,12 @@ async def test_the_asker_is_whoever_last_spoke_in_the_thread_and_not_a_bot() -> # ── Sending only what moved ────────────────────────────────────────────────── -async def test_only_the_header_and_the_pages_that_moved_are_appended() -> None: +async def test_only_the_sections_that_moved_are_appended() -> None: """The whole point of a stream over an edit. An edit replaces the message's whole blocks array; an append replaces the - one block it names. The header moves on its own, the page of steps is - rewritten whole, and the session card goes out once and never again. + one block it names. A section is rewritten whole, and a message with one + section in it is one chunk however much of the turn changed. """ client = FakeWebClient() adapter = _adapter(client) @@ -198,13 +202,11 @@ async def test_only_the_header_and_the_pages_that_moved_are_appended() -> None: CHANNEL, "Agent", ref, TurnActivity([done, second], _turn(), 9.0), THREAD ) - opened = _chunks(client)[0] - assert [c["type"] for c in opened] == ["plan_update", "task_update", "blocks"] + assert [c["type"] for c in _chunks(client)[0]] == ["blocks"] later = _chunks(client)[1] - assert [c["type"] for c in later] == ["plan_update", "blocks"] - assert later[0]["title"] == "Working… 9s" - assert later[1]["blocks"][0]["title"] == "Activity 1–2 Β· Running: Grep" - assert [(t["title"], t["status"]) for t in later[1]["blocks"][0]["tasks"]] == [ + assert [c["type"] for c in later] == ["blocks"] + assert later[0]["blocks"][0]["title"] == "Working… 9s Β· Running: Grep" + assert [(t["title"], t["status"]) for t in later[0]["blocks"][0]["tasks"][1:]] == [ ("Read", "complete"), ("Grep", "in_progress"), ] @@ -216,7 +218,7 @@ async def test_a_blocks_chunk_never_carries_more_than_one_plan() -> None: append are fine, which is how both pages move together.""" client = FakeWebClient() adapter = _adapter(client) - many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(51)] + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(50)] await adapter.post_rich(CHANNEL, "Agent", TurnActivity(many, _turn()), THREAD) @@ -225,24 +227,24 @@ async def test_a_blocks_chunk_never_carries_more_than_one_plan() -> None: assert all(len(chunk["blocks"]) == 1 for chunk in sent) -async def test_a_page_that_did_not_move_is_not_sent_again() -> None: - """A full page is fifty cards and several kilobytes. Once a step lands in - one it never moves to another, so a settled page is left where it is.""" +async def test_a_section_that_did_not_move_is_not_sent_again() -> None: + """A full section is fifty cards and several kilobytes. Once a step lands in + one it never moves to another, so a settled section is left where it is.""" client = FakeWebClient() adapter = _adapter(client) - many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(51)] + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(50)] ref = await adapter.post_rich( CHANNEL, "Agent", TurnActivity(many, _turn(), 1.0), THREAD ) - over = [*many, _tool("t51", "Tool 51", status="completed")] + over = [*many, _tool("t50", "Tool 50", status="completed")] await adapter.update_rich( CHANNEL, "Agent", ref, TurnActivity(over, _turn(), 9.0), THREAD ) later = [c for c in _chunks(client)[1] if c["type"] == "blocks"] assert len(later) == 1 - assert later[0]["blocks"][0]["title"] == "Activity 51–52 Β· Last: Tool 51" + assert later[0]["blocks"][0]["title"] == "Working… 9s Β· Last: Tool 50" async def test_a_publish_that_changed_nothing_appends_nothing() -> None: @@ -257,30 +259,40 @@ async def test_a_publish_that_changed_nothing_appends_nothing() -> None: assert len(client.appended) == 1 -async def test_the_clock_moves_the_header_without_resending_a_card() -> None: - """What the second message existed to buy, bought inside the first.""" +async def test_the_clock_moves_the_live_section_and_nothing_above_it() -> None: + """What the second message existed to buy, bought inside the first. + + The clock is in a block's heading now rather than in a header of its own, so + a tick rewrites that block β€” there is no call that moves a block's title on + its own. What it must not do is disturb a settled section above it, which is + the one a reader is likely to have open. + """ client = FakeWebClient() adapter = _adapter(client) - tool = _tool("t1", "Read") + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(50)] ref = await adapter.post_rich( - CHANNEL, "Agent", TurnActivity([tool], _turn(), 5.0), THREAD + CHANNEL, "Agent", TurnActivity(many, _turn(), 5.0), THREAD ) await adapter.update_rich( - CHANNEL, "Agent", ref, TurnActivity([tool], _turn(), 10.0), THREAD + CHANNEL, "Agent", ref, TurnActivity(many, _turn(), 10.0), THREAD ) - assert _chunks(client)[1] == [{"type": "plan_update", "title": "Working… 10s"}] + assert [c["blocks"][0]["block_id"] for c in _chunks(client)[1]] == [ + "switch-steps-middle" + ] + assert _chunks(client)[1][0]["blocks"][0]["title"] == "Working… 10s Β· Last: Tool 49" -async def test_a_detail_takes_the_shape_of_the_place_it_is_sent_to() -> None: +async def test_a_detail_is_rich_text_because_that_is_what_a_card_takes() -> None: """One field name, two shapes, and Slack rejects the wrong one. Measured, not read: `details` on a `task_update` chunk is a plain string and the live API refuses every rich_text form of it, while `details` on a `task_card` inside a `plan` block requires rich_text and refuses the string. - The step cards moved from the first to the second, so the shape moved with - them β€” and getting it wrong takes down the whole append. + Every card the message holds is now in a block, the session card included, + so all of them take the second shape β€” and getting it wrong takes down the + whole append. """ client = FakeWebClient() adapter = _adapter(client) @@ -296,9 +308,11 @@ async def test_a_detail_takes_the_shape_of_the_place_it_is_sent_to() -> None: THREAD, ) - assert _cards(client)[0]["details"] == ( - "" - ) + assert _cards(client)[0]["details"]["elements"][0]["elements"][0] == { + "type": "link", + "url": "https://switch.example/session", + "text": "Open in Console app", + } assert _steps(client)[0]["details"]["type"] == "rich_text" assert _steps(client)[0]["details"]["elements"][0]["elements"][0] == { "type": "text", @@ -331,10 +345,28 @@ async def test_the_link_is_the_card_rather_than_a_line_under_a_label() -> None: assert _cards(client)[0]["hide_title"] is True +async def test_the_link_is_in_every_section_rather_than_only_the_first() -> None: + """A reader opens one section. A link in the other one is a link they have + to go looking for, and the section they opened is the section they chose.""" + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(60)] + url = "https://switch.example/session" + + await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many, _turn(), 9.0, session_url=url), THREAD + ) + + assert len(_pages(client)) == 2 + assert [ + card["details"]["elements"][0]["elements"][0]["url"] for card in _cards(client) + ] == [url, url] + + async def test_a_card_with_no_link_in_it_still_says_what_it_is() -> None: """Hiding the title is the link earning the row. With no link there is no second row to earn it, and a card hiding the only thing it holds is blank β€” - in a plan that exists to keep the status line above it drawn.""" + in a section that is drawn before the turn has anything else to put in it.""" client = FakeWebClient() adapter = _adapter(client) @@ -378,13 +410,19 @@ async def test_a_real_console_link_arrives_whole(renders_custom_schemes: bool) - THREAD, ) - assert _cards(client)[0]["details"] == f"<{url}|Open in Console app>" + assert _cards(client)[0]["details"]["elements"][0]["elements"][0]["url"] == url -async def test_the_console_link_goes_out_once() -> None: - """`details` on a `task_update` appends to what the card already holds - rather than replacing it, so a card re-sent with the same link shows the - link twice β€” and again on every redraw after that.""" +async def test_the_console_link_is_drawn_once_however_often_it_is_sent() -> None: + """A block is replaced whole, so a link re-sent is the same link, not a + second one. + + This is what moving the card out of the stream's own plan bought. `details` + on a `task_update` chunk *appends* to what the card already holds rather + than replacing it, so the link had to be tracked and dropped from every + chunk after the one that carried it, or the card came back holding it twice + β€” and again on every redraw after that. + """ client = FakeWebClient() adapter = _adapter(client) tool = _tool("t1", "Read") @@ -402,14 +440,18 @@ async def test_the_console_link_goes_out_once() -> None: THREAD, ) - assert [card["details"] for card in _cards(client)] == [ - f"<{url}|Open in Console app>" + assert len(_cards(client)) == 1 + links = [ + element + for card in _cards(client) + for element in card["details"]["elements"][0]["elements"] ] + assert [element["url"] for element in links] == [url] -async def test_a_link_that_only_turns_up_later_is_still_sent() -> None: - """Appending to a card that has no detail yet leaves just the link, so the - one card the stream owns is not spent before the session url arrives.""" +async def test_a_link_that_only_turns_up_later_is_still_drawn() -> None: + """The session url is built from configuration and three ids, and a publish + can be drawn before the code that builds it has them all.""" client = FakeWebClient() adapter = _adapter(client) tool = _tool("t1", "Read") @@ -426,80 +468,38 @@ async def test_a_link_that_only_turns_up_later_is_still_sent() -> None: THREAD, ) - assert [card.get("details") for card in _cards(client)] == [ - None, - f"<{url}|Open in Console app>", - ] + assert _cards(client)[0]["details"]["elements"][0]["elements"][0]["url"] == url -async def test_a_redraw_without_the_link_does_not_make_the_stream_forget_it() -> None: - """What the stream remembers is what Slack was sent, not what was last drawn. +async def test_the_live_section_spins_while_the_turn_runs_and_settles_with_it() -> None: + """Slack draws a block's glyph from the cards in it, and between two calls + every step card in a running turn is settled. - A publish can arrive without the session url β€” the clock ticks on whatever - the caller happens to hold. Remembering that empty card as though it had - been sent loses the fact that the link already went out, and the next - publish appends the same link to a card that is already carrying it. + So the session card carries the turn's state on the live section: sent + complete it showed a check beside "Working…", which is the one thing the top + of the message should never say while the turn is still going. On a settled + section above it the same card is complete, because a spinner there points + at a place where nothing is happening. """ client = FakeWebClient() adapter = _adapter(client) - tool = _tool("t1", "Read") - url = "https://switch.example/session" - - ref = await adapter.post_rich( - CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0, session_url=url), THREAD - ) - await adapter.update_rich( - CHANNEL, "Agent", ref, TurnActivity([tool], _turn(), 9.0), THREAD - ) - await adapter.update_rich( - CHANNEL, - "Agent", - ref, - TurnActivity([tool], _turn(), 14.0, session_url=url), - THREAD, - ) - - assert [card["details"] for card in _cards(client)] == [ - f"<{url}|Open in Console app>" - ] - - -async def test_the_status_card_spins_while_the_turn_runs_and_settles_with_it() -> None: - """Slack draws a block's glyph from the cards in it, and the stream's own - plan holds exactly one. Sent complete from the start it showed a check - beside "Working…", which is the one thing the top of the message should - never say while the turn is still going. - - Turning it over at the end is the one update that card ever takes, and it - has to carry the title with it: measured, an update of id and status alone - stores an empty title, and one that repeats `details` appends the link a - second time. - """ - client = FakeWebClient() - adapter = _adapter(client) - tool = _tool("t1", "Read") + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(60)] url = "https://switch.example/session" ref = await adapter.post_rich( - CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0, session_url=url), THREAD + CHANNEL, "Agent", TurnActivity(many, _turn(), 1.0, session_url=url), THREAD ) - done = tool.model_copy(update={"revision": 2, "status": "completed"}) + running = [card["status"] for card in _cards(client)] await adapter.update_rich( CHANNEL, "Agent", ref, - TurnActivity([done], _turn("completed"), 9.0, session_url=url), + TurnActivity(many, _turn("completed"), 9.0, session_url=url), THREAD, ) - assert [(card["title"], card["status"]) for card in _cards(client)] == [ - ("Switch session", "in_progress"), - ("Switch session", "complete"), - ] - assert [card.get("details") for card in _cards(client)] == [ - f"<{url}|Open in Console app>", - None, - ] + assert running == ["complete", "in_progress"] + assert [card["status"] for card in _cards(client)] == ["complete", "complete"] # ── Paging ─────────────────────────────────────────────────────────────────── @@ -507,10 +507,10 @@ async def test_the_status_card_spins_while_the_turn_runs_and_settles_with_it() - async def test_a_turn_of_any_length_draws_the_same_three_step_blocks() -> None: """Slack keeps a block where it was first written and has no call that - removes one, so a block per fifty steps would grow the message without - bound. Rotating a disclosure line and the newest two pages through a fixed - three ids keeps both the length and the order of the message fixed however - long the turn runs. + removes one, so a block per section would grow the message without bound. + Rotating a disclosure line and the newest two sections through a fixed three + ids keeps both the length and the order of the message fixed however long + the turn runs. """ client = FakeWebClient() adapter = _adapter(client) @@ -529,29 +529,30 @@ async def test_a_turn_of_any_length_draws_the_same_three_step_blocks() -> None: ) gone, older, newer = _drawn(client).values() - assert gone["elements"][0]["text"] == "_Activity 1–150 no longer shown_" - assert older["title"] == "Activity 151–200" - assert [task["title"] for task in older["tasks"]][:1] == ["Tool 150"] - assert newer["title"] == "Activity 201–240 Β· Last: Tool 239" - assert [task["title"] for task in newer["tasks"]][-1:] == ["Tool 239"] + assert gone["elements"][0]["text"] == "_Activity 1–147 no longer shown_" + assert older["title"] == "Activity 148–196" + assert [task["title"] for task in older["tasks"][1:]][:1] == ["Tool 147"] + assert newer["title"] == "Working… 4m 0s Β· Last: Tool 239" + assert [task["title"] for task in newer["tasks"][1:]][-1:] == ["Tool 239"] -async def test_what_is_no_longer_shown_is_one_line_above_the_steps() -> None: +async def test_what_is_no_longer_shown_is_one_line_above_the_sections() -> None: """One cumulative line naming the whole missing range, not a count tucked - into the title of a section. - - It is above the steps because it has to be created before them: Slack fixes - a block where it was first written, so a line added later would describe the - pages from underneath them. That is why the top block starts as the first - page of steps and is replaced in place β€” same id, same position β€” by the - line once there is something to disclose. + into the title of a section β€” and not there at all until a third section + has pushed the first one out. + + It is above the sections because it has to be created before them: Slack + fixes a block where it was first written, so a line added later would + describe them from underneath. That is why the top block starts as the first + section and is replaced in place β€” same id, same position β€” by the line once + there is something to disclose. """ client = FakeWebClient() adapter = _adapter(client) - many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(101)] + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(99)] ref = await adapter.post_rich( - CHANNEL, "Agent", TurnActivity(many[:100], _turn(), 1.0), THREAD + CHANNEL, "Agent", TurnActivity(many[:98], _turn(), 1.0), THREAD ) before = list(_drawn(client).values()) await adapter.update_rich( @@ -559,42 +560,42 @@ async def test_what_is_no_longer_shown_is_one_line_above_the_steps() -> None: ) assert [block["title"] for block in before] == [ - "Activity 1–50", - "Activity 51–100 Β· Last: Tool 99", + "Activity 1–49", + "Working… 1s Β· Last: Tool 97", ] after = list(_drawn(client).values()) assert [block["type"] for block in after] == ["context", "plan", "plan"] assert after[0]["block_id"] == before[0]["block_id"] - assert after[0]["elements"][0]["text"] == "_Activity 1–50 no longer shown_" + assert after[0]["elements"][0]["text"] == "_Activity 1–49 no longer shown_" assert [block["title"] for block in after[1:]] == [ - "Activity 51–100", - "Activity 101–101 Β· Last: Tool 100", + "Activity 50–98", + "Working… 9s Β· Last: Tool 98", ] -async def test_a_step_never_moves_between_pages_once_it_has_landed() -> None: - """Pages are cut on fixed boundaries β€” the first fifty, the next fifty β€” - rather than as a window on the newest hundred. A window would shuffle every - card down one on every step, which is a redraw of both blocks each time and - a card that is not the one the reader opened.""" +async def test_a_step_never_moves_between_sections_once_it_has_landed() -> None: + """Sections are cut on fixed boundaries β€” the first forty-nine, the next + forty-nine β€” rather than as a window on the newest ninety-eight. A window + would shuffle every card down one on every step, which is a redraw of both + blocks each time and a card that is not the one the reader opened.""" client = FakeWebClient() adapter = _adapter(client) many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(120)] ref = await adapter.post_rich( - CHANNEL, "Agent", TurnActivity(many[:51], _turn(), 1.0), THREAD + CHANNEL, "Agent", TurnActivity(many[:50], _turn(), 1.0), THREAD ) await adapter.update_rich( - CHANNEL, "Agent", ref, TurnActivity(many[:52], _turn(), 9.0), THREAD + CHANNEL, "Agent", ref, TurnActivity(many[:51], _turn(), 9.0), THREAD ) moved = [c for c in _chunks(client)[1] if c["type"] == "blocks"] assert [chunk["blocks"][0]["title"] for chunk in moved] == [ - "Activity 51–52 Β· Last: Tool 51" + "Working… 9s Β· Last: Tool 50" ] older, newer = _pages(client) - assert [task["title"] for task in older["tasks"]][:1] == ["Tool 0"] - assert [task["title"] for task in newer["tasks"]] == ["Tool 50", "Tool 51"] + assert [task["title"] for task in older["tasks"][1:]][:1] == ["Tool 0"] + assert [task["title"] for task in newer["tasks"][1:]] == ["Tool 49", "Tool 50"] # ── Where the live step is named ───────────────────────────────────────────── @@ -605,10 +606,11 @@ async def test_the_live_step_is_named_on_its_own_section_and_not_in_the_header() ): """Said once, where it is useful. - The header is the whole of a collapsed message, so naming the tool there - told a reader what was running but not where to open to watch it. On the - section it does both: the heading that names the step is the one holding - it, and past fifty steps there is more than one heading to choose between. + A heading is the whole of a collapsed section, so the label belongs on the + heading of the section actually holding the live step β€” that is both what is + running and where to open to watch it. It is not always the newest section: + the clock sits there, and a step that is still open can be a section behind + it. """ client = FakeWebClient() adapter = _adapter(client) @@ -617,10 +619,9 @@ async def test_the_live_step_is_named_on_its_own_section_and_not_in_the_header() await adapter.post_rich(CHANNEL, "Agent", TurnActivity(many, _turn(), 40.0), THREAD) - assert _chunks(client)[0][0] == {"type": "plan_update", "title": "Working… 40s"} assert [page["title"] for page in _pages(client)] == [ - "Activity 1–50 Β· Running: Grep", - "Activity 51–60", + "Activity 1–49 Β· Running: Grep", + "Working… 40s", ] @@ -629,17 +630,17 @@ async def test_the_label_leaves_a_settled_section_when_the_live_step_moves_past_ ): """A heading saying what is running has to stop saying it once nothing is. - Crossing a page boundary is the one moment a settled page is rewritten, and - it is rewritten to drop the label rather than to change its steps. Leaving - it would put "Running: …" on a section where that step has finished and the - reader would open the wrong one. + Crossing a section boundary is the one moment a settled section is + rewritten, and it is rewritten to drop the label rather than to change its + steps. Leaving it would put "Last: …" on a section whose last step is no + longer the turn's, and the reader would open the wrong one. """ client = FakeWebClient() adapter = _adapter(client) - many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(51)] + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(50)] ref = await adapter.post_rich( - CHANNEL, "Agent", TurnActivity(many[:50], _turn(), 1.0), THREAD + CHANNEL, "Agent", TurnActivity(many[:49], _turn(), 1.0), THREAD ) await adapter.update_rich( CHANNEL, "Agent", ref, TurnActivity(many, _turn(), 9.0), THREAD @@ -647,12 +648,12 @@ async def test_the_label_leaves_a_settled_section_when_the_live_step_moves_past_ first = [c for c in _chunks(client)[0] if c["type"] == "blocks"] assert [chunk["blocks"][0]["title"] for chunk in first] == [ - "Activity 1–50 Β· Last: Tool 49" + "Working… 1s Β· Last: Tool 48" ] later = [c for c in _chunks(client)[1] if c["type"] == "blocks"] assert [chunk["blocks"][0]["title"] for chunk in later] == [ - "Activity 1–50", - "Activity 51–51 Β· Last: Tool 50", + "Activity 1–49", + "Working… 9s Β· Last: Tool 49", ] @@ -669,7 +670,7 @@ async def test_a_step_title_is_not_escaped_because_nothing_in_it_is_parsed() -> ) assert _steps(client)[0]["title"] == shell - assert _pages(client)[0]["title"] == f"Activity 1–1 Β· Running: {shell}" + assert _pages(client)[0]["title"] == f"Working… 1s Β· Running: {shell}" # ── Ending ─────────────────────────────────────────────────────────────────── @@ -692,7 +693,9 @@ async def test_a_finished_turn_stops_the_stream_and_leaves_the_message() -> None assert client.stopped == [{"channel": CHANNEL, "ts": "1.0"}] assert client.deleted == [] - assert _chunks(client)[-1][0]["title"] == "Worked for 30s. 1 tool call." + assert ( + _chunks(client)[-1][0]["blocks"][0]["title"] == "Worked for 30s. 1 tool call." + ) async def test_an_unfinished_step_is_named_rather_than_left_spinning() -> None: @@ -888,7 +891,9 @@ async def refuse(**kwargs: Any) -> None: assert refused.value.retry_after == 7.0 assert ref in adapter._streams - assert adapter._streams[ref].title == "Working… 1s" + assert [block["title"] for block in adapter._streams[ref].blocks.values()] == [ + "Working… 1s Β· Running: Read" + ] # ── End to end, through the publisher ──────────────────────────────────────── @@ -919,8 +924,4 @@ async def test_a_turn_published_from_start_to_finish_is_one_streamed_message() - assert len(client.stopped) == 1 assert [ [chunk["type"] for chunk in call["chunks"]] for call in client.appended - ] == [ - ["plan_update", "task_update", "blocks"], - ["plan_update"], - ["plan_update", "task_update", "blocks"], - ] + ] == [["blocks"], ["blocks"], ["blocks"]] diff --git a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py index 01fd8a09d..060ccb2e3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py @@ -541,7 +541,8 @@ async def test_the_clock_and_the_tool_log_share_one_message_and_keep_warnings() plan = client.updated[-1]["blocks"][0] assert client.updated[-1]["ts"] == "1.0" assert "Worked for 10s" in plan["title"] - task = plan["tasks"][0] + assert plan["tasks"][0]["task_id"] == "switch-session" + task = plan["tasks"][1] assert task["status"] == "complete" assert "Read" in task["title"] and task["title"] != "Read" assert client.api_calls == [] From 72a1c06cc9200b19af959ae76a06ef03f04c479d Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 13:45:53 +0100 Subject: [PATCH 114/120] Mark a tool call in the platforms whose activity log is body text A Slack card says what kind of line it is with an icon. Mattermost, Discord, Teams and Telegram get the same log as plain text, where the outcome glyph was the only thing in front of a title and nothing said the line was a call at all. Give the kind a column of its own ahead of the outcome, and move the remark marker to a glyph that reads as speech rather than as a continuation. Turn summary counts keep the bare outcome glyphs: they count calls, they are not calls. Co-Authored-By: Claude Opus 5 --- .../session/renderers/neutral.py | 16 ++++++-- .../test_discord_activity_view.py | 4 +- .../test_mattermost_activity_view.py | 8 ++-- .../collaboration/test_session_activity.py | 10 ++--- .../test_session_activity_log.py | 38 +++++++++---------- .../test_session_turn_messages.py | 2 +- .../collaboration/test_teams_activity_fold.py | 12 +++--- .../test_telegram_activity_fold.py | 18 ++++----- 8 files changed, 58 insertions(+), 50 deletions(-) diff --git a/core/switch_core/bridges/collaboration/session/renderers/neutral.py b/core/switch_core/bridges/collaboration/session/renderers/neutral.py index 94248b5e7..9bd2fdb4f 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -112,11 +112,19 @@ _LOG_EMPTY_YET = "No activity yet." _LOG_CUT = "…{left} earlier in this turn, not shown." +# What kind of line this is, in the column before the outcome. Slack draws the +# distinction with a card icon; a platform whose log is body text has only the +# line, so the kind gets a glyph of its own rather than being left for a reader +# to infer from the outcome. Two columns each answering one question are read +# faster than one column answering whichever question suits the line. +_TOOL_MARKER = "βŒ—" + # What the agent said, and how much of it. The marker is not one of the outcome # glyphs because a sentence has no outcome, and a tick beside one would read as -# a call that succeeded. The ceiling is a call's two ceilings added, so neither -# kind of line is the systematically longer one. -SAID_MARKER = "Β»" +# a call that succeeded β€” which is also why a remark occupies the kind column +# alone and leaves the outcome column empty. The ceiling is a call's two +# ceilings added, so neither kind of line is the systematically longer one. +SAID_MARKER = "❝" _LOG_SAID_TEXT = _LOG_TITLE + _LOG_DETAIL # What a reader is told, privately and in place of the log, when the press @@ -406,7 +414,7 @@ def _log_line(item: Item, *, escape: Callable[[str], str]) -> str: said = _fit(" ".join(item.text.split()), _LOG_SAID_TEXT, escape=escape) return f"{SAID_MARKER} {said}" title = _fit(item.title, _LOG_TITLE, escape=escape) if item.title else _LOG_UNTITLED - line = f"{_OUTCOME[item.status]} {title}" + line = f"{_TOOL_MARKER} {_OUTCOME[item.status]} {title}" if item.text: line += f" β€” {_fit(item.text, _LOG_DETAIL, escape=escape)}" return line diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py index 4399eae3d..c3399ce66 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py @@ -914,8 +914,8 @@ async def test_the_view_carries_what_the_agent_said_as_well_as_what_it_did() -> lines = _shown(press).splitlines() assert lines[1:3] == [ - "\u2713 Ran the tests", - "\u00bb Both write to the same fixture user.", + "\u2317 \u2713 Ran the tests", + "\u275d Both write to the same fixture user.", ] diff --git a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py index 96a53f0e0..71489ccea 100644 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py @@ -351,7 +351,7 @@ async def test_the_log_is_the_calls_oldest_first_under_the_state_line() -> None: lines = _shown(await adapter._handle_callback(_press())).splitlines() - assert lines[1:3] == ["βœ“ Read config.toml", "βœ“ Ran the tests β€” 42 passed"] + assert lines[1:3] == ["βŒ— βœ“ Read config.toml", "βŒ— βœ“ Ran the tests β€” 42 passed"] async def test_the_log_carries_what_the_agent_said_as_well_as_what_it_did() -> None: @@ -377,8 +377,8 @@ async def test_the_log_carries_what_the_agent_said_as_well_as_what_it_did() -> N lines = _shown(await adapter._handle_callback(_press())).splitlines() assert lines[1:3] == [ - "\u2713 Ran the tests", - "\u00bb Both write to the same fixture user.", + "\u2317 \u2713 Ran the tests", + "\u275d Both write to the same fixture user.", ] @@ -435,7 +435,7 @@ async def test_a_log_too_long_for_a_post_is_cut_rather_than_refused() -> None: assert len(shown) <= adapter.rich_fallback_limit() assert "not shown." in shown - assert shown.splitlines()[-2] == "βœ“ Call 399" + assert shown.splitlines()[-2] == "βŒ— βœ“ Call 399" async def test_what_a_host_called_a_tool_cannot_address_the_channel() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 61d6bb18a..6f039762a 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -850,7 +850,7 @@ async def test_what_the_agent_said_is_a_card_in_the_plan_marked_as_speech() -> N ) titles = [task["title"] for task in _turn_cards(drawn.blocks[0])] - assert titles == ["Ran the tests", "Β» All green."] + assert titles == ["Ran the tests", "❝ All green."] async def test_a_sentence_is_never_marked_unfinished_when_the_turn_stops() -> None: @@ -862,7 +862,7 @@ async def test_a_sentence_is_never_marked_unfinished_when_the_turn_stops() -> No ) card = _turn_cards(drawn.blocks[0])[0] - assert card["title"] == "Β» Handing it over." + assert card["title"] == "❝ Handing it over." assert "Unfinished" not in card["title"] @@ -872,7 +872,7 @@ async def test_a_turn_that_only_talked_has_a_plan_rather_than_a_bare_line() -> N drawn = render_activity_plan([_said("Fixed that yesterday.")], _turn("completed")) assert drawn.blocks[0]["type"] == "plan" - assert _turn_cards(drawn.blocks[0])[0]["title"] == "Β» Fixed that yesterday." + assert _turn_cards(drawn.blocks[0])[0]["title"] == "❝ Fixed that yesterday." async def test_a_paragraph_is_folded_onto_the_one_line_its_card_gives_it() -> None: @@ -883,7 +883,7 @@ async def test_a_paragraph_is_folded_onto_the_one_line_its_card_gives_it() -> No ) assert ( - _turn_cards(drawn.blocks[0])[0]["title"] == "Β» First thought. Second thought." + _turn_cards(drawn.blocks[0])[0]["title"] == "❝ First thought. Second thought." ) @@ -916,7 +916,7 @@ async def test_a_hidden_title_still_says_what_the_card_is() -> None: the title, and an empty one would leave the remark showing as nothing.""" drawn = render_activity_plan([_said("All green.")], _turn("completed")) - assert _turn_cards(drawn.blocks[0])[0]["title"] == "Β» All green." + assert _turn_cards(drawn.blocks[0])[0]["title"] == "❝ All green." async def test_prose_and_calls_are_told_apart_by_the_glyph_not_by_the_status() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py index bfef7394b..f34841f40 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py @@ -75,13 +75,13 @@ def test_every_call_is_there_oldest_first_under_the_state_line() -> None: lines = _log(items) - assert lines[1:] == ["βœ“ First", "βœ“ Second", "βœ“ Third"] + assert lines[1:] == ["βŒ— βœ“ First", "βŒ— βœ“ Second", "βŒ— βœ“ Third"] def test_a_call_that_said_something_says_it_beside_the_name() -> None: lines = _log([_call(title="Ran the tests", text="42 passed")]) - assert lines[1] == "βœ“ Ran the tests β€” 42 passed" + assert lines[1] == "βŒ— βœ“ Ran the tests β€” 42 passed" def test_how_a_call_went_is_on_the_line_rather_than_left_to_the_tally() -> None: @@ -90,7 +90,7 @@ def test_how_a_call_went_is_on_the_line_rather_than_left_to_the_tally() -> None: lines = _log(items) - assert lines[1:] == ["βœ“ Read it", "βœ— Wrote it"] + assert lines[1:] == ["βŒ— βœ“ Read it", "βŒ— βœ— Wrote it"] def test_a_call_with_no_name_is_shown_rather_than_dropped() -> None: @@ -98,7 +98,7 @@ def test_a_call_with_no_name_is_shown_rather_than_dropped() -> None: that silently omitted it would undercount the turn.""" lines = _log([_call(title="")]) - assert lines[1] == "βœ“ (untitled)" + assert lines[1] == "βŒ— βœ“ (untitled)" # ── What the agent said, beside what it did ────────────────────────────────── @@ -116,9 +116,9 @@ def test_what_the_agent_said_sits_where_it_was_said() -> None: lines = _log(items) assert lines[1:] == [ - "βœ“ Read the adapter", - "Β» That test shares a fixture user with the session test.", - "βœ“ Ran the tests", + "βŒ— βœ“ Read the adapter", + "❝ That test shares a fixture user with the session test.", + "βŒ— βœ“ Ran the tests", ] @@ -127,7 +127,7 @@ def test_a_sentence_is_not_marked_as_a_call_that_succeeded() -> None: something the agent did and got right.""" lines = _log([_said("Looking now."), _call(title="Searched")]) - assert lines[1].startswith("Β»") + assert lines[1].startswith("❝") assert "βœ“" not in lines[1] @@ -136,7 +136,7 @@ def test_a_paragraph_is_folded_onto_the_one_line_it_is_given() -> None: indistinguishable from four things having happened.""" lines = _log([_said("First thought.\n\nSecond thought.\nThird.")]) - assert lines[1:] == ["Β» First thought. Second thought. Third."] + assert lines[1:] == ["❝ First thought. Second thought. Third."] def test_an_item_the_host_has_opened_and_not_filled_is_not_a_line() -> None: @@ -144,7 +144,7 @@ def test_an_item_the_host_has_opened_and_not_filled_is_not_a_line() -> None: nothing after it says the agent said something and withholds it.""" lines = _log([_said(""), _call(title="Searched")]) - assert lines[1:] == ["βœ“ Searched"] + assert lines[1:] == ["βŒ— βœ“ Searched"] def test_a_turn_that_only_talked_has_a_log_rather_than_nothing() -> None: @@ -152,7 +152,7 @@ def test_a_turn_that_only_talked_has_a_log_rather_than_nothing() -> None: to leave a reader who asked what happened with "No activity.".""" lines = _log([_said("Yes β€” it was fixed in the merge yesterday.")]) - assert lines[1:] == ["Β» Yes β€” it was fixed in the merge yesterday."] + assert lines[1:] == ["❝ Yes β€” it was fixed in the merge yesterday."] def test_a_long_remark_is_cut_like_a_call_is_rather_than_spending_the_log() -> None: @@ -162,7 +162,7 @@ def test_a_long_remark_is_cut_like_a_call_is_rather_than_spending_the_log() -> N assert lines[1].endswith("…") assert len(lines[1]) <= 2 + 200 + 120 - assert lines[2] == "βœ“ Searched" + assert lines[2] == "βŒ— βœ“ Searched" def test_a_remark_is_escaped_the_way_a_call_name_is() -> None: @@ -179,7 +179,7 @@ def test_a_remark_is_escaped_the_way_a_call_name_is() -> None: heading=False, ).splitlines() - assert lines == ["Β» <b>not bold</b>"] + assert lines == ["❝ <b>not bold</b>"] def test_the_cut_counts_what_was_said_as_well_as_what_was_done() -> None: @@ -191,7 +191,7 @@ def test_the_cut_counts_what_was_said_as_well_as_what_was_done() -> None: assert lines[1].startswith("…") assert "not shown" in lines[1] - assert lines[-1] == "Β» Thought 19." + assert lines[-1] == "❝ Thought 19." def test_a_turn_that_has_ended_with_no_calls_says_it_made_none() -> None: @@ -213,14 +213,14 @@ def test_a_log_too_long_for_the_budget_is_cut_at_the_oldest_end() -> None: lines = _log(items, limit=120) - assert lines[-1] == "βœ“ Call 19" + assert lines[-1] == "βŒ— βœ“ Call 19" def test_a_cut_log_says_how_many_calls_it_is_not_showing() -> None: items = [_call(title=f"Call {index}") for index in range(20)] lines = _log(items, limit=120) - shown = [line for line in lines[1:] if line.startswith("βœ“")] + shown = [line for line in lines[1:] if line.startswith("βŒ—")] assert lines[1] == f"…{20 - len(shown)} earlier in this turn, not shown." @@ -262,7 +262,7 @@ def test_a_log_that_declines_the_heading_starts_at_the_first_call() -> None: first line is the card showing one sentence twice.""" items = [_call(title="First"), _call(title="Second")] - assert _log(items, heading=False) == ["βœ“ First", "βœ“ Second"] + assert _log(items, heading=False) == ["βŒ— βœ“ First", "βŒ— βœ“ Second"] def test_declining_the_heading_gives_its_room_back_to_the_calls() -> None: @@ -270,9 +270,9 @@ def test_declining_the_heading_gives_its_room_back_to_the_calls() -> None: line to pay for fits more of the turn into the same space.""" items = [_call(title=f"Call {index}") for index in range(20)] - with_head = [line for line in _log(items, limit=120)[1:] if line.startswith("βœ“")] + with_head = [line for line in _log(items, limit=120)[1:] if line.startswith("βŒ—")] without = [ - line for line in _log(items, limit=120, heading=False) if line.startswith("βœ“") + line for line in _log(items, limit=120, heading=False) if line.startswith("βŒ—") ] assert len(without) > len(with_head) diff --git a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py index 060ccb2e3..d01f0f6d3 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py @@ -511,7 +511,7 @@ async def test_what_the_agent_said_is_behind_the_plan_and_not_in_the_notificatio await _publish(activity, [narration, tool], _turn("completed"), elapsed_seconds=25) for call in [*client.posted, *client.updated]: assert "Answered in the room" not in call.get("text", "") - assert "Β» Answered in the room." in _blocks(client.posted[0]) + assert "❝ Answered in the room." in _blocks(client.posted[0]) assert "Read file" in _blocks(client.posted[0]) assert "Worked for 25s" in _blocks(client.updated[0]) diff --git a/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py b/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py index b96162518..382d16c25 100644 --- a/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py +++ b/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py @@ -82,7 +82,7 @@ def test_an_ended_turn_carries_its_tool_calls_folded_under_the_status() -> None: _run(adapter.post_rich(CHANNEL, AGENT, _ended("Read a file", "Ran a test"), ROOT)) - assert _log_lines(_posted(connector)) == ["βœ“ Read a file", "βœ“ Ran a test"] + assert _log_lines(_posted(connector)) == ["βŒ— βœ“ Read a file", "βŒ— βœ“ Ran a test"] def test_a_running_turn_is_offered_no_fold_because_the_next_call_would_shut_it() -> ( @@ -128,7 +128,7 @@ def test_the_fold_arrives_on_the_redraw_that_ends_the_turn() -> None: _run(adapter.update_rich(CHANNEL, AGENT, ref, _ended("Ran a test"), ROOT)) edited = connector.updates[0]["activity"]["attachments"][0]["content"] - assert _log_lines(edited) == ["βœ“ Ran a test"] + assert _log_lines(edited) == ["βŒ— βœ“ Ran a test"] def test_what_the_agent_said_is_in_the_fold_and_not_on_the_card() -> None: @@ -147,7 +147,7 @@ def test_what_the_agent_said_is_in_the_fold_and_not_on_the_card() -> None: ) card = _posted(connector) - assert _log_lines(card) == ["\u2713 Ran the tests", "\u00bb All green."] + assert _log_lines(card) == ["\u2317 \u2713 Ran the tests", "\u275d All green."] assert "All green." not in card["fallbackText"] @@ -163,7 +163,7 @@ def test_a_turn_that_only_talked_is_still_worth_a_fold() -> None: ) ) - assert _log_lines(_posted(connector)) == ["\u00bb Fixed yesterday."] + assert _log_lines(_posted(connector)) == ["\u275d Fixed yesterday."] # ── What opening it does ───────────────────────────────────────────────────── @@ -298,7 +298,7 @@ def test_the_log_does_not_repeat_the_state_line_it_is_folded_under() -> None: card = _posted(connector) assert "console.example.test" in _card_text(connector.sends[0]["activity"]) - assert _log_lines(card) == ["βœ“ Ran a test"] + assert _log_lines(card) == ["βŒ— βœ“ Ran a test"] def test_host_text_in_the_log_goes_through_the_platforms_own_escape() -> None: @@ -328,4 +328,4 @@ def test_a_log_too_long_for_the_card_is_cut_and_says_how_much_it_cut() -> None: assert lines[0].startswith("…") assert "not shown" in lines[0] assert len("\n".join(lines)) <= 2000 - assert lines[-1] == "βœ“ Call 399" + assert lines[-1] == "βŒ— βœ“ Call 399" diff --git a/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py b/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py index c49200481..c994ec628 100644 --- a/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py @@ -65,7 +65,7 @@ async def test_a_finished_turn_carries_its_tool_calls_folded_under_the_status() CHANNEL, "my-agent", _ended("Read a file", "Ran a test"), None ) - assert _fold(_sent(adapter)) == ["βœ“ Read a file", "βœ“ Ran a test"] + assert _fold(_sent(adapter)) == ["βŒ— βœ“ Read a file", "βŒ— βœ“ Ran a test"] async def test_a_running_turn_is_offered_no_fold_because_the_next_edit_shuts_it() -> ( @@ -89,7 +89,7 @@ async def test_the_fold_arrives_on_the_edit_that_ends_the_turn() -> None: await adapter.update_rich(CHANNEL, "my-agent", ref, _ended("Ran a test"), None) - assert _fold(_last_edit(adapter)) == ["βœ“ Ran a test"] + assert _fold(_last_edit(adapter)) == ["βŒ— βœ“ Ran a test"] async def test_a_turn_with_nothing_behind_it_is_offered_nothing_to_open() -> None: @@ -154,7 +154,7 @@ async def test_the_log_does_not_repeat_the_status_it_is_folded_under() -> None: text = _sent(adapter) assert SESSION_URL in text - assert _fold(text) == ["βœ“ Ran a test"] + assert _fold(text) == ["βŒ— βœ“ Ran a test"] async def test_the_calls_read_oldest_first_so_the_newest_is_where_it_ended() -> None: @@ -164,7 +164,7 @@ async def test_the_calls_read_oldest_first_so_the_newest_is_where_it_ended() -> CHANNEL, "my-agent", _ended("First", "Second", "Third"), None ) - assert _fold(_sent(adapter)) == ["βœ“ First", "βœ“ Second", "βœ“ Third"] + assert _fold(_sent(adapter)) == ["βŒ— βœ“ First", "βŒ— βœ“ Second", "βŒ— βœ“ Third"] async def test_host_text_in_the_log_cannot_close_the_block_it_is_inside() -> None: @@ -195,7 +195,7 @@ async def test_a_log_too_long_for_the_message_is_cut_and_says_how_much() -> None lines = _fold(_sent(adapter)) assert lines[0].startswith("…") assert "not shown" in lines[0] - assert lines[-1] == "βœ“ Call 399" + assert lines[-1] == "βŒ— βœ“ Call 399" async def test_the_notice_that_nobody_was_reached_stays_out_of_the_fold() -> None: @@ -209,7 +209,7 @@ async def test_the_notice_that_nobody_was_reached_stays_out_of_the_fold() -> Non text = _sent(adapter) assert adapter.unnotified_notice() in text - assert _fold(text) == ["βœ“ Ran a test"] + assert _fold(text) == ["βŒ— βœ“ Ran a test"] async def test_what_the_agent_said_is_in_the_fold_and_not_in_the_chat() -> None: @@ -230,7 +230,7 @@ async def test_what_the_agent_said_is_in_the_fold_and_not_in_the_chat() -> None: ) text = _sent(adapter) - assert _fold(text) == ["βœ“ Ran the tests", "Β» All green."] + assert _fold(text) == ["βŒ— βœ“ Ran the tests", "❝ All green."] assert "All green." not in text.split("\n None: None, ) - assert _fold(_sent(adapter)) == ["Β» Fixed yesterday."] + assert _fold(_sent(adapter)) == ["❝ Fixed yesterday."] # ── What it costs the message ──────────────────────────────────────────────── @@ -310,4 +310,4 @@ async def test_a_turn_republished_after_a_refusal_still_carries_its_calls() -> N fallback = adapter.rich_fallback_text(_ended("Ran a test")) - assert _fold(fallback) == ["βœ“ Ran a test"] + assert _fold(fallback) == ["βŒ— βœ“ Ran a test"] From 28577b6e6c6d7b19af1c6b55b4df6289b5298133 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 13:53:40 +0100 Subject: [PATCH 115/120] Leave a two-section activity message as its stream drew it A turn that overflowed its first section was being redrawn as one section the moment its stream closed: the whole turn replaced by its last forty-nine lines under "N earlier lines not shown", to a reader who had just watched it run. The stream is forgotten when the turn ends, so any publication after that falls to the ordinary-post renderer and chat.update replaces the message with what it draws. It cannot draw the sections: measured against the live API, chat.update refuses a message carrying more than one plan block exactly as chat.postMessage does, and refuses it on a message a stream itself built. There is no edit that keeps them. So the adapter remembers which messages a stream drew in more than one section and declines to edit those, warning once. The message stands as the stream left it, which is the turn; only a revision that landed after the turn ended goes unshown, and that is said out loud rather than passed over. A turn that never left one section is still redrawn as before. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/slack/adapter.py | 52 +++++++++- .../test_session_slack_streaming.py | 94 +++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index e75a3f9dc..2e5dffd11 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -192,7 +192,7 @@ class _ActivityStream: A stream is a conversation, not a document: Slack keeps the message and each append moves part of it. So the adapter has to remember what it last - said to work out what is worth saying next β€” resending a page of fifty + said to work out what is worth saying next β€” resending a section of fifty cards that has not changed costs an append and risks nothing useful. `blocks` is the last thing written to each section, by `block_id`, so a @@ -260,6 +260,10 @@ def __init__(self, *, config: SlackConnectionConfig) -> None: # Open activity streams by message ref, holding what has already been # appended so a redraw can send only what changed. self._streams: OrderedDict[str, _ActivityStream] = OrderedDict() + # Messages a stream drew in more than one section, which nothing can + # redraw. The value records whether that has already been reported, so + # a turn asked for repeatedly says it once. See `_forget_stream`. + self._unredrawable: OrderedDict[str, bool] = OrderedDict() # Folded Slack username β†’ user id, for resolving outbound @mentions to # real Slack mentions. Primed from the bridge's known external users and # topped up as new ones are resolved. @@ -622,6 +626,18 @@ async def update_rich( if stream is not None and isinstance(content, TurnActivity): await self._extend_stream(stream, message_ref, content) return + if message_ref in self._unredrawable: + if not self._unredrawable[message_ref]: + self._unredrawable[message_ref] = True + logger.warning( + "Leaving the activity message %s as its stream drew it: it " + "holds two sections and Slack takes only one in an edit, so " + "redrawing it would replace the turn with its last few " + "dozen lines. Anything that changed since the stream closed " + "is not shown.", + message_ref, + ) + return responder_name = None if isinstance(content, RequestCard) and content.responder_external_id: user = await self._resolve_user_name(content.responder_external_id) @@ -777,7 +793,8 @@ async def _open_stream( stream = _ActivityStream(channel_id=channel_id, ts=str(ts)) self._streams[ref] = stream while len(self._streams) > _MAX_OPEN_STREAMS: - abandoned, _ = self._streams.popitem(last=False) + abandoned = next(iter(self._streams)) + self._forget_stream(abandoned) logger.warning( "Forgetting the activity stream %s to make room; its turn never " "ended, so the message is left in its streaming state.", @@ -786,6 +803,33 @@ async def _open_stream( await self._extend_stream(stream, ref, content) return ref + def _forget_stream(self, message_ref: str) -> None: + """Drop a stream, and note whether its message can still be redrawn. + + A message a stream drew in two sections cannot be redrawn by anything. + Measured: `chat.update` refuses a message carrying two plan blocks + exactly as `chat.postMessage` does, and it refuses it on a message a + stream itself built β€” so once the stream has gone, an edit can only + offer the single section the fallback draws, which is the turn's + history replaced by its last forty-nine lines. + + Remembering which messages those are is what lets a later publication + be declined rather than drawn. The message is already showing the turn + as the stream finally left it, so there is nothing owed to a reader in + the ordinary case β€” only a revision that landed after the turn ended + goes unshown, which is why it is said out loud rather than passed over. + """ + stream = self._streams.pop(message_ref, None) + if stream is None: + return + sections = sum(block.get("type") == "plan" for block in stream.blocks.values()) + if sections < 2: + return + self._unredrawable[message_ref] = False + self._unredrawable.move_to_end(message_ref) + while len(self._unredrawable) > _MAX_OPEN_STREAMS: + self._unredrawable.popitem(last=False) + async def _extend_stream( self, stream: _ActivityStream, message_ref: str, content: TurnActivity ) -> None: @@ -846,7 +890,7 @@ async def _close_stream( The turn is over and the plan is the record of what it did, so unlike the old progress card there is nothing here to delete. """ - self._streams.pop(message_ref, None) + self._forget_stream(message_ref) try: await client.chat_stopStream(channel=stream.channel_id, ts=stream.ts) except SlackApiError as error: @@ -871,7 +915,7 @@ def _stream_failed( """ code = error.response.get("error") if code in _STREAM_CLOSED_ERRORS: - self._streams.pop(message_ref, None) + self._forget_stream(message_ref) logger.warning( "The activity stream %s is no longer accepting appends (%s); " "later updates will be drawn as an ordinary message.", diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py index b97b87d27..787a52db8 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -865,6 +865,100 @@ async def test_a_closed_stream_is_forgotten_so_later_updates_are_edits( assert [call["ts"] for call in client.updated] == ["1.0"] +async def test_a_turn_of_two_sections_is_left_as_the_stream_drew_it( + caplog: Any, +) -> None: + """The one thing an edit cannot take back. + + Measured, not read: `chat.update` refuses a message carrying two plan + blocks exactly as `chat.postMessage` does, and refuses it on a message a + stream itself built. So a turn long enough to have overflowed its first + section can only be redrawn as the single section the fallback draws β€” the + whole turn replaced by its last forty-nine lines, under a reader who + watched it run. Once the stream has closed, the message stands. + """ + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(60)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many, _turn(), 1.0), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(many, _turn("completed"), 9.0), THREAD + ) + with caplog.at_level(logging.WARNING): + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(many, _turn("completed"), 9.0), THREAD + ) + + assert client.updated == [] + assert "holds two sections" in caplog.text + assert [page["title"] for page in _pages(client)] == [ + "Activity 1–49", + "Worked for 9s. 60 tool calls.", + ] + + +async def test_a_message_that_says_it_cannot_be_redrawn_says_it_once( + caplog: Any, +) -> None: + """A turn that has ended is still published for as long as anything about + it moves, and a line of log on each of those would bury the one that + matters.""" + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(60)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many, _turn(), 1.0), THREAD + ) + with caplog.at_level(logging.WARNING): + for elapsed in (9.0, 10.0, 11.0, 12.0): + await adapter.update_rich( + CHANNEL, + "Agent", + ref, + TurnActivity(many, _turn("completed"), elapsed), + THREAD, + ) + + said = [ + record for record in caplog.records if "holds two sections" in record.message + ] + assert len(said) == 1 + + +async def test_a_turn_that_never_left_one_section_is_still_redrawn() -> None: + """The guard is about what an edit cannot carry, not about having streamed. + + A turn that fits one section draws the same single block either way, so the + message is still the whole turn after an edit and a revision that lands + late is still worth showing. + """ + client = FakeWebClient() + adapter = _adapter(client) + tool = _tool("t1", "Read", status="completed") + url = "https://switch.example/session" + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity([tool], _turn(), 1.0), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity([tool], _turn("completed"), 9.0), THREAD + ) + await adapter.update_rich( + CHANNEL, + "Agent", + ref, + TurnActivity([tool], _turn("completed"), 9.0, session_url=url), + THREAD, + ) + + assert [call["ts"] for call in client.updated] == ["1.0"] + assert adapter._unredrawable == {} + + async def test_a_throttled_append_asks_the_caller_to_wait_and_keeps_the_stream( monkeypatch: Any, ) -> None: From 2dedde345c7ba6320a24ebc6eeded9c4b0415948 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 14:31:28 +0100 Subject: [PATCH 116/120] Preserve only a message whose stream drew the end of its turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard was recording every stream it dropped, including one whose append had just been refused and one evicted to make room. Neither has drawn the end of its turn, so declining the retry froze the message at whatever it last showed β€” "Working…" forever β€” and reported the publication delivered. That loses the completion itself, not merely a revision arriving after it. Only `_close_stream` records now, and it is reached solely when the terminal append landed. A refused append and an eviction go back to dropping the stream and nothing else, so recovery behaviour is as it was. Co-Authored-By: Claude Opus 5 --- .../bridges/collaboration/slack/adapter.py | 30 ++++++----- .../test_session_slack_streaming.py | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 2e5dffd11..c9798d04b 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -260,9 +260,11 @@ def __init__(self, *, config: SlackConnectionConfig) -> None: # Open activity streams by message ref, holding what has already been # appended so a redraw can send only what changed. self._streams: OrderedDict[str, _ActivityStream] = OrderedDict() - # Messages a stream drew in more than one section, which nothing can - # redraw. The value records whether that has already been reported, so - # a turn asked for repeatedly says it once. See `_forget_stream`. + # Messages a finished stream drew in more than one section, which + # nothing can redraw. The value records whether that has already been + # reported, so a turn asked for repeatedly says it once. Bounded and + # in-memory, so a restart or enough later turns lose the protection and + # a publication after that collapses the message. See `_settle_stream`. self._unredrawable: OrderedDict[str, bool] = OrderedDict() # Folded Slack username β†’ user id, for resolving outbound @mentions to # real Slack mentions. Primed from the bridge's known external users and @@ -793,8 +795,7 @@ async def _open_stream( stream = _ActivityStream(channel_id=channel_id, ts=str(ts)) self._streams[ref] = stream while len(self._streams) > _MAX_OPEN_STREAMS: - abandoned = next(iter(self._streams)) - self._forget_stream(abandoned) + abandoned, _ = self._streams.popitem(last=False) logger.warning( "Forgetting the activity stream %s to make room; its turn never " "ended, so the message is left in its streaming state.", @@ -803,8 +804,8 @@ async def _open_stream( await self._extend_stream(stream, ref, content) return ref - def _forget_stream(self, message_ref: str) -> None: - """Drop a stream, and note whether its message can still be redrawn. + def _settle_stream(self, message_ref: str) -> None: + """Drop a finished stream, and note whether its message can be redrawn. A message a stream drew in two sections cannot be redrawn by anything. Measured: `chat.update` refuses a message carrying two plan blocks @@ -815,9 +816,14 @@ def _forget_stream(self, message_ref: str) -> None: Remembering which messages those are is what lets a later publication be declined rather than drawn. The message is already showing the turn - as the stream finally left it, so there is nothing owed to a reader in - the ordinary case β€” only a revision that landed after the turn ended - goes unshown, which is why it is said out loud rather than passed over. + as the stream finally left it, so there is nothing owed to a reader β€” + only a revision that landed after the turn ended goes unshown, which is + why it is said out loud rather than passed over. + + Only a stream whose last append landed comes through here. One that was + refused, or dropped to make room, has not drawn the end of its turn + yet, and a collapsed message showing that end beats a whole one that + stops mid-turn. """ stream = self._streams.pop(message_ref, None) if stream is None: @@ -890,7 +896,7 @@ async def _close_stream( The turn is over and the plan is the record of what it did, so unlike the old progress card there is nothing here to delete. """ - self._forget_stream(message_ref) + self._settle_stream(message_ref) try: await client.chat_stopStream(channel=stream.channel_id, ts=stream.ts) except SlackApiError as error: @@ -915,7 +921,7 @@ def _stream_failed( """ code = error.response.get("error") if code in _STREAM_CLOSED_ERRORS: - self._forget_stream(message_ref) + self._streams.pop(message_ref, None) logger.warning( "The activity stream %s is no longer accepting appends (%s); " "later updates will be drawn as an ordinary message.", diff --git a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py index 787a52db8..c621e6d53 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -900,6 +900,56 @@ async def test_a_turn_of_two_sections_is_left_as_the_stream_drew_it( ] +async def test_a_turn_whose_last_append_was_refused_is_still_drawn_by_an_edit() -> None: + """The guard preserves a finished turn, not an interrupted one. + + If the append carrying the end of the turn is refused, the message is still + showing the turn mid-flight. Declining to edit it would leave it that way + for good and report success for a completion nobody ever saw. A collapsed + message that says the turn ended beats a whole one that says it is running. + """ + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(60)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many, _turn(), 1.0), THREAD + ) + client.append_error = "stopped_by_user" + with pytest.raises(RichContentFailed): + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(many, _turn("completed"), 9.0), THREAD + ) + client.append_error = None + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(many, _turn("completed"), 9.0), THREAD + ) + + assert adapter._unredrawable == {} + assert [call["ts"] for call in client.updated] == ["1.0"] + + +async def test_a_stream_dropped_to_make_room_leaves_its_message_editable() -> None: + """Same eligibility: its turn never ended, so it never drew the end.""" + client = FakeWebClient() + adapter = _adapter(client) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(60)] + + first = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many, _turn(), 1.0), THREAD + ) + for index in range(_MAX_OPEN_STREAMS): + await adapter.post_rich( + CHANNEL, + "Agent", + TurnActivity([_tool(f"x{index}", "Read")], _turn()), + THREAD, + ) + + assert first not in adapter._streams + assert adapter._unredrawable == {} + + async def test_a_message_that_says_it_cannot_be_redrawn_says_it_once( caplog: Any, ) -> None: From 90df9741fe12e03116f3bd99584a7824428e2873 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 14:14:16 +0100 Subject: [PATCH 117/120] style(console): format files left unformatted by the terminal removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PTY and terminal-plugin removals left six files that oxfmt rewrites, so the Console job's format check fails and the steps after it β€” lint already having passed, then typecheck and vitest β€” never run. Formatting only; no behaviour change. Locally the whole console workspace is now green on format:check, typecheck and vitest (browser project not run here). Co-Authored-By: Claude Opus 5 --- .../core/agent-hooks/claude-trust-service.test.ts | 2 -- .../main/core/agent-hooks/claude-trust-service.ts | 1 - .../src/main/core/agents/known-agent-type.test.ts | 1 - .../core/dependencies/ssh-install-runner.test.ts | 15 ++++++++++++--- .../src/shared/core/providers/hook-session-id.ts | 4 +++- .../plugins/src/agents/impl/index.test.ts | 1 - 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/console/apps/switch-console-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts b/console/apps/switch-console-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts index 9af829549..57b12ddec 100644 --- a/console/apps/switch-console-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts @@ -210,8 +210,6 @@ describe('ClaudeTrustService', () => { expect(JSON.parse(String(claudeJson?.[1]))).not.toHaveProperty('hasCompletedOnboarding'); }); - - it('is idempotent when already trusted', async () => { const service = makeService(); const trustedPath = '/already/trusted'; diff --git a/console/apps/switch-console-desktop/src/main/core/agent-hooks/claude-trust-service.ts b/console/apps/switch-console-desktop/src/main/core/agent-hooks/claude-trust-service.ts index 7b67efe02..5daac4e02 100644 --- a/console/apps/switch-console-desktop/src/main/core/agent-hooks/claude-trust-service.ts +++ b/console/apps/switch-console-desktop/src/main/core/agent-hooks/claude-trust-service.ts @@ -200,4 +200,3 @@ function withClaudeTrustedProject( }, }; } - diff --git a/console/apps/switch-console-desktop/src/main/core/agents/known-agent-type.test.ts b/console/apps/switch-console-desktop/src/main/core/agents/known-agent-type.test.ts index fefbdc049..c6f6e321b 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/known-agent-type.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/known-agent-type.test.ts @@ -28,7 +28,6 @@ describe('knownAgentTypeForProvider', () => { expect(knownAgentTypeForProvider('opencode')).toBe('opencode'); expect(log.warn).not.toHaveBeenCalled(); }); - }); it('registers Antigravity under its own gateway type', () => { diff --git a/console/apps/switch-console-desktop/src/main/core/dependencies/ssh-install-runner.test.ts b/console/apps/switch-console-desktop/src/main/core/dependencies/ssh-install-runner.test.ts index 722a7d96a..b1e6afbeb 100644 --- a/console/apps/switch-console-desktop/src/main/core/dependencies/ssh-install-runner.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/dependencies/ssh-install-runner.test.ts @@ -11,7 +11,10 @@ vi.mock('@main/core/ssh/lifecycle/remote-shell-profile', () => ({ describe('SSH installer', () => { it('streams stdout and stderr through a plain exec channel and reports failure', async () => { const channel = Object.assign(new EventEmitter(), { - stderr: new EventEmitter(), end: vi.fn(), close: vi.fn(), signal: vi.fn(), + stderr: new EventEmitter(), + end: vi.fn(), + close: vi.fn(), + signal: vi.fn(), }); const exec = vi.fn((_command, callback) => { callback(null, channel); @@ -23,10 +26,16 @@ describe('SSH installer', () => { }); const proxy = { getRemoteShellProfile: async () => ({}), exec } as unknown as SshClientProxy; const output = vi.fn(); - const result = await createSshInstallCommandRunner(proxy, output)({ command: 'tool', args: ['a; b'] }); + const result = await createSshInstallCommandRunner( + proxy, + output + )({ command: 'tool', args: ['a; b'] }); expect(exec.mock.calls[0][0]).toBe("'tool' 'a; b'"); expect(output).toHaveBeenCalledWith('permission denied'); expect(channel.end).toHaveBeenCalled(); - expect(result).toMatchObject({ success: false, error: { type: 'permission-denied', exitCode: 13 } }); + expect(result).toMatchObject({ + success: false, + error: { type: 'permission-denied', exitCode: 13 }, + }); }); }); diff --git a/console/apps/switch-console-desktop/src/shared/core/providers/hook-session-id.ts b/console/apps/switch-console-desktop/src/shared/core/providers/hook-session-id.ts index c3bbbb7a3..fd21977cc 100644 --- a/console/apps/switch-console-desktop/src/shared/core/providers/hook-session-id.ts +++ b/console/apps/switch-console-desktop/src/shared/core/providers/hook-session-id.ts @@ -4,7 +4,9 @@ export function makeHookSessionId(provider: AgentProviderId, sessionId: string): return `${provider}-session-${sessionId}`; } -export function parseHookSessionId(id: string): { providerId: AgentProviderId; sessionId: string } | null { +export function parseHookSessionId( + id: string +): { providerId: AgentProviderId; sessionId: string } | null { for (const providerId of AGENT_PROVIDER_IDS) { const prefix = `${providerId}-session-`; if (id.startsWith(prefix)) return { providerId, sessionId: id.slice(prefix.length) }; diff --git a/console/packages/plugins/src/agents/impl/index.test.ts b/console/packages/plugins/src/agents/impl/index.test.ts index 8949a3da3..cc1db6e02 100644 --- a/console/packages/plugins/src/agents/impl/index.test.ts +++ b/console/packages/plugins/src/agents/impl/index.test.ts @@ -150,5 +150,4 @@ describe('pluginRegistry', () => { expect(typeof p.behavior.prompt?.buildCommand).toBe('function'); } }); - }); From df2db235ec412ecc1e34821eea52c0da449e4809 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 16:56:47 +0100 Subject: [PATCH 118/120] Join the two migration heads this merge produced The card-removal column here and the activity-reaction index below are both heads after the merge, so alembic upgrade head is ambiguous and refuses to run. Empty merge revision, as the six before it. Co-Authored-By: Claude Opus 5 --- ...rge_card_removal_and_activity_reaction_heads.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 core/switch_core/migrations/versions/e6b8d40f2a17_merge_card_removal_and_activity_reaction_heads.py diff --git a/core/switch_core/migrations/versions/e6b8d40f2a17_merge_card_removal_and_activity_reaction_heads.py b/core/switch_core/migrations/versions/e6b8d40f2a17_merge_card_removal_and_activity_reaction_heads.py new file mode 100644 index 000000000..26b793239 --- /dev/null +++ b/core/switch_core/migrations/versions/e6b8d40f2a17_merge_card_removal_and_activity_reaction_heads.py @@ -0,0 +1,14 @@ +"""Merge card-removal and activity-reaction migration heads.""" + +revision = "e6b8d40f2a17" +down_revision = ("c1e4b73a0d58", "9c3d1e7a5b84") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass From 7955f785da0faf5eebcd83241f4619ee25d8b2f2 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 15:32:39 +0100 Subject: [PATCH 119/120] docs(bridges): correct the Helm account of the callback port The chart publishes the port now, behind `switchCore.collaborationCallback.enabled`, and prints the address to put on the bridge. The page still told operators to add the Service port and route by hand. Co-Authored-By: Claude Opus 5 --- docs/old/bridges/MATTERMOST_SETUP.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/old/bridges/MATTERMOST_SETUP.md b/docs/old/bridges/MATTERMOST_SETUP.md index be722f3df..8ca2c7399 100644 --- a/docs/old/bridges/MATTERMOST_SETUP.md +++ b/docs/old/bridges/MATTERMOST_SETUP.md @@ -154,9 +154,13 @@ reads the post back and merges rather than overwriting what the Mattermost server itself put there. It is one extra API call, made only for request cards on bridges that take callbacks. -**Kubernetes.** The Helm chart does not publish the callback port yet, so a -chart deployment needs the Service port and route added by hand for now; the -`switchCore.teamsBridge` block in `values.yaml` is the shape it will take. +**Kubernetes.** The chart publishes the callback port when +`switchCore.collaborationCallback.enabled` is set, which it is not by default: +the Service gains the port, switch-core declares it, and the Mattermost this +chart deploys is given the address to allow. `helm install` then prints the +`callback_base_url` to put on the bridge β€” the cluster-internal Service name, +not an address a browser follows. No Ingress is rendered for it, so a Mattermost +outside the cluster needs a route you provide yourself. ## Local development From 11caf7a210d9bea1575ebd7c6250797b5813d296 Mon Sep 17 00:00:00 2001 From: Simon Flack Date: Thu, 17 Sep 2026 15:43:10 +0100 Subject: [PATCH 120/120] docs(bridges): say what a blocked Mattermost press actually looks like The page called it a silent failure. Mattermost displays "Action failed to execute" under the card from 10.5 on, and scopes the untrusted-connections allowlist to private addresses rather than requiring it of every host. Co-Authored-By: Claude Opus 5 --- docs/old/bridges/MATTERMOST_SETUP.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/old/bridges/MATTERMOST_SETUP.md b/docs/old/bridges/MATTERMOST_SETUP.md index 8ca2c7399..652b634a4 100644 --- a/docs/old/bridges/MATTERMOST_SETUP.md +++ b/docs/old/bridges/MATTERMOST_SETUP.md @@ -108,9 +108,12 @@ waiting for the next deploy. integration requests to private addresses unless the host is listed in System Console β†’ Environment β†’ Developer β†’ *Allow untrusted internal connections to* (`ServiceSettings.AllowedUntrustedInternalConnections`, space-separated hosts). -Add the host from `callback_base_url`. Switch's own compose stacks set it -already; a server you bring yourself does not, and the symptom is a press that -silently does nothing with an error only in the Mattermost server log. +Add the host from `callback_base_url` when that address is a private one; a +publicly routable address is not gated by this setting. Switch's own compose +stacks set it already, and a server you bring yourself does not. The symptom is +a press that fails: Mattermost 10.5 and later put "Action failed to execute" +under the card, and the reason β€” `err=address forbidden` for this one β€” is in +the Mattermost server log and nowhere else. **TLS** is a proxy's job, as it is for Teams: the listener speaks plain HTTP. If the hop between the two servers leaves a network you trust, terminate TLS in