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/console/packages/agent-providers/src/claude/claude-adapter.test.ts b/console/packages/agent-providers/src/claude/claude-adapter.test.ts index 08458f106..558bf4a94 100644 --- a/console/packages/agent-providers/src/claude/claude-adapter.test.ts +++ b/console/packages/agent-providers/src/claude/claude-adapter.test.ts @@ -569,6 +569,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', @@ -581,6 +582,98 @@ 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('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' }); + 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 643bb8325..9db92516c 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 254332eaa..48e5bbc9a 100644 --- a/console/packages/agent-providers/src/claude/claude-mapping.ts +++ b/console/packages/agent-providers/src/claude/claude-mapping.ts @@ -75,6 +75,43 @@ 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. + * + * 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, + 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 = 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 { + 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 b7ca3aa9f..6d0ab26c7 100644 --- a/console/packages/agent-providers/src/codex/codex-adapter.test.ts +++ b/console/packages/agent-providers/src/codex/codex-adapter.test.ts @@ -386,6 +386,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 9a03bbca5..4d3844c08 100644 --- a/console/packages/agent-providers/src/codex/codex-adapter.ts +++ b/console/packages/agent-providers/src/codex/codex-adapter.ts @@ -168,6 +168,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'] @@ -674,8 +690,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 988a7f3cb..5b21d9612 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[]; } diff --git a/core/switch_core/bridges/agent/protocol/service.py b/core/switch_core/bridges/agent/protocol/service.py index 858792ed3..b8a4752ea 100644 --- a/core/switch_core/bridges/agent/protocol/service.py +++ b/core/switch_core/bridges/agent/protocol/service.py @@ -1501,24 +1501,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 4b2e9eaed..3ba8ee7ea 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -1,15 +1,15 @@ 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 +from typing import ClassVar, Literal 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, @@ -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, @@ -37,27 +41,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 that - retires a status line by editing it rather than deleting it 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. @@ -89,27 +72,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. @@ -128,16 +90,39 @@ 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 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 +@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` / @@ -163,6 +148,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 @@ -191,6 +180,26 @@ 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, 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. + """ + + class RichContentThrottled(RichContentFailed): """The platform asked us to wait before attempting another update.""" @@ -199,13 +208,100 @@ 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. + + 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. + """ + + +#: 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 - # 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. 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. + #: + #: True where nobody follows a thread they are not already in, so a post + #: 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 + #: 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 - renders_legacy_runtime_state: ClassVar[bool] = True + + #: 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 + #: 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 #: Whether this platform can create a channel from Switch at all. #: @@ -239,21 +335,57 @@ 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. + #: Whether `find_request_card` can actually search this platform. #: - #: 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. + #: 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 + + #: Whether a publication carries a marker `find_request_card` can match on + #: regardless of what the message says. #: - #: 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 + #: 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 + + #: 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 @@ -269,6 +401,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 @@ -285,15 +425,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 @@ -386,6 +517,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 @@ -404,14 +536,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" @@ -561,16 +705,41 @@ 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, + 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 *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 + 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: @@ -582,17 +751,42 @@ 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 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,8 +811,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. @@ -648,272 +854,111 @@ 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 @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. + 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_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. + """ + 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 ) -> None: ... async def mark_activity( - self, channel_id: str, message_ref: str, *, working: bool, force: bool = False - ) -> None: - """Update a platform work indicator when the adapter supports one.""" - - 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, + message_ref: 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, + mark: ActivityMark, + on: bool, + force: bool = False, ) -> 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. + """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. + + `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. """ - 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( + async def notify_working( 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. + """Signal once, where the work was asked for, that the agent has begun. - 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. + 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 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. + `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. - 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. + 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. """ - 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 + def unnotified_notice(self) -> str: + """Why an attention post named nobody, for a platform that says so. - 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) + 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." ) - 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) - @abstractmethod async def create_channel( self, @@ -1110,6 +1155,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: @@ -1132,6 +1191,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: @@ -1264,10 +1335,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 9cf81a2a8..999831216 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, ) @@ -55,7 +56,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 @@ -116,11 +116,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 " @@ -225,14 +220,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 @@ -250,6 +237,7 @@ def __init__( SessionRequestCards( adapter, bridge_id=bridge_id, + surface=bridge_type, posts=session_request_post_store, session_factory=session_factory, ) @@ -300,6 +288,7 @@ def _build_session_demo( SessionRequestCards( self._adapter, bridge_id=self._bridge_id, + surface=self._bridge_type, posts=posts, session_factory=self._session_factory, ), @@ -385,6 +374,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), @@ -1361,16 +1352,41 @@ 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 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 _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. @@ -1391,7 +1407,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( @@ -1456,8 +1472,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: @@ -1465,10 +1496,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) @@ -1824,11 +1857,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: @@ -1986,8 +2014,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, @@ -2070,8 +2096,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: @@ -2102,125 +2126,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 @@ -2248,63 +2153,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 ee022fbcd..3f1446b89 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -5,10 +5,11 @@ import io import logging import re -import time 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 import discord @@ -18,10 +19,22 @@ 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, + ActivitySnapshot, CollaborationAdapter, - LiveRuntimeIndicator, + RemovalFailed, + RequestCard, + RichContent, + RichContentFailed, + RichContentThrottled, + ThreadUnavailable, + 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, @@ -36,9 +49,26 @@ 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 ( + ACTIVITY_AUDIENCE_UNKNOWN, + ACTIVITY_FAILED, + ACTIVITY_GONE, + ACTIVITY_NOT_A_MEMBER, + ACTIVITY_UNREADABLE, + activity_log, + render_request, + turn_status, +) logger = logging.getLogger(__name__) @@ -46,14 +76,32 @@ # 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. -_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 +# "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 + +# "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 @@ -67,6 +115,185 @@ # 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) + +# 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 +) + + +# 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" + + +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. + + 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. +# 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 +) -> 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. + + 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): + return RichContentFailed(f"{description}: {error}", text=text) + return None + class _WebhookIdentity: """Keep one accepted identity across chunks and attachment retries.""" @@ -182,6 +409,48 @@ 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 + + # 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 + 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. + activity_reactions_per_agent: 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 + + # 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 + + #: 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 @@ -190,23 +459,22 @@ 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() + # 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. 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. - self._eyes: set[str] = set() - self._agent_eyes: dict[tuple[str, str], set[str]] = {} + # 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() # 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 @@ -247,6 +515,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): @@ -332,6 +605,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: @@ -349,6 +633,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: @@ -596,6 +881,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 @@ -622,7 +908,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}", ) @@ -703,153 +989,1039 @@ async def send_typing( "Failed to trigger typing in Discord channel %s", channel_id ) - # ── Runtime state ──────────────────────────────────────────────────────── + # ── 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. + + 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 + ).text + + def _draw( + self, + content: RichContent, + *, + mention: str | None, + responder: str | None, + prefix: str, + controls: bool, + ) -> Drawn: + 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 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)), + markup=markup, + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + mention=mention, + error_summary=content.error_summary, + tool_detail=True, + ) + + tail + ) + 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 "" + 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=_MAX_BUTTON_LABEL if controls else None, + ) + return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") + + 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 + ) + 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, controls=controls) + + 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], + *, + controls: bool, + ) -> 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 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) + 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=self.rich_fallback_text(content), + ) + view.add_item( + discord.ui.Button( + label=_button_label(control), + custom_id=custom_id, + style=discord.ButtonStyle.secondary, + ) + ) + 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. - async def _apply_runtime_state( + 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, - state: str, - *, - mention_handle: str | None, + 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_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: + 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" + prefix = f"**{await self.agent_label_for_body(agent_name)}**: " if lobby else "" + 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( + error, f"Discord refused the post in DM {channel_id}", fallback + ) 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( + int(channel_id), thread_root_id, fallback + ) + + try: + 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 + if view is not None: + payload["view"] = view + 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}", fallback + ) from error + return f"{sent.channel.id}:{sent.id}" + + async def _publication_thread( + self, channel_id: int, thread_root_id: str, fallback: str + ) -> Any: + """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, fallback) + 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 reporting that there is none. + 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=fallback, + ) from error + + async def _reachable_thread( + self, channel_id: int, thread_root_id: str, fallback: 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=fallback, + ) from error + + async def update_rich( + self, + channel_id: str, + agent_name: str, + message_ref: str, + content: RichContent, 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. + """Redraw a publication in place, including the last time. + + 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 + 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. """ - # 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, + _, 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 ref is not None: - self._input_pings.setdefault(key, []).append(ref) + 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" + prefix = f"**{await self.agent_label_for_body(agent_name)}**: " if lobby else "" + if lobby: + controls = True else: - await self._clear_working(channel_id, agent_name) - await self._clear_input_pings(channel_id, agent_name) + 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, 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, + fallback=self.rich_fallback_text(content), + ) - async def _track_turn( + async def _edit_rich( self, channel_id: str, - anchor_message_ref: str | None, - agent_name: str, + message_ref: str, + text: str, + view: discord.ui.View | None, *, - state: str, + lobby: bool, + fallback: str, ) -> None: - """Mark every message this agent is working on, and unmark them together. + """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. - 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. + `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. """ - akey = (channel_id, agent_name) - if state in ("working", "awaiting-input"): - if anchor_message_ref is 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, view=view, allowed_mentions=_NO_MASS_MENTIONS + ) return - self._agent_eyes.setdefault(akey, set()).add(anchor_message_ref) - await self._mark_being_read(anchor_message_ref, working=True) + kwargs: dict[str, Any] = {} + if location_id and location_id != channel_id: + kwargs["thread"] = discord.Object(id=int(location_id)) + webhook = await self._publication_webhook(int(channel_id)) + await webhook.edit_message( + int(message_id), + content=text, + view=view, + 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}", + fallback, + ) 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 + + @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: + """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.") + + 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)) + 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 - for ref in sorted(self._agent_eyes.pop(akey, set())): - await self._mark_being_read(ref, working=False) + try: + # The publication webhook, not the agents' one: a webhook may + # 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 - 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. + 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: + 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. - 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. + 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. """ - location_id, 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): + 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. + + 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) + await location.fetch_message(message_id) + except discord.NotFound: + self._say_already_gone(message_ref, error) return + except Exception as failure: + 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", + ) 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, + 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. + + 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, and D14. + """ + 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 `{self._rich_escape(handle)}`" + for place in await self._recovery_places(channel_id, thread_root_id): + 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 not self._is_publication(message, author): + continue + if self._heads_a_card(message.content or "", wanted): + 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 + + @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]: + """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 _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 + posted to this channel yet, so the cache is empty and every message in + it would look like a stranger's. + """ + parent = getattr(place, "parent", None) or place + if self._channel_type_of(parent) == "lobby": + return self._bot_user_id or None 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) - else: - await message.remove_reaction(_WORKING_REACTION, self._client.user) - self._eyes.discard(message_ref) - except discord.NotFound: - # The message (or the reaction) is gone; the end state is what was - # wanted either way. - self._eyes.discard(message_ref) - except discord.Forbidden: + return (await self._publication_webhook(parent.id)).id + except Exception as e: + logger.warning( + "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 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 + ) -> 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( - "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.", + "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, ) - except (discord.HTTPException, ValueError) as e: + return False + + async def mark_activity( + self, + channel_id: str, + message_ref: str, + *, + agent_name: str, + mark: ActivityMark, + on: bool, + force: bool = False, + ) -> None: + """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 + 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( - "Could not %s the working reaction on Discord message %s: %s", - "add" if working else "remove", + "Cannot mark %s: not a Discord message reference.", message_ref, + ) + return + if not force and on == ((message_ref, mark) in self._marked): + return + 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 + ) -> 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, ) - 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) + @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 - 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) + 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 + raise, so a caller that can try again knows it should. + + 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() + key = (message_ref, mark) + try: + channel = await self._get_channel(int(location_id)) + message = channel.get_partial_message(int(message_id)) + if on: + await message.add_reaction(_REACTION[mark]) + self._marked.add(key) + else: + 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._marked.discard(key) + except discord.Forbidden as error: + if on: + raise ActivityMarkRefused( + 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 {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}." + ) from error # ── Channels ───────────────────────────────────────────────────────────── @@ -1200,10 +2372,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 @@ -1356,6 +2528,467 @@ 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 {}) + 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 + 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 _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. + + 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 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.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 (%s), so the activity " + "behind message %s is not shown.", + location_id, + error, + ref, + ) + await self._privately(interaction, ACTIVITY_FAILED, ref) + return + 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) + 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, + heading=True, + ) + 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) -> 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 + 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. + + 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: + return self._is_recipient(channel, user) + 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 ACTIVITY_AUDIENCE_UNKNOWN + member = guild.get_member(user.id) + if member is None: + try: + member = await guild.fetch_member(user.id) + 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), " + "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 ACTIVITY_UNREADABLE + is_private = getattr(channel, "is_private", None) + if is_private is None or not is_private(): + return None + if allowed.manage_threads: + return None + try: + await channel.fetch_member(user.id) + 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 " + "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) -> 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 + 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 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 + ) -> 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( @@ -1575,23 +3208,92 @@ 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) + # 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] = 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/switch_core/bridges/collaboration/ingress.py b/core/switch_core/bridges/collaboration/ingress.py new file mode 100644 index 000000000..9f6a40eaa --- /dev/null +++ b/core/switch_core/bridges/collaboration/ingress.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import asyncio +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 + # 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( + 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. + + 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. + + 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: + 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"] + 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 ff963f475..41efab142 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 @@ -24,6 +25,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 @@ -93,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 @@ -482,18 +496,16 @@ 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 - ): + 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 + ) + 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: @@ -634,8 +646,20 @@ 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 + # 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() @@ -661,6 +685,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 21a0503ef..1a029d70a 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -10,19 +10,46 @@ import uuid from collections import OrderedDict from collections.abc import Awaitable, Callable -from dataclasses import replace +from contextvars import ContextVar +from dataclasses import dataclass, replace +from datetime import UTC, datetime from typing import Any, ClassVar 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 ( + ActivityMark, + ActivitySnapshot, CollaborationAdapter, - LiveRuntimeIndicator, - format_elapsed, + RemovalFailed, + RequestCard, + RichContent, + RichContentFailed, + RichContentThrottled, + TurnActivity, +) +from switch_core.bridges.collaboration.ingress import ( + CallbackEndpoint, + CallbackRefused, +) +from switch_core.bridges.collaboration.mattermost.callback import ( + MAX_BUTTON_LABEL, + ActivityPress, + activity_action, + answer_actions, + read_press, ) from switch_core.bridges.collaboration.models import ( Attachment, @@ -33,10 +60,25 @@ InboundAgentJoin, InboundAppJoin, InboundCommand, + InboundInteraction, InboundMessage, InboundUserJoin, OutboundAttachment, ) +from switch_core.bridges.collaboration.session.renderers import ( + Drawn, + offered_controls, + position_action, +) +from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_AUDIENCE_UNKNOWN, + ACTIVITY_FAILED, + ACTIVITY_GONE, + ACTIVITY_NOT_A_MEMBER, + activity_log, + render_request, + turn_status, +) logger = logging.getLogger(__name__) @@ -53,13 +95,117 @@ _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 +# 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" + +# 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. +_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. _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 + + +@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 @@ -81,13 +227,84 @@ 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 +) + + +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): - #: 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. - runtime_state_follows_anchor: ClassVar[bool] = True + publishes_sdk_sessions: ClassVar[bool] = True + + #: 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 + + #: 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 + 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 + #: on its own. + activity_reactions_per_agent: 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 + + #: 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 + + #: 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__() @@ -112,24 +329,16 @@ 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._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 - # 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. + self._usernames: OrderedDict[str, str] = OrderedDict() + self._usernames_max = 1000 self._main_loop: asyncio.AbstractEventLoop | None = None @@ -144,6 +353,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( @@ -181,6 +398,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, @@ -192,6 +411,279 @@ 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. + + 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 " + "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(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, 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: + 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) + 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) -> 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. 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. 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 + 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 ACTIVITY_AUDIENCE_UNKNOWN + try: + member = await loop.run_in_executor( + None, driver.channels.get_channel_member, channel_id, user_id + ) + 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 " + "(%s), so no activity is shown.", + user_id, + channel_id, + error, + ) + 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. + + 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, + 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( @@ -216,6 +708,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. @@ -228,7 +721,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") @@ -419,22 +912,45 @@ async def _create_post( channel_id: str, content: str, thread_root_id: str | None, + props: dict[str, Any] | 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, Any] | 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 +968,507 @@ 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. + + 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, controls=False).text + + def _draw( + self, + content: RichContent, + *, + mention: str | None, + responder: str | None, + controls: bool, + ) -> Drawn: + 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 + # Mattermost refuses. + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable else "" + return Drawn( + text=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, + tool_detail=True, + ) + + 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**" + # reads as part of the heading. It is charged to the same budget, or a + # 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 "" + 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=MAX_BUTTON_LABEL if controls else None, + ) + 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. + + 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 + if url is None or endpoint is None or self._on_interaction is None: + return None + return url, endpoint.key + + 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 [] + return answer_actions(key, url, content.reference.token, controls) + + 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) + if isinstance(content, RequestCard) + else None + ) + controls = ( + isinstance(content, RequestCard) and self._button_address() is not None + ) + drawn = self._draw( + content, mention=mention, responder=responder, controls=controls + ) + 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 + # 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, + 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. + 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. + """ + rendered = await self._render_rich(channel_id, 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=rendered.plain, + ) + token = ( + content.publication_token + if isinstance(content, TurnActivity) + else content.reference.token + ) + props: dict[str, Any] = {} + if token: + props[_PUBLICATION_PROP] = token + if rendered.actions: + props[_ATTACHMENTS_PROP] = [{"actions": rendered.actions}] + try: + ref = await self._post_or_raise( + 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=rendered.plain, + ) + if failure is None: + raise + raise failure from error + 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, 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 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 + refusal buys a fallback reply about a card that is already correct. + + 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 + # anything new for the person it names. + 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: + raise RichContentFailed( + "Mattermost is not connected, so the post could not be updated.", + text=rendered.plain, + ) + try: + patch: dict[str, Any] = {"message": rendered.text} + if isinstance(content, RequestCard): + patch["props"] = await self._props_with_actions( + driver, loop, message_ref, rendered.actions + ) + await loop.run_in_executor( + None, driver.posts.patch_post, message_ref, patch + ) + except Exception as error: + failure = _as_rich_failure( + error, + description=( + f"Mattermost refused the edit to post {message_ref} in " + f"channel {channel_id}" + ), + text=rendered.plain, + ) + if failure is None: + 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, + 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. + + 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. `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 + 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, + mark: ActivityMark, + on: bool, + force: bool = False, + ) -> None: + """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 + 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._react_or_raise( + agent_name, message_ref, mark=mark, on=on, 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) + + 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 = 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 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: logger.error("Cannot delete message: Mattermost client not connected") @@ -464,6 +1481,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( @@ -510,151 +1585,16 @@ 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( + async def _react_or_raise( 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 + post_id: str, + *, + mark: ActivityMark, + on: bool, + force: bool, ) -> None: """Put 👀 on the post an agent is working on, and take it off after. @@ -663,102 +1603,54 @@ 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._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 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) 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"] - 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", - post_id, - e, + if on: + await loop.run_in_executor( + None, + driver.reactions.create_reaction, + { + "user_id": user_id, + "post_id": post_id, + "emoji_name": _REACTION[mark], + }, ) - if not working: - self._eyes.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") + self._marked.add(key) 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 + None, + driver.reactions.delete_reaction, + user_id, + post_id, + _REACTION[mark], ) + except ResourceNotFound: + pass + self._marked.discard(key) # ── Channel creation ────────────────────────────────────────────────────── @@ -1416,7 +2308,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/switch_core/bridges/collaboration/mattermost/callback.py b/core/switch_core/bridges/collaboration/mattermost/callback.py new file mode 100644 index 000000000..66071dbf9 --- /dev/null +++ b/core/switch_core/bridges/collaboration/mattermost/callback.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +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 +# 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 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 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 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 +# 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: + """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 + + +@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. + + 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, _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]]: + """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 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}" + + +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 + 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. + + 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, 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): + return 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 + # 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, _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 " + "bridge's credential, or the credential has been rotated since the " + "card was posted.", + token, + ) + 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") + 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 user_id, post_id, channel_id + + +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/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/session/activity_journal.py b/core/switch_core/bridges/collaboration/session/activity_journal.py index b5161a6f8..13790d4fe 100644 --- a/core/switch_core/bridges/collaboration/session/activity_journal.py +++ b/core/switch_core/bridges/collaboration/session/activity_journal.py @@ -14,13 +14,86 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass +from typing import Any, Final -from sqlalchemy import select, text +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 +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"], + ) + ) + + +#: 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] @@ -28,20 +101,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: @@ -61,22 +224,79 @@ 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], channel: str, 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. + + `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. + + `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": {"channel_id": channel, "reaction_ref": ref}} - ), + *criteria, ) ) return any( @@ -84,6 +304,124 @@ async def reaction_held( for row in rows ) + async def mark_expected( + self, + mark: dict[str, str], + *, + sessions: async_sessionmaker[AsyncSession], + ) -> bool: + """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". + + 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. + """ + 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, + _claiming(mark), + ) + ) + ).first() + ) + + async def mark_holders( + self, + mark: dict[str, str], + *, + sessions: async_sessionmaker[AsyncSession], + ) -> 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( + select(SessionActivityPost).where( + SessionActivityPost.tenant_id == require_tenant_id(), + SessionActivityPost.bridge_id == self.bridge_id, + _claiming(mark), + ) + ) + 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, str]], + sessions: async_sessionmaker[AsyncSession], + ) -> None: + """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 + 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. + + `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 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. + + 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. + + 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 = _without_claim(SessionActivityPost.data) + async with sessions() as db: + for session_id, command_id, attempt in sorted(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, + _claiming(mark), + held == attempt, + ) + .values(data=forgotten) + ) + await db.commit() + @asynccontextmanager async def open( self, session_id: str, command_id: str diff --git a/core/switch_core/bridges/collaboration/session/demo.py b/core/switch_core/bridges/collaboration/session/demo.py index 848e16aa6..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, @@ -235,7 +236,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/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/outbound.py b/core/switch_core/bridges/collaboration/session/outbound.py index b09601574..e2472814c 100644 --- a/core/switch_core/bridges/collaboration/session/outbound.py +++ b/core/switch_core/bridges/collaboration/session/outbound.py @@ -45,17 +45,20 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from switch_core.bridges.collaboration.adapter import ( + ActivityMark, + ActivityMarkRefused, CollaborationAdapter, RequestCard, RichContentFailed, RichContentThrottled, + ThreadUnavailable, TurnActivity, ) from switch_core.db.models import Client, ExternalUser, SessionRequestPost 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 @@ -99,12 +102,30 @@ 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 +@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. @@ -120,6 +141,26 @@ def _violates(error: IntegrityError, constraint: str) -> bool: return f'"{constraint}"' in str(error.orig) +#: 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"], + mark["mark"], + ) + + class CardNotPosted(RuntimeError): """A request that has no card, so nobody was asked and nobody can answer. @@ -136,11 +177,57 @@ 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. + + 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. + """ + + +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, 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: + super().__init__(message) + self.slot = slot + self.abandoned_at = abandoned_at + + 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. @@ -154,15 +241,108 @@ def __init__( "activity_record", default=None ) self._adapter = adapter - self._separate_activity_log = getattr(adapter, "separate_activity_log", False) + self._abandoned: OrderedDict[tuple[str, ...], None] = OrderedDict() + self._separate_attention_slot = getattr( + adapter, "separate_attention_slot", False + ) + 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], set[tuple[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 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. + + 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 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. + + 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 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. + + 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 ''}" + if self._timer_redraws + else "" + ) + drawn = f"{clock}/" + ",".join( + 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) + async def recorded_commands(self, session_id: str) -> set[str]: return ( await self._journal.recorded_commands(session_id) @@ -183,21 +363,13 @@ 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: - 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, - ) - if self._separate_activity_log: + async def attend() -> None: + if not self._separate_attention_slot: + return + try: await self._refresh_attention( session_id, channel_id, @@ -205,8 +377,49 @@ async def draw() -> bool: thread_root_id, turn, notify_external_id, + 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: + 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 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. + # 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: @@ -232,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], @@ -246,13 +459,30 @@ 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:"): # Keep a small completion receipt to suppress replay, but # discard delivery reservations and reaction/log anchors. - record.data = {"turn_id": turn.turn_id, "ended": True} + # 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 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. `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", "shown") + if field in record.data + }, + } record.data["completed"] = True await record.save() return drawn @@ -267,6 +497,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.""" @@ -291,6 +522,7 @@ async def _refresh_attention( [], turn, status_only=True, + notify_unreachable=notify_unreachable, error_summary=error_summary, ) if ref is None: @@ -304,9 +536,13 @@ 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, thread_root_id + ) else: - await self._adapter.update_rich(channel_id, 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() @@ -316,10 +552,51 @@ 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, 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: + 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: 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( @@ -330,6 +607,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) @@ -342,11 +629,47 @@ async def _post_activity( "Activity journal message reference must be a string." ) return saved_ref + 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() + 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 " + 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) ref = await self._adapter.find_request_card( delivery["channel"], delivery["thread"], delivery["token"], datetime.fromisoformat(delivery["created_at"]), + None, ) if ref is None: raise CardNotPosted( @@ -415,13 +738,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` 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) @@ -444,7 +771,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, @@ -460,7 +787,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, @@ -470,15 +797,15 @@ async def _publish( elapsed_seconds=elapsed_seconds, ) - 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: 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) @@ -510,7 +837,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, @@ -519,7 +863,6 @@ async def _begin( items, turn, elapsed_seconds, - status_only=self._separate_activity_log, session_url=session_url, ), thread_root_id, @@ -545,15 +888,9 @@ 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),), ) async def _edit( @@ -567,26 +904,25 @@ 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( anchor.channel_id, + anchor.agent_name, anchor.message_ref, TurnActivity( items, turn, elapsed_seconds, - status_only=self._separate_activity_log, session_url=anchor.session_url, ), + anchor.thread_root_id, ) except RichContentThrottled: raise @@ -604,63 +940,139 @@ 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 - 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), - ) - if state == anchor.log_state and anchor.log_ref: + 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. + + 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. + + 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 - 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.log_ref, content - ) - 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 + 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 claims(record.data.get("mark"), wanted) - async def _claim_thread(self, key: tuple[str, str], anchor: _Anchor) -> None: - """Add this turn to the set of turns holding the reaction on - `anchor.reaction_ref`, switching it on only if this turn is the - first to want it. + 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`, 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 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. + + 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 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 + expectation and none is left waiting to remove a mark nobody could put + there. """ if anchor.reaction_ref is None: return - thread_key = (anchor.channel_id, anchor.reaction_ref) - turns = self._thread_turns.setdefault(thread_key, set()) - first = not turns - if not first or await self._mark_thread(anchor, working=True): - turns.add(key) + turns = self._thread_turns.setdefault(self._thread_key(anchor, mark), set()) + if key in turns: + return + 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: + """Take this turn off every mark it could be holding. - async def _release_thread(self, key: tuple[str, str], anchor: _Anchor) -> bool: + 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 @@ -676,7 +1088,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, mark) turns = self._thread_turns.get(thread_key) if turns is not None: turns.discard(key) @@ -684,45 +1096,288 @@ 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(anchor, working=False) + return await self._mark_thread(key, anchor, mark=mark, on=False) + + 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 + 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, mark) - async def _mark_thread(self, anchor: _Anchor, *, working: bool) -> bool: - """Put `:eyes:` on the message that actually asked, or take it off. + async def _mark_thread( + self, key: tuple[str, str], anchor: _Anchor, *, mark: ActivityMark, on: bool + ) -> bool: + """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 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`. + 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 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 + held = self._mark_key(anchor, mark) + removing: set[tuple[str, str, str]] = set() + attempt: _MarkAttempt | None = None + if on: + attempt = await self._expect_mark(key, held) + else: + removing = await self._claimants(held) try: await self._adapter.mark_activity( anchor.channel_id, anchor.reaction_ref, - working=working, + agent_name=anchor.agent_name, + mark=mark, + on=on, **({"force": True} if self._journal else {}), ) - return True + except ActivityMarkRefused as refusal: + if on: + if attempt is not None: + 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(held): + 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.", - "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 on: + await self._mark_taken_off(key, held, removing) + return True + + 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 + 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 "", + "mark": mark, + } + + 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 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. + + An expectation written before attempts were stamped carries no stamp, + 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 claims(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: + await record.claim(mark, attempt.token) + return attempt + + 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. + + 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 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: + await record.disclaim(attempt.token, renewed=attempt.renewed) + + async def _mark_taken_off( + 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 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. + + 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: + 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 (*key, record.data.get("mark_attempt", "")) in holders + ): + record.data.pop("mark", None) + record.data.pop("mark_attempt", None) + 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, 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. + + 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 = { + (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() + 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. + + 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 self._expecting.get(_mark_id(mark)): + return True + 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, + ) async def _forget_the_oldest(self) -> None: """Bound the in-memory cache, retaining durable message references. @@ -741,7 +1396,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: @@ -752,14 +1407,83 @@ 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]] = {} + self._noted_unconfirmed: set[str] = set() + self._undeliverable: set[str] = set() + + @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 + + @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)) + + @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 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)) + + @property + 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 + 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_answered_cards", False)) async def post( self, @@ -767,12 +1491,14 @@ async def post( *, channel_id: str, thread_root_id: str | None, + asked_at_root: bool, room_id: str, session_id: str, epoch: str, 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. @@ -788,6 +1514,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) @@ -805,22 +1539,39 @@ 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, - ), - 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 RichContentThrottled: + await session.delete(post) + await session.commit() + raise 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." @@ -839,7 +1590,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( @@ -860,6 +1615,171 @@ 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, + ) + + 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: + """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 remove(self, post: SessionRequestPost) -> None: + """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 + 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 + ) + if stored is None or stored.removed_at is not None: + return + stored.removed_at = datetime.now(UTC) + await session.commit() + async def _reserve( self, session: AsyncSession, @@ -964,10 +1884,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 @@ -999,8 +1925,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) @@ -1013,6 +1943,7 @@ async def refresh( try: await self._adapter.update_rich( post.external_channel_id, + agent_name, post.external_post_id, RequestCard( request, @@ -1020,6 +1951,7 @@ async def refresh( responder_external_id=responder_external_id, unavailable_reason=unavailable_reason, ), + post.thread_id, ) except RichContentThrottled: raise @@ -1036,9 +1968,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}", - post.external_post_id, + "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/session/renderers/__init__.py b/core/switch_core/bridges/collaboration/session/renderers/__init__.py index bac290809..679f1ce71 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/__init__.py +++ b/core/switch_core/bridges/collaboration/session/renderers/__init__.py @@ -11,9 +11,19 @@ from __future__ import annotations +import re +from collections.abc import Callable from dataclasses import dataclass -from switch_core.sessions.contract import TURN_ENDED, Item, TurnUpsert +from switch_core.sessions.contract import ( + TURN_ENDED, + ApprovalContent, + Item, + Question, + SnapshotRequest, + 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: @@ -21,7 +31,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.", @@ -30,7 +40,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. @@ -40,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 @@ -48,6 +66,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: @@ -55,20 +79,24 @@ 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") + 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" 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}." @@ -81,6 +109,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. @@ -101,6 +218,182 @@ 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) + ] + + +@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 marks the neutral renderer makes, in one platform's spelling. + + 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, 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: + """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. + + 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 + # 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 29429b05d..9bd2fdb4f 100644 --- a/core/switch_core/bridges/collaboration/session/renderers/neutral.py +++ b/core/switch_core/bridges/collaboration/session/renderers/neutral.py @@ -1,26 +1,229 @@ -"""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. + +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 + 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. +- `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. + +`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 from collections.abc import Callable +from dataclasses import dataclass + +from switch_core.sessions.contract import ( + TURN_ENDED, + ApprovalContent, + ApprovalOption, + ApprovalResult, + DecidedBy, + Item, + Question, + QuestionOption, + QuestionsContent, + QuestionsResult, + SnapshotRequest, + TurnUpsert, +) + +from . import ( + CLOSED, + NO_OPTIONS, + SURFACES, + Drawn, + Markup, + 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; 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, +# so the two platforms do not spell the same outcome differently. +_OUTCOME = { + "in-progress": "▸", + "completed": "✓", + "failed": "✗", + "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 activity." +_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 — 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 -from switch_core.sessions.contract import Item, SnapshotRequest, TurnUpsert +# 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 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." +) +# 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." +) -from . import RequestReference, turn_state +_OUTCOME_WORDS = { + "in-progress": "running", + "completed": "done", + "failed": "failed", + "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": "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)" + +# 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", + "submitting": "Questions", + "resolved": "Questions answered", + "closed": "Questions closed", +} def turn_summary( @@ -42,7 +245,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) @@ -60,31 +263,933 @@ def turn_summary( return f"{body}\n{state}" +def turn_status( + items: list[Item], + turn: TurnUpsert, + *, + escape: Callable[[str], str], + limit: int, + markup: Markup, + elapsed_seconds: float | None = None, + session_url: str | None = None, + mention: str | None = None, + error_summary: str | None = None, + tool_detail: bool, +) -> 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. + + `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 + 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, 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: + 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, tool_detail=tool_detail + ): + 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, + tool_detail: bool, +) -> 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. + + 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 tool_detail and 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 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() + if count + ) + if tally: + lines.append(tally) + 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"{SAID_MARKER} {said}" + title = _fit(item.title, _LOG_TITLE, escape=escape) if item.title else _LOG_UNTITLED + line = f"{_TOOL_MARKER} {_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, + *, + escape: Callable[[str], str], + limit: int, + markup: Markup, + elapsed_seconds: float | None, + session_url: str | None, + heading: bool, +) -> str: + """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 + 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 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. + + `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. + """ + 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}" + + 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) + + 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(shown), start=1): + line = _log_line(item, escape=escape) + if spent + len(line) + 1 > limit: + omitted = len(shown) - 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(lines if head is None else [head, *lines]) + + def request_summary( request: SnapshotRequest, reference: RequestReference, *, escape: Callable[[str], str], limit: int, + markup: Markup, + 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. Nothing else carries the options either, which is why the + body here always prints them. + """ + return render_request( + request, + reference, + escape=escape, + limit=limit, + markup=markup, + responder=responder, + unavailable_reason=unavailable_reason, + control_label_limit=None, + ).text + + +def render_request( + request: SnapshotRequest, + reference: RequestReference, + *, + escape: Callable[[str], str], + limit: int, + 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. + + 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. + + `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 + 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. + + 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): + head, body, footer, invites_answer = _approval_form( + request, + content, + reference, + escape=escape, + limit=limit, + markup=markup, + responder=responder, + control_label_limit=control_label_limit, + ) + else: + head, body, footer, invites_answer = _questions_form( + request, + content, + reference, + escape=escape, + limit=limit, + markup=markup, + responder=responder, + ) + if unavailable_reason and request.state in {"open", "submitting"}: + body = [] + footer = _fit(unavailable_reason, max(1, limit // 3), escape=escape) + invites_answer = False + 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( + request: SnapshotRequest, + content: ApprovalContent, + reference: RequestReference, + *, + escape: Callable[[str], str], + limit: int, + markup: Markup, + responder: str | None, + control_label_limit: int | None, +) -> tuple[list[str], list[str], str, bool]: + fit = _Faithful(escape) + literal = markup.literal(escape) + handle = literal(reference.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: + # 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": + budget = _label_budget(limit) + 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) + if invites_answer and not fit.whole: + return (head, body, _TOO_BIG, False) + return ( + head, + body, + _approval_footer( + request, + content, + handle, + outcome, + escape=escape, + limit=limit, + markup=markup, + responder=responder, + ), + invites_answer, + ) + + +def _approval_footer( + request: SnapshotRequest, + content: ApprovalContent, + handle: str, + outcome: _Chosen | None, + *, + escape: Callable[[str], str], + limit: int, + markup: Markup, + responder: str | None, +) -> str: + if request.state == "open": + if not content.options: + return NO_OPTIONS + # 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. + # + # 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": + return _approval_answer( + request, outcome, escape=escape, limit=limit, responder=responder + ) + return _closed(request, escape=escape, limit=limit, responder=responder) + + +@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, +) -> _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 + if not isinstance(result, ApprovalResult): + return None + 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. + # + # 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 "", + ) + + +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( + request: SnapshotRequest, + content: QuestionsContent, + reference: RequestReference, + *, + escape: Callable[[str], str], + limit: int, + markup: Markup, + responder: str | None, +) -> tuple[list[str], list[str], str, bool]: + fit = _Faithful(escape) + handle = markup.literal(escape)(reference.handle) + head = [ + f"{markup.bold(_QUESTION_HEADINGS[request.state])} · request " + f"{markup.code(handle)}", + fit(content.title, _share(limit, 1500, 3)), + ] + + body: list[str] = [] + if request.state == "open": + budget = _label_budget(limit) + for position, question in enumerate(content.questions, start=1): + title = fit(question.title, budget) if question.title else "" + 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): + 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 fit.whole: + return (head, body, _TOO_BIG, False) + return ( + head, + body, + _questions_footer( + request, + content, + handle, + escape=escape, + limit=limit, + markup=markup, + responder=responder, + ), + invites_answer, + ) + + +def _option_line( + index: int, + option: QuestionOption, + *, + fit: _Faithful, + limit: int, +) -> str: + """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, _label_budget(limit))}" + return line + + +def _questions_footer( + request: SnapshotRequest, + content: QuestionsContent, + handle: str, + *, + escape: Callable[[str], str], + limit: int, + markup: Markup, + responder: str | None, ) -> 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. - """ - 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." + if request.state == "open": + stuck = unanswerable(content.questions) + 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"{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": + 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. + """ + 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, if_cut: str +) -> 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 + 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) + 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: + 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 + # 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 + 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]), cut + + +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, 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 "" + return markup.link(label, url) + + +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) + + +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, 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: + """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. + + 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 "" + 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: + """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 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 + 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. + + 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: """The start of `text` that fits `limit`, saying so if it had to cut. diff --git a/core/switch_core/bridges/collaboration/session/renderers/slack.py b/core/switch_core/bridges/collaboration/session/renderers/slack.py index 9830d05f2..c62768ff6 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 @@ -46,11 +47,20 @@ 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, +) +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. @@ -117,9 +127,63 @@ # 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 +# 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. +# +# 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, 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 +# 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 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. +_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 @@ -130,6 +194,24 @@ _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 +# 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 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 # 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 @@ -141,6 +223,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 @@ -166,27 +256,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,18 +533,23 @@ 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." - # 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. + # 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 + # 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." @@ -487,7 +561,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 +593,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,15 +830,19 @@ 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)}`" + 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." @@ -774,7 +852,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 +860,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 +869,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 +877,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 @@ -934,7 +963,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=[ @@ -953,7 +982,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. @@ -992,7 +1020,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, @@ -1000,45 +1030,12 @@ 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", } ], ) 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" - 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'}" - 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 - ) - 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"] @@ -1053,7 +1050,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), @@ -1118,7 +1117,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)) @@ -1165,6 +1166,354 @@ 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. + + 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. + + 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_SECTION_ITEMS :] + title = _activity_title( + items, turn, elapsed_seconds=elapsed_seconds, omitted=len(shown) - len(kept) + ) + 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 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 + blocks: list[dict[str, Any]] + + +def render_activity_stream( + items: list[Item], + turn: TurnUpsert, + *, + elapsed_seconds: float | None = None, + session_url: str | None = None, +) -> StreamedActivity: + """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)] + 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, + ) + _fit_details(blocks, _MAX_STREAM_BYTES) + return StreamedActivity(title=blocks[-1]["title"], blocks=blocks) + + +def _session_card(session_url: str | None, *, live: bool) -> dict[str, Any]: + """The first card of a section, and where the Console link lives. + + 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. + + 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 + 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 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] = { + "task_id": _SESSION_CARD, + "title": "Switch session", + "status": "in_progress" if live else "complete", + } + if not session_url or urlsplit(session_url).scheme not in { + "https", + "http", + "switchdash", + }: + return card + return { + **card, + "hide_title": True, + "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, + header: str, + session_url: str | None, + live: bool, +) -> list[dict[str, Any]]: + """The turn as the three blocks that hold it: what is gone, then two sections. + + 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 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. 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 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. + """ + top, middle, bottom = _STEP_BLOCKS + last = max(len(steps) - 1, 0) // _MAX_SECTION_ITEMS + if last < 2: + pages = (top, middle) + return [ + _step_page( + steps, page, pages[page], running, header, session_url, live, last + ) + for page in range(last + 1) + ] + gone = (last - 1) * _MAX_SECTION_ITEMS + return [ + { + "type": "context", + "block_id": top, + "elements": [ + {"type": "mrkdwn", "text": f"_Activity 1–{gone} no longer shown_"} + ], + }, + _step_page(steps, last - 1, middle, running, header, session_url, live, last), + _step_page(steps, last, bottom, running, header, session_url, live, last), + ] + + +def _step_page( + steps: list[dict[str, Any]], + page: int, + block_id: str, + running: tuple[int, str] | None, + header: str, + session_url: str | None, + live: bool, + newest: int, +) -> dict[str, Any]: + """One section as the plan block that draws it. + + 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 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_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": [_session_card(session_url, live=live and page == newest), *shown], + } + + +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. + + 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: + task["title"] = _truncate("Unfinished: " + task["title"], _MAX_PLAN_TASK_TITLE) + return task + + +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) + running = _running(items, turn) + if running: + title += f" · {running[1]}" + if omitted: + line = "line" if omitted == 1 else "lines" + title += f" · {omitted} earlier {line} not shown" + return title + + +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 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 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(calls) if drawn[index].status == "in-progress"), + None, + ) + index = calls[-1] if current is None else current + label = "Last" if current is None else "Running" + title = plain_text(drawn[index].title) if drawn[index].title else "Tool" + return index, f"{label}: {title}" + + def _plan( items: list[Item], did: list[Item], @@ -1186,7 +1535,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." @@ -1202,9 +1551,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 @@ -1212,18 +1566,53 @@ 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 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": + preview = f"{SAID_MARKER} {' '.join(plain_text(item.text).split())}" + return { + "task_id": _task_id(item.item_id), + "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)), + } 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": _fit(title, _MAX_PLAN_TASK_TITLE) or "(untitled)", + "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 "" if details: - task["details"] = _rich_text(_fit(details, _MAX_PLAN_TASK_DETAILS)) + task["details"] = _rich_text(_truncate_prose(details, _MAX_PLAN_TASK_DETAILS)) return task @@ -1263,6 +1652,74 @@ 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. + + 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 = [ + 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: + 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/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 549807bce..c9798d04b 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 @@ -21,7 +21,9 @@ from slack_sdk.web.async_client import AsyncWebClient from switch_core.bridges.collaboration.adapter import ( + ActivityMark, CollaborationAdapter, + RemovalFailed, RequestCard, RichContent, RichContentFailed, @@ -45,6 +47,8 @@ from switch_core.bridges.collaboration.session.renderers.slack import ( SlackMessage, render_activity, + render_activity_plan, + render_activity_stream, render_attention, render_request, render_turn_with_request, @@ -55,6 +59,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__) @@ -122,8 +127,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: @@ -159,11 +168,63 @@ class SlackConnectionConfig(BridgeConnectionConfig): ) +# 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 — 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 + redraw sends only what moved. + """ + + channel_id: str + ts: str + blocks: dict[str, dict[str, Any]] = field(default_factory=dict) + + class SlackAdapter(CollaborationAdapter): publishes_sdk_sessions: ClassVar[bool] = True - separate_activity_log: ClassVar[bool] = True + 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 - renders_legacy_runtime_state: ClassVar[bool] = False + 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_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 + #: 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 @@ -190,6 +251,21 @@ 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() + # 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 # topped up as new ones are resolved. @@ -215,8 +291,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. @@ -398,8 +474,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." @@ -495,8 +577,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 @@ -509,10 +603,18 @@ 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, + thread_root_id: str | None, ) -> 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. @@ -522,6 +624,22 @@ 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 + 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) @@ -539,14 +657,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 @@ -556,6 +667,279 @@ 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: + 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 + + 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 + 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 — + 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: + 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: + """Send the sections that moved, and close a finished turn. + + 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. + + 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: + raise RuntimeError("Cannot extend an activity stream: Slack disconnected.") + if message_ref in self._streams: + self._streams.move_to_end(message_ref) + + drawn = render_activity_stream( + content.items, + content.turn, + elapsed_seconds=content.elapsed_seconds, + session_url=content.session_url, + ) + moved = [ + block + for block in drawn.blocks + if stream.blocks.get(block["block_id"]) != block + ] + if moved: + try: + await client.chat_appendStream( + 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) + 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) + + 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._settle_stream(message_ref) + 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: @@ -563,20 +947,20 @@ 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 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 @@ -728,6 +1112,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 @@ -752,7 +1137,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, @@ -951,6 +1336,40 @@ 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: + 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}: {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 + # 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( @@ -1004,63 +1423,89 @@ 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, ) 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, + mark: ActivityMark, + on: 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 + channel_id, + self._thread_ts_of(message_ref), + mark=mark, + on=on, + force=force, ) @staticmethod @@ -1828,6 +2273,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/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 9168776ae..0f5e46d7d 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -9,11 +9,13 @@ import secrets import time from collections import OrderedDict -from collections.abc import Awaitable, Callable -from dataclasses import replace +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 -from urllib.parse import quote +from urllib.parse import quote, unquote import httpx from aiohttp import web @@ -23,8 +25,12 @@ from switch_core.bridges.agent.commands import COMMANDS_BY_NAME from switch_core.bridges.collaboration.adapter import ( CollaborationAdapter, - LiveRuntimeIndicator, - format_elapsed, + RemovalFailed, + RequestCard, + RichContent, + RichContentFailed, + RichContentThrottled, + TurnActivity, ) from switch_core.bridges.collaboration.models import ( BridgeConnectionConfig, @@ -34,24 +40,47 @@ InboundAgentJoin, InboundAppJoin, InboundCommand, + InboundInteraction, InboundMessage, InboundUserJoin, ) +from switch_core.bridges.collaboration.session.renderers import ( + Drawn, + Markup, + offered_controls, + position_action, +) +from switch_core.bridges.collaboration.session.renderers.neutral import ( + activity_log, + in_activity_log, + render_request, + turn_status, +) from switch_core.bridges.collaboration.teams.auth import ( InboundActivityValidator, TeamsTokenProvider, ) from switch_core.bridges.collaboration.teams.cards import ( + activity_detail, agent_message_card, + answer_actions, card_attachment, + read_answer_action, +) +from switch_core.bridges.collaboration.teams.connector import ( + BotConnectorClient, + BotConnectorConflict, + BotConnectorGone, + BotConnectorRefused, + BotConnectorThrottled, ) -from switch_core.bridges.collaboration.teams.connector import BotConnectorClient 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 +94,219 @@ _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. +""" + +_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. + +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 a lock nobody is using 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. +""" + +_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. + + 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 + + +@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. + + 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. + + 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 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}**" + + def command(self, text: str) -> str: + return text + + +_TEAMS_MARKUP = _TeamsMarkup() + + # 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( @@ -120,14 +362,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) @@ -319,6 +564,41 @@ 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 + + # 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 + + # 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 + + #: 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] @@ -434,6 +714,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, _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() @@ -710,6 +992,81 @@ 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) + + @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 + 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 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. + """ + 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: + unused = next( + ( + key + for key, waiting in self._conversation_writes.items() + if not waiting.users + ), + None, + ) + if unused is None: + break + 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. @@ -807,7 +1164,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, below: list[dict[str, Any]] + ) -> dict[str, Any]: agent = await self.agent_rendering(sender_name) mentions = self._mention_entities(body) return { @@ -816,7 +1175,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, below)) + ], } # ── Messaging ──────────────────────────────────────────────────────────── @@ -831,36 +1192,50 @@ 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) + 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) + + 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. - if self._is_channel(channel_id) and thread_root_id is None: - conversation_id, msg_id = await self._connector.create_channel_thread( + 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, ) - - 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 + # `_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, remembered, only_if_unset=True) + return msg_id async def admin_message( self, @@ -869,6 +1244,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 @@ -876,44 +1252,28 @@ 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) + 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": 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 # 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, - ) - - 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 + 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 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 +1288,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 +1330,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,175 +1338,568 @@ async def send_typing( except Exception: logger.warning("Failed to send typing indicator to %s", channel_id) - # ── Runtime state ────────────────────────────────────────────────────────── + # ── 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. - async def _apply_runtime_state( + 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, + notice=self.unnotified_notice() if content.notify_unreachable else None, + ).text + + def _draw( self, - channel_id: str, - agent_name: str, - state: str, + content: RichContent, *, - 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. - - 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. + mention: str | None, + responder: str | None, + notice: str | None, + ) -> 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. """ - 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(), + 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{notice}" if notice else "" + text = ( + 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, + tool_detail=True, ) - elif state == "awaiting-input": - ref = await self._ping_operator( - channel_id, - agent_name, - mention_handle, - thread_root_id, - deeplink_url, - detail, + + tail ) - 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) + 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}") - async def _leaves_a_tombstone(self, channel_id: str) -> bool: - """Whether deleting a message here would leave wreckage behind. + def _mention(self, external_id: str | None) -> str | None: + """`` markup naming whoever holds this AAD id, or None. - 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. + 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. """ - return await self._uses_post_layout(channel_id) + 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)}" - 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), + 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." ) - 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. + def _below(self, content: RichContent, drawn: Drawn) -> list[dict[str, Any]]: + """What the card carries under its body. - 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. + 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. """ - 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( + 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 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 + 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 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 + directly under the copy of itself the card already shows. + """ + if content.turn.status not in TURN_ENDED: + return [] + if not any(in_activity_log(item) 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. + + 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 + `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=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 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. + + 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, 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. + + 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, - live.message_ref, - agent_name, - self.translate_outbound(f"✓ Done · {elapsed}"), + ) + 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, ) - 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. + @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, + ) - 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. + @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. """ - if await self._leaves_a_tombstone(channel_id): - return - await super()._reposition_runtime_state(channel_id, agent_name, thread_root_id) + return RichContentThrottled(retry_after=_CONFLICT_BACKOFF, text=text) - async def _remove_runtime_indicator( - self, channel_id: str, message_ref: str + 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. 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 + that makes a redraw after a restart address the conversation the post + actually went to. + """ + 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, 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 + # ordered against the channel instead. + target = ( + channel_id + if opening + else self._publication_conversation(channel_id, thread_root_id) + ) + try: + 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 + return _publication_ref(service_url, conversation_id, 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, including the last time. + + 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 + status halfway through the turn; and it addresses the message through + `_sent`, which a restart empties. Here the card is rebuilt, and the + 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 + an edit that did not know it would republish the turn as somebody else. + """ + drawn = self._render_rich(replace(content, notify_external_id=None)) + text = drawn.text + connector = self._connector + if connector is None: + raise RichContentFailed( + "Teams is not connected, so the publication could not be redrawn.", + text=text, + ) + address = self._publication_address(channel_id, message_ref, thread_root_id) + await self._edit_rich( + connector, agent_name, address, text, self._below(content, drawn) + ) + + async def _edit_rich( + self, + connector: BotConnectorClient, + agent_name: str, + address: _Publication, + text: str, + below: list[dict[str, Any]], ) -> None: - """Drop a superseded indicator without letting a delete failure escape. + try: + 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, below), + ) + 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 {address.activity_id} in " + f"conversation {address.conversation_id}: {error}", + 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. + + 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 + it away entirely. + """ + connector = self._connector + if connector is None: + raise RemovalFailed("Teams is not connected.") - 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.""" + address = self._publication_address(channel_id, message_ref, None) try: - await self.delete_message(channel_id, message_ref) - except (RuntimeError, httpx.HTTPError) as e: + 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: + 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( + 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( - "Could not remove the superseded runtime indicator %s in %s (%s); " - "a stale copy may remain visible", + "Teams card %s was already gone when it was taken back: %s", message_ref, - channel_id, - e, + error, ) - - 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) + except Exception as error: + raise RemovalFailed( + f"Teams would not delete {address.activity_id} in conversation " + f"{address.conversation_id}: {error}" + ) from error # ── Channels ───────────────────────────────────────────────────────────── @@ -1337,7 +2103,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. @@ -1515,13 +2281,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") @@ -1544,6 +2315,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( @@ -1623,6 +2397,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 7de5e8f1c..a0ecc1752 100644 --- a/core/switch_core/bridges/collaboration/teams/cards.py +++ b/core/switch_core/bridges/collaboration/teams/cards.py @@ -1,16 +1,285 @@ from __future__ import annotations +import re 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 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" + +# 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 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 +# 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+[.)]) ") + +# 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. + + 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. + + 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. + 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 + 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", + "text": "\n".join(lines), + "wrap": True, + "spacing": spacing, + } + for spacing, lines in runs + ] + + +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 activity_detail(log: str) -> list[dict[str, Any]]: + """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 + 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. + + 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 _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]], + below: list[dict[str, Any]], ) -> dict[str, Any]: """An Adaptive Card that labels a message with the sending agent's identity. @@ -26,14 +295,23 @@ 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 - notified.""" + notified. + + ``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": "1.4", + "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. @@ -71,11 +349,8 @@ def agent_message_card( }, ], }, - { - "type": "TextBlock", - "text": body, - "wrap": True, - }, + *body_blocks(body), + *below, ], } if mentions: diff --git a/core/switch_core/bridges/collaboration/teams/connector.py b/core/switch_core/bridges/collaboration/teams/connector.py index 1ccfdac91..a0bd8c339 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,125 @@ logger = logging.getLogger(__name__) +ACTIVITY_SIZE_LIMIT = 64 * 1024 +"""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 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. +""" + 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) + wire = text.encode() + measured = len(text.encode("utf-16-le")) + if measured > ACTIVITY_SIZE_LIMIT: + raise BotConnectorRefused( + 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 wire class BotConnectorClient: @@ -21,6 +138,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 +156,61 @@ 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 + # 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"usable {field} ({value!r}), so the message it wrote cannot be " + "edited or deleted.", + status=resp.status_code, + retry_after=None, + ) + return value + async def create_channel_thread( self, *, service_url: str, channel_id: str, activity: dict[str, Any] ) -> tuple[str, str]: @@ -43,35 +219,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 +277,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 8a960b7ef..968eb9410 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, @@ -21,13 +24,27 @@ 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 ( + ActivityMark, + ActivityMarkRefused, CollaborationAdapter, - LiveRuntimeIndicator, + RemovalFailed, + RequestCard, + RichContent, + RichContentFailed, + RichContentThrottled, + TurnActivity, ) from switch_core.bridges.collaboration.models import ( Attachment, @@ -39,14 +56,29 @@ InboundAgentJoin, InboundAppJoin, InboundCommand, + InboundInteraction, InboundMessage, InboundUserJoin, OutboundAttachment, ) +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_log, + in_activity_log, + render_request, + 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__) @@ -62,7 +94,81 @@ _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. 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. +_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 +# 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 = ("!", "/") @@ -116,6 +222,115 @@ ) +# 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 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. +_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 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}" + + 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. @@ -129,6 +344,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 @@ -164,6 +400,44 @@ class TelegramAdapter(CollaborationAdapter): supports_directory_search: ClassVar[bool] = False renders_custom_url_schemes: ClassVar[bool] = False + publishes_sdk_sessions: ClassVar[bool] = True + + #: 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 + + # 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 + + #: 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 @@ -197,27 +471,23 @@ 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] = {} + # 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 -> 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 # ── Lifecycle ──────────────────────────────────────────────────────────── @@ -641,7 +911,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 +1022,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( @@ -795,6 +1065,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. @@ -805,7 +1076,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( @@ -887,169 +1160,644 @@ 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. + # ── 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. """ - if thread_root_id: - return self._thread_trigger.get((chat_id, thread_root_id), thread_root_id) - return self._last_inbound.get(chat_id) + return MAX_MESSAGE - 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) + def rich_markup(self) -> Markup: + return TELEGRAM_HTML + + def rich_fallback_text(self, content: RichContent) -> str: + """The drawing `post_rich` sends, minus the agent's name and the buttons. - async def _end_working_reactions(self, chat_id: str, agent_name: str) -> None: - """Unmark everything this agent marked — the turn ends only once. + Only the text an error carries, so there is nothing here to attribute: + 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. - 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. + 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. """ - for ref in sorted(self._agent_reactions.pop((chat_id, agent_name), set())): - await self._mark_working(chat_id, ref, working=False) + return self._draw( + content, mention=None, responder=None, prefix="", controls=False + ).text - 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. + def _draw( + self, + content: RichContent, + *, + mention: str | None, + responder: str | None, + prefix: str, + controls: bool, + ) -> Drawn: + 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, + tool_detail=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. + lead = f"{mention}\n" if mention else "" + tail = f"\n{self.unnotified_notice()}" if content.notify_unreachable 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=_MAX_BUTTON_LABEL if controls else None, + ) + return replace(drawn, text=f"{prefix}{lead}{drawn.text}{tail}") + + def _activity_fold(self, content: TurnActivity, budget: int) -> str: + """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 + 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 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 + 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(in_activity_log(item) 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: + """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. + + 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 not drawn.answerable: + 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=self.rich_fallback_text(content), + ) + rows.append( + [InlineKeyboardButton(text=_button_label(control), callback_data=data)] + ) + return InlineKeyboardMarkup(rows) if rows else None - 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. + async def _render_rich(self, content: RichContent, agent_name: str) -> Drawn: + """Draw `content` as the agent, for one Telegram chat. - 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. + 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. """ - key = (chat_id, message_id) - if working == (key in self._reacted): - return + 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, + controls=True, + ) + + 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: - 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: + user_id = int(external_user_id) + except ValueError: logger.warning( - "Could not %s the working reaction on %s in %s: %s", - "add" if working else "remove", - message_id, - chat_id, - e, + "Cannot mention %r on Telegram: it is not a user id.", + external_user_id[:64], ) - return - if working: - self._reacted.add(key) - else: - self._reacted.discard(key) - - # ── Runtime state ──────────────────────────────────────────────────────── + 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 _apply_runtime_state( + async def post_rich( self, channel_id: str, agent_name: str, - state: str, - *, - mention_handle: str | None, + 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. + """ + 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, drawn) + anchor = await self._publication_anchor(channel_id, thread_root_id, text) + try: + 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, + reply_markup=controls, + **anchor, + ) + except Exception as error: + raise self._rich_failure( + 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) + return ref + + async def update_rich( + self, + channel_id: str, + agent_name: str, + message_ref: str, + content: RichContent, 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. - - 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. - - 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. + """Redraw a publication in place, including the last time. + + 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 while the turn runs the status is a line and + 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 + 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. """ - 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) + 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. + drawn = await self._render_rich( + replace(content, notify_external_id=None), agent_name + ) + 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), + content=content, + ) + + async def _edit_rich( + self, + channel_id: str, + message_ref: str, + text: str, + controls: InlineKeyboardMarkup | None, + *, + content: RichContent, + ) -> 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. + + `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: + 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, + reply_markup=controls, + ) + 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_publication(channel_id) 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(), + raise self._rich_failure( + error, + f"Telegram refused the edit to {message_ref} in chat {channel_id}", + 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}", + self.rich_fallback_text(content), + ) from error + self._note_publication(channel_id) + + 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 + + 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. 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 + 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, ) - elif state == "awaiting-input": - ref = await self._ping_operator( - channel_id, agent_name, mention_handle, thread_root_id, deeplink_url + 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. + + 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_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, 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(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_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) + + async def mark_activity( + self, + channel_id: str, + message_ref: str, + *, + agent_name: str, + mark: ActivityMark, + on: 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. + + 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. + + 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 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: + logger.warning( + "Cannot mark %s as being worked on: not a Telegram message reference.", + message_ref, ) - if ref is not None: - self._input_pings.setdefault(key, []).append(ref) + return + key = (channel_id, message_id) + 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 on else [], + ) + except (BadRequest, Forbidden) as error: + raise ActivityMarkRefused( + f"Telegram will not {'add' if on else 'remove'} the working " + f"reaction on {message_id} in chat {channel_id} ({error})." + ) from error + if on: + self._reacted.add(key) 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) + self._reacted.discard(key) - 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) + 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. + + 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. A forum whose topic cannot be + 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. + """ + 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, + **topic, + ) + except Exception as error: + logger.warning( + "Could not signal in Telegram chat %s that %s has started: %s.", + channel_id, + agent_name, + error, + ) # ── Channels ───────────────────────────────────────────────────────────── @@ -1320,12 +2068,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 @@ -1394,7 +2283,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 @@ -1484,11 +2372,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" @@ -1948,11 +2839,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)}" ) @@ -1960,26 +2847,170 @@ 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. - `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.""" + 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. + + 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 _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. + + 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. + + 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 {} - 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 root.is_topic: + return {"message_thread_id": root.id} + return { + "reply_parameters": ReplyParameters( + message_id=root.id, + allow_sending_without_reply=not await self._is_forum(channel_id), + ) + } + + 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 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 {} + 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 a message in it, so there is no conversation " + "this belongs to.", + text=text, + ) + 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=False ) } + async def _topic_kwargs( + self, channel_id: str, thread_root_id: str | None + ) -> 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 not None and root.is_topic: + return {"message_thread_id": root.id} + if not await self._is_forum(channel_id): + return {} + 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: """Whether Telegram will accept this as an inline photo. @@ -2031,9 +3062,21 @@ 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) + # 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. + # 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)): - kwargs = self._reply_kwargs(thread_root_id) if 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/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/switch_core/config.py b/core/switch_core/config.py index b7ab7a5a0..9c709370f 100644 --- a/core/switch_core/config.py +++ b/core/switch_core/config.py @@ -167,6 +167,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/switch_core/db/models.py b/core/switch_core/db/models.py index 6d7337f04..ae51c48c9 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -1578,6 +1578,27 @@ 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 + ) + # 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 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 + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) 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/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/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..b18ef87f3 --- /dev/null +++ b/core/switch_core/migrations/versions/c1e4b73a0d58_session_request_post_removed_at.py @@ -0,0 +1,33 @@ +"""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 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. + +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/migrations/versions/d94a7f1b5230_room_control_followup.py b/core/switch_core/migrations/versions/d94a7f1b5230_room_control_followup.py index dfb7bc4fb..788cabf2a 100644 --- a/core/switch_core/migrations/versions/d94a7f1b5230_room_control_followup.py +++ b/core/switch_core/migrations/versions/d94a7f1b5230_room_control_followup.py @@ -4,7 +4,7 @@ from alembic import op revision = "d94a7f1b5230" -down_revision = "c83f6e0a4129" +down_revision = "a9c4e7b21d63" branch_labels = None depends_on = None 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 diff --git a/core/switch_core/sessions/contract.py b/core/switch_core/sessions/contract.py index e3f08ae4e..92672af09 100644 --- a/core/switch_core/sessions/contract.py +++ b/core/switch_core/sessions/contract.py @@ -464,3 +464,46 @@ def parse_snapshot(payload: Any) -> Snapshot: 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 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. 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 + 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 + 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 DECISIONS + for option in content.options + ) diff --git a/core/switch_core/sessions/presentation.py b/core/switch_core/sessions/presentation.py index dbf4fb819..2f453cee4 100644 --- a/core/switch_core/sessions/presentation.py +++ b/core/switch_core/sessions/presentation.py @@ -46,6 +46,11 @@ async def notification_recipient( ) -> str | None: """Slack participants follow replies by default; mention only other origins. + 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. """ @@ -56,19 +61,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 + claimant: str | None = await db.scalar( members.join( ExternalUserClaim, ExternalUserClaim.external_user_id == ExternalUser.id ) @@ -76,15 +74,34 @@ async def notification_recipient( .order_by(ExternalUser.id) .limit(1) ) - if recipient: - return recipient - return None + return claimant + + 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) + + return await initiator() or await claimed_by(agent.owner_id) 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 cf8dd5b3a..733f412e0 100644 --- a/core/switch_core/sessions/publication.py +++ b/core/switch_core/sessions/publication.py @@ -4,12 +4,18 @@ 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 -from switch_core.bridges.collaboration.adapter import RichContentThrottled +from switch_core.bridges.collaboration.adapter import ( + ActivitySnapshot, + RemovalFailed, + RichContentThrottled, +) from switch_core.bridges.collaboration.session.outbound import ( + CardRefused, SessionRequestCards, SessionTurnActivity, ) @@ -24,11 +30,13 @@ 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, Snapshot, TurnUpsert, + decided, ) from switch_core.sessions.presentation import ( activity_error_summary, @@ -44,6 +52,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 @@ -67,8 +79,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__( @@ -79,7 +92,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." ) @@ -89,15 +102,49 @@ 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, + 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, + 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. `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. + + `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. + + `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 @@ -115,6 +162,23 @@ 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. + + `removal_allowed` / `removal_succeeded` / `removal_delayed` are the same + 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 + 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 + Console without linking to it. """ posts = SessionRequestPostStore() async with session_factory() as db: @@ -166,6 +230,16 @@ 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 + # only meaningful here, where naming someone was the intent. + asking = post is None and request.state == "open" recipient = ( await notification_recipient( db, @@ -175,18 +249,44 @@ async def refresh_cards( agent=agent, thread_id=thread_id, ) - 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, + asked_at_root, + 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 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, + asked_at_root, + recipient, + unreachable, + console_url, + ) in publications: state = ( request.revision, request.state @@ -196,46 +296,133 @@ 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": continue - new_post = await cards.post( - request, - channel_id=channel_id, - thread_root_id=thread_id, - room_id=room_id, - 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 {} - ), - ) + attempt = f"{session_id}:{request.request_id}" + if cards.undeliverable(attempt): + continue + if not post_allowed(attempt): + backed_off += 1 + continue + 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 RichContentThrottled as throttled: + post_delayed(attempt, throttled.retry_after) + backed_off += 1 + continue + 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.removed_at is not None: + # 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. + continue 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: + 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 continue post = await cards.recover(post) recovery_succeeded(post.token) + recovered = True + 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 + # 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. + 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 answered 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) + continue + if post is not None and (recovered or refresh_needed(post.token, state)): await cards.refresh( post, request, - **( - {"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 @@ -252,7 +439,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.", @@ -404,6 +591,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 +646,15 @@ 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. + # 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 + # 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,17 +670,20 @@ 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 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") @@ -497,10 +695,13 @@ 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 + turn, + snapshot.session, + online=online, + unconfirmed=turn.turn_id in unconfirmed, ) state = ( turn.status + (":" + error_summary if error_summary else ""), @@ -575,22 +776,38 @@ 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, + ) + 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 + # 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": 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 @@ -655,16 +872,98 @@ 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 `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 @@ -684,6 +983,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) @@ -811,10 +1121,13 @@ 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]] = {} 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) @@ -826,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 = ( @@ -859,6 +1187,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 @@ -928,17 +1257,25 @@ 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, + 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, + removal_allowed=self._card_removal.allowed, + removal_succeeded=self._card_removal.succeeded, + removal_delayed=self._card_removal.delay, ) except PublicationIncomplete as incomplete: ok = False 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, @@ -951,7 +1288,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/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_bridge_agent_display_names.py b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py index 9e9de7b1c..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: @@ -1412,31 +1311,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 @@ -1452,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 @@ -1515,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 "), ], ) @@ -1525,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 @@ -1588,9 +1393,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 +1409,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 @@ -1726,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_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_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_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..29710373e --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_collaboration_ingress.py @@ -0,0 +1,388 @@ +"""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 asyncio +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_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) + + 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_discord_activity_view.py b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py new file mode 100644 index 000000000..c3399ce66 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_activity_view.py @@ -0,0 +1,970 @@ +"""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_LABEL, + _ACTIVITY_VIEW_ID, + _CONSOLE_LABEL, + _MAX_BUTTON_LABEL, + _MAX_CUSTOM_ID, + _REFRESH_LABEL, + DiscordAdapter, + _refresh_id, +) +from switch_core.bridges.collaboration.session.renderers.neutral import ( + ACTIVITY_AUDIENCE_UNKNOWN, + ACTIVITY_FAILED, + ACTIVITY_GONE, + ACTIVITY_NOT_A_MEMBER, + ACTIVITY_UNREADABLE, +) + +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" + + +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.""" + + 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 _unknown_member() + + +class _HTTPResponse: + status = 404 + reason = "Not Found" + 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.""" + + 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() + # 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 _unknown_member() + 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 + + +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 ────────────────────────────────────────────────────────── + + +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: + """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_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_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.""" + 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_NOT_A_MEMBER + + +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_NOT_A_MEMBER + + +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_AUDIENCE_UNKNOWN + assert any( + "Cannot establish who may read" in r.getMessage() for r in caplog.records + ) + + +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) + + 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) + + +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_NOT_A_MEMBER + + +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_AUDIENCE_UNKNOWN + assert any("Cannot establish who is in" in r.getMessage() for r in caplog.records) + + +# ── 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_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] == [ + "\u2317 \u2713 Ran the tests", + "\u275d 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=[])) + press = _status_press(channel) + + await adapter._handle_interaction(press) # type: ignore[arg-type] + + assert "No activity." 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( + 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. + + 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) + + with caplog.at_level(logging.WARNING): + await adapter._handle_interaction(press) # type: ignore[arg-type] + + 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: + """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_discord_adapter.py b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py index 2ff6c4d82..5cac95b50 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")) @@ -910,143 +911,6 @@ def test_send_typing_triggers_once_and_off_is_noop() -> None: assert channel.typing_count == 1 -# ── Runtime state (working-on-it activity) ────────────────────────────────── - - -def _runtime_setup() -> tuple[DiscordAdapter, _FakeChannel, _FakeWebhook]: - adapter = _adapter() - channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) - webhook = _FakeWebhook() - adapter._webhooks[CHANNEL_ID] = 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 ─────────────────────────────────────────────────────── @@ -1258,29 +1122,3 @@ async def scenario() -> None: assert adapter._client is None _run(scenario()) - - -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() - - _run( - adapter.apply_runtime_state( - str(CHANNEL_ID), - "my-agent", - "awaiting-input", - mention_handle=None, - thread_root_id=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_card_buttons.py b/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py new file mode 100644 index 000000000..037386757 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_card_buttons.py @@ -0,0 +1,862 @@ +"""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, + RichContentFailed, + ThreadUnavailable, +) +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, + _http_error, + _no_thread_yet, + _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"] + + +# ── 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.""" + 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_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py new file mode 100644 index 000000000..6659c6868 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_card_removal.py @@ -0,0 +1,312 @@ +"""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, + _Message, + _Response, + _Webhook, +) + +CARD_ID = 9999 +CARD = f"{CHANNEL_ID}:{CARD_ID}" +THREADED_CARD = f"{ROOT_MESSAGE_ID}:{CARD_ID}" + + +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: + 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 = _unknown_message() + + 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 = _unknown_message() + + 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_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_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 + 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] = _unknown_message() # 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 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..0b5378b36 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_sdk_only.py @@ -0,0 +1,1122 @@ +"""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. + +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 + +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 ( + ActivityMarkRefused, + RequestCard, + RichContentFailed, + RichContentThrottled, + ThreadUnavailable, + TurnActivity, +) +from switch_core.bridges.collaboration.discord.adapter import ( + _PUBLICATION_WEBHOOK_NAME, + _WEBHOOK_NAME, + 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 +PUBLICATION_WEBHOOK_ID = 78 +ROOT_MESSAGE_ID = 321 +ASKER_ID = "60606" + + +# ── Fakes ──────────────────────────────────────────────────────────────────── + + +class _Response: + def __init__(self) -> None: + self.status = 400 + self.reason = "Bad Request" + self.headers: dict[str, str] = {} + + +def _http_error( + status: int, *, headers: dict[str, str] | None = None +) -> discord.HTTPException: + response = _Response() + 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] + + +class _Role: + pass + + +class _Guild: + def __init__(self) -> None: + self.id = GUILD_ID + self.default_role = _Role() + + +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.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 + self.channel.deleted_ids.append(self.id) + + async def create_thread(self, *, name: str) -> Any: + return self.channel.open_thread(self.id, name) + + +class _PartialMessage: + 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 + 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.fetch_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 + 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: + 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] + 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(name) + 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 + + +_WEBHOOK_IDS = { + _WEBHOOK_NAME: WEBHOOK_ID, + _PUBLICATION_WEBHOOK_NAME: PUBLICATION_WEBHOOK_ID, +} + + +class _Webhook: + 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]] = [] + 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() + # 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] + 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]: + """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}) + channel.existing_webhooks = [ + _Webhook(_WEBHOOK_NAME), + _Webhook(_PUBLICATION_WEBHOOK_NAME), + ] + return adapter, channel, thread, channel.existing_webhooks[1] + + +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 publication is the only account of the turn ────────────────────────── + + +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 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.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_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}) + + 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" + + +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.""" + 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( + str(CHANNEL_ID), "my-agent", _activity(), f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}" + ) + + assert channel.sent == [] + assert webhook.sent == [] + + +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. + + 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.NotFound(_Response(), "unknown message") # type: ignore[arg-type] + + with pytest.raises(ThreadUnavailable): + await adapter.post_rich( + str(CHANNEL_ID), + "my-agent", + await _card(), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + ) + + 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 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. + """ + 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) as raised: + await adapter.post_rich( + str(CHANNEL_ID), + "my-agent", + await _card(), + 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 == [] + + +# ── 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_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) + + with pytest.raises(RichContentFailed): + await adapter.update_rich( + str(CHANNEL_ID), "my-agent", f"{ROOT_MESSAGE_ID}:901", await _card(), None + ) + + +# ── Redrawing ──────────────────────────────────────────────────────────────── + + +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(), None + ) + + 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), "my-agent", f"{ROOT_MESSAGE_ID}:901", _ended(), None + ) + + assert webhook.deletes == [] + assert webhook.edits[0]["message_id"] == 901 + + +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.deletes == [] + assert webhook.edits[0]["message_id"] == 901 + + +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) + + await adapter.update_rich( + str(DM_CHANNEL_ID), "my-agent", f"{DM_CHANNEL_ID}:501", _ended(), None + ) + + 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: + 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), "my-agent", ref, _activity(), None) + + assert dm.messages[501].edited is not None + assert dm.messages[501].edited.startswith("**my-agent**: ") + + +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) + + restarted = _adapter({DM_CHANNEL_ID: dm}) + 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**: ") + + +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), "my-agent", f"{CHANNEL_ID}:901", card, None + ) + + assert webhook.deletes == [] + assert webhook.edits[0]["message_id"] == 901 + + +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( + 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(), None + ) + + assert webhook.deletes == [] + assert len(webhook.edits) == 2 + + +# ── 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`", PUBLICATION_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_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`", 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"{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`", PUBLICATION_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", mark="working", on=True + ) + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="other-agent", mark="working", on=True + ) + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=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", mark="working", on=True + ) + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True, force=True + ) + + assert channel.reactions == [("👀", True), ("👀", True)] + + +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 pytest.raises(ActivityMarkRefused, match="Add Reactions"): + await adapter.mark_activity( + str(CHANNEL_ID), + f"{CHANNEL_ID}:{ROOT_MESSAGE_ID}", + agent_name="my-agent", + mark="working", + on=True, + ) + + +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. + + 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}" + await adapter.mark_activity( + 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", mark="working", on=False + ) + + +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 pytest.raises(ActivityMarkRefused): + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True + ) + + channel.reaction_error = None + await adapter.mark_activity( + str(CHANNEL_ID), ref, agent_name="my-agent", mark="working", on=True + ) + assert channel.reactions == [("👀", True)] + + +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 + ) + # 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: + 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", + mark="working", + on=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_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, "👀") 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..3e604b7db --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_lifecycle_callback_endpoint.py @@ -0,0 +1,168 @@ +"""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} + + +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, 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, tenant, 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, _tenant, 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, _tenant, 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_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, _tenant, 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 08ec447fe..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,7 +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(), @@ -60,7 +67,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/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..71489ccea --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_activity_view.py @@ -0,0 +1,567 @@ +"""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_AUDIENCE_UNKNOWN, + ACTIVITY_FAILED, + ACTIVITY_GONE, + ACTIVITY_NOT_A_MEMBER, +) + +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_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] == [ + "\u2317 \u2713 Ran the tests", + "\u275d 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.""" + 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: + """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_NOT_A_MEMBER + 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" — 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_AUDIENCE_UNKNOWN + 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. + + 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_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_AUDIENCE_UNKNOWN + + +# ── 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_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} + + 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( + 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_callback.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py new file mode 100644 index 000000000..cef7fe10f --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_callback.py @@ -0,0 +1,183 @@ +"""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.ingress import CallbackIngress +from switch_core.bridges.collaboration.mattermost.callback import ( + CONTEXT_KEY, + action_context, + 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_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 _key_for(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: + 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 = _key_for(BRIDGE_ID, secret="the-previous-server-secret") + 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_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 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..0a0f18060 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_buttons.py @@ -0,0 +1,479 @@ +"""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. + +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 + +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, + 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_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 + + +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 + 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_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") + 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") + + props = _patched(adapter)["props"] + assert "attachments" not in props + assert props["from_bot"] == "true" + + +# ── 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_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py new file mode 100644 index 000000000..14bc684ab --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_card_removal.py @@ -0,0 +1,163 @@ +"""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. + +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 + +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_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/bridges/collaboration/test_mattermost_press.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py new file mode 100644 index 000000000..b77f0e094 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_press.py @@ -0,0 +1,320 @@ +"""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()) + + # 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 == [] + + +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 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 62a57fa17..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_runtime_state.py +++ /dev/null @@ -1,321 +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. -""" - -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 new file mode 100644 index 000000000..3690c6d61 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_mattermost_sdk_only.py @@ -0,0 +1,1002 @@ +"""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. + +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 + +import asyncio +import logging +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path +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 ( + 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]]] = [] + # 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.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]] = [] + # 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) + 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 + 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: + 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]] = [] + 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"]) + ) + return options + + 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 _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.""" + + 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 _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) + + 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: + self.posts = _DriverPosts(posts, owner) + self.users = users + self.channels = _FakeChannels() + self.reactions = _FakeReactions() + self.client = _FakeClient() + + +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, name) # type: ignore[assignment] + adapter._bridge_bot_ids.add(f"bot-{name}") + 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._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) + + +# ── 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 _posts(adapter).created_by == ["worker"] + assert ref + + +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 = NotEnoughPermissions("403 permission denied") + + with pytest.raises(RichContentFailed) as excinfo: + await adapter.post_rich("chan-1", "worker", _activity()) + + 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 ────────────────────────────────────────────────────────────────── + + +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(), None) + + 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(), None) + + assert _posts(restarted).patched_by == ["worker"] + + +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 = ResourceNotFound("404 post not found") + + with pytest.raises(RichContentFailed) as excinfo: + await adapter.update_rich("chan-1", "worker", ref, await _card(), None) + + 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", "worker", ref, await _card(), None) + + +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", "worker", ref, await _card(), None) + + 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.""" + 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", "worker", ref, card, None) + + 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), "R7" + ) + + 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), "R7" + ) + 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, "R7") + + 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), "R7" + ) + 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", 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"] + 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_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", mark="working", on=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", mark="working", on=True + ) + + +async def test_a_failed_mark_is_tried_again_rather_than_recorded_as_done() -> None: + """`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", mark="working", on=True + ) + + driver.reactions.create_error = None + 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")] + + +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", 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", mark="working", on=False + ) + + driver.reactions.delete_error = None + 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"), + ] + + +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", mark="working", on=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` and your choice, e.g. `R7 1`." 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"] + + +# ── 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_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( + _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_mattermost_working_reaction.py b/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py deleted file mode 100644 index 8112599b2..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_mattermost_working_reaction.py +++ /dev/null @@ -1,254 +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. -""" - -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") not in adapter._eyes - 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"), - ] 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..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 @@ -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 @@ -161,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), None) assert len(adapter.updated) == 1 channel_id, message_ref, content = adapter.updated[0] @@ -173,13 +174,17 @@ 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.")] - 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")), None + ) async def test_update_rich_raises_when_the_platform_raises() -> None: @@ -196,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")), None ) assert isinstance(excinfo.value.__cause__, RuntimeError) @@ -224,15 +229,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` and your choice, e.g. `R1 1`." 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 deleted file mode 100644 index b416a8cf3..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_runtime_indicator_race.py +++ /dev/null @@ -1,189 +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. - -Telegram still uses this shared runtime-indicator path. Slack now uses SDK -publication and does not participate in these races. - -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 - -from switch_core.bridges.collaboration.adapter import LiveRuntimeIndicator -from switch_core.bridges.collaboration.telegram.adapter import ( - TelegramAdapter, - TelegramConnectionConfig, -) - -CHANNEL = "chan-1" -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: TelegramAdapter, 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 update_message( - channel_id: str, message_ref: str, new_content: str - ) -> None: - await asyncio.sleep(0) - self.edits.append((message_ref, new_content)) - - 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.delete_message = delete_message # type: ignore[method-assign] - - -def _adapter() -> tuple[TelegramAdapter, _Platform]: - adapter = TelegramAdapter( - config=TelegramConnectionConfig(bot_token="test", bot_username="test_bot") - ) - 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: TelegramAdapter, detail: str) -> Any: - return adapter.apply_runtime_state( - CHANNEL, - AGENT, - "working", - mention_handle=None, - thread_root_id=None, - detail=detail, - ) - - -def _assert_consistent(adapter: TelegramAdapter, 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_session_activity.py b/core/tests/switch_core/bridges/collaboration/test_session_activity.py index 89f014635..6f039762a 100644 --- a/core/tests/switch_core/bridges/collaboration/test_session_activity.py +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity.py @@ -23,7 +23,12 @@ turn_state, ) from switch_core.bridges.collaboration.session.renderers.slack import ( + _MAX_SAID_DETAILS, + _MAX_SECTION_ITEMS, + StreamedActivity, render_activity, + render_activity_plan, + render_activity_stream, render_activity_text, render_request, render_turn_with_request, @@ -53,6 +58,24 @@ TURN = "turn-activity" +# 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 — 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 +# 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: source = FixtureEventSource.from_examples( @@ -101,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"]} @@ -285,7 +320,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: @@ -325,6 +360,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) @@ -343,20 +397,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. @@ -407,7 +474,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 +482,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 +498,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 +513,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." @@ -728,4 +797,305 @@ 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 _turn_cards(plan)[-1]["title"] == "Read" + + +async def test_a_turn_with_nothing_to_plan_yet_still_shows_it_is_working() -> None: + """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"] == "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 ───────────────────── + + +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 _turn_cards(drawn.blocks[0])] + 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 = _turn_cards(drawn.blocks[0])[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 _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: + """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 ( + _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: + """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 = _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.") + assert len(_detail(card)) > len(card["title"]) + + +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")) + + card = _turn_cards(drawn.blocks[0])[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 _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: + """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 = _turn_cards(drawn.blocks[0]) + 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(_turn_cards(drawn.blocks[0])[0]) + == "The failure is in **retry** — see `backoff.reset()`." + ) + + +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 = _turn_cards(drawn.blocks[0])[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_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(_turn_cards(drawn.blocks[0])[0]) + assert len(detail) <= _MAX_SAID_DETAILS + 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(_turn_cards(drawn.blocks[0])[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": "blocks", "blocks": [block]} for block in drawn.blocks] + ).encode() + ) + + +async def test_the_worst_streamed_message_is_one_slack_would_accept() -> None: + """Every card a stream can draw, all at the budget, in the costliest language. + + 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_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(_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. + + 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_SECTION_ITEMS, "漢" * _MAX_SAID_DETAILS), + _turn("completed"), + elapsed_seconds=90, + ) + + assert len(_turn_cards(drawn.blocks[0])) == _MAX_SECTION_ITEMS + 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_SECTION_ITEMS, "漢" * _MAX_SAID_DETAILS), + _turn("completed"), + elapsed_seconds=90, + ) + + 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) + + +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 len(_detail(_turn_cards(drawn.blocks[0])[0])) == _MAX_SAID_DETAILS + + +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_activity_log.py b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py new file mode 100644 index 000000000..f34841f40 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_activity_log.py @@ -0,0 +1,365 @@ +"""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 +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. + +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 +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 + +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 + +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 _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", + *, + limit: int = 10_000, + heading: bool = True, +) -> list[str]: + return activity_log( + items, + _turn(turn_state), + escape=_identity, + limit=limit, + markup=MARKDOWN, + elapsed_seconds=None, + session_url=None, + heading=heading, + ).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)" + + +# ── 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 = [ + _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 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 activity 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] + + +# ── 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 activity."] + + +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 ──────────────────────────────────────────────────── + + +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, + heading=True, + ) + + 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", + heading=True, + ) + + 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, + heading=True, + ) + + assert "console.example.test" not in drawn 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_session_card_posting.py b/core/tests/switch_core/bridges/collaboration/test_session_card_posting.py index 7fbbce9ca..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 @@ -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, ) @@ -147,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", @@ -154,6 +157,24 @@ async def _post_one( ) +def _steps(message: dict[str, Any]) -> list[str]: + """The cards drawn on an activity message, in the order they are in. + + 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. 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" + ] + + def _interactions( session_factory: async_sessionmaker[AsyncSession], bridge_id: str ) -> SessionInteractions: @@ -383,6 +404,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 +466,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, @@ -516,7 +539,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( @@ -552,7 +575,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( @@ -572,25 +595,32 @@ 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) 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 + 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 log + assert "same fixture user" in turn + assert len(steps) == 10 + 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, 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 @@ -601,11 +631,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 + assert len(_steps(client.updated[0])) == 10 + turn, card = ( json.dumps(call["blocks"], ensure_ascii=False) for call in client.updated ) - assert "9 tool calls" in log assert not client.deleted assert "Turn interrupted. 1 step left unfinished." in turn assert "Permission request closed" in card @@ -630,10 +660,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 = ( + assert len(_steps(client.updated[0])) == 10 + turn, card = ( json.dumps(call["blocks"], ensure_ascii=False) for call in client.updated ) - assert "9 tool calls" in log assert not client.deleted assert "Turn interrupted. 1 step left unfinished." in turn assert "Permission request closed" in card @@ -654,7 +684,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 ) @@ -677,7 +707,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( @@ -717,7 +747,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/bridges/collaboration/test_session_compact_presentation.py b/core/tests/switch_core/bridges/collaboration/test_session_compact_presentation.py index 9f5d62118..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 @@ -32,27 +32,33 @@ 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 in json.dumps(message.blocks[0]["tasks"][0]) + assert URL not in message.text 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 in json.dumps(message.blocks[0]["tasks"][0]) + assert URL not in message.text 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 +71,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(): @@ -121,7 +130,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, None) assert "<@UOWNER>" not in client.updated[0]["text"] @@ -141,10 +150,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 +167,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 +179,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_neutral_forms.py b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py new file mode 100644 index 000000000..efe2cccce --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_neutral_forms.py @@ -0,0 +1,482 @@ +"""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 + +import pytest + +from switch_core.bridges.collaboration.session.renderers import ( + MARKDOWN, + RequestReference, +) +from switch_core.bridges.collaboration.session.renderers.neutral import ( + render_request, + 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", + title: str = "Run a command?", + detail: str | None = None, +) -> 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=title, + detail=detail, + 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, markup=MARKDOWN + ) + + +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` and your choice, e.g. `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 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", + "title": title, + "prompt": "", + "options": [ + QuestionOption.model_validate( + { + "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) + ], + "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` and your answer, e.g. `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` and your answer, e.g. `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 + + +# ── 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 _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( + _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` and your choice, e.g. `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", "Always allow", decision="acceptForSession"), + _option("no", "Decline", decision="decline"), + ) + ) + + assert lines[-2:] == [ + "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 + + +# 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.""" + 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_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_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_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_session_request_lifecycle.py b/core/tests/switch_core/bridges/collaboration/test_session_request_lifecycle.py index 84ed85f9f..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", @@ -301,6 +304,7 @@ def _cards(adapter: SlackAdapter, post: SessionRequestPost) -> SessionRequestCar bridge_id="bridge-1", posts=_JustThisRow(post), session_factory=cast(Any, _NoDatabase), + surface="slack", ) @@ -322,7 +326,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] @@ -351,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() @@ -360,17 +369,37 @@ 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 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 ): @@ -381,8 +410,10 @@ 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"), + 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 ba4f71a15..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 @@ -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,14 +30,24 @@ @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 "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(): + 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"] - assert "10 earlier not shown" in plan["title"] - assert plan["tasks"][0]["task_id"] == "step-10" def text_size(value): @@ -136,14 +146,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, None) 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, None) 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, None) assert update.await_count == 2 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..1e4860892 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_settled_cards.py @@ -0,0 +1,315 @@ +"""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 + +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") +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( + *, + 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, SAID_SO, SUBJECT], + ).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_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)) + + 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"] + + +# ── 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 + ) 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_session_slack_streaming.py b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py new file mode 100644 index 000000000..c621e6d53 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_session_slack_streaming.py @@ -0,0 +1,1071 @@ +"""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 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 +the turn, and falls back visibly to an ordinary post everywhere else. +""" + +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 ( + 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.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 + +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 _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. + """ + 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"]: + drawn[block["block_id"]] = block + return drawn + + +def _pages(client: FakeWebClient) -> list[dict[str, Any]]: + """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"][1:]] + + +# ── 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_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. 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) + 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]] == ["blocks"] + later = _chunks(client)[1] + 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"), + ] + + +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(50)] + + 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_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(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"] == "Working… 9s · Last: Tool 50" + + +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_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) + 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(), 5.0), THREAD + ) + await adapter.update_rich( + CHANNEL, "Agent", ref, TurnActivity(many, _turn(), 10.0), THREAD + ) + + 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_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. + 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) + + await adapter.post_rich( + CHANNEL, + "Agent", + TurnActivity( + [_tool("t1", "Read", status="completed", text="312 lines")], + _turn(), + session_url="https://switch.example/session", + ), + THREAD, + ) + + 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", + "text": "312 lines", + } + + +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_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 section that is drawn before the turn has anything else to put in it.""" + 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. + + 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) + 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("t1", "Read")], _turn(), session_url=url), + THREAD, + ) + + assert _cards(client)[0]["details"]["elements"][0]["elements"][0]["url"] == url + + +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") + url = "https://switch.example/session" + + 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 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_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") + 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(), 9.0, session_url=url), + THREAD, + ) + + assert _cards(client)[0]["details"]["elements"][0]["elements"][0]["url"] == url + + +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. + + 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) + 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(many, _turn(), 1.0, session_url=url), THREAD + ) + running = [card["status"] for card in _cards(client)] + await adapter.update_rich( + CHANNEL, + "Agent", + ref, + TurnActivity(many, _turn("completed"), 9.0, session_url=url), + THREAD, + ) + + assert running == ["complete", "in_progress"] + assert [card["status"] for card in _cards(client)] == ["complete", "complete"] + + +# ── Paging ─────────────────────────────────────────────────────────────────── + + +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 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) + many = [_tool(f"t{n}", f"Tool {n}", status="completed") for n in range(240)] + + 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, + ) + + gone, older, newer = _drawn(client).values() + 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_sections() -> None: + """One cumulative line naming the whole missing range, not a count tucked + 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(99)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many[:98], _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] == [ + "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–49 no longer shown_" + assert [block["title"] for block in after[1:]] == [ + "Activity 50–98", + "Working… 9s · Last: Tool 98", + ] + + +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[:50], _turn(), 1.0), THREAD + ) + await adapter.update_rich( + 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] == [ + "Working… 9s · Last: Tool 50" + ] + older, newer = _pages(client) + 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 ───────────────────────────────────────────── + + +async def test_the_live_step_is_named_on_its_own_section_and_not_in_the_header() -> ( + None +): + """Said once, where it is useful. + + 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) + 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 [page["title"] for page in _pages(client)] == [ + "Activity 1–49 · Running: Grep", + "Working… 40s", + ] + + +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 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(50)] + + ref = await adapter.post_rich( + CHANNEL, "Agent", TurnActivity(many[:49], _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] == [ + "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–49", + "Working… 9s · Last: Tool 49", + ] + + +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"Working… 1s · Running: {shell}" + + +# ── 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]["blocks"][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 = _steps(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_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_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: + """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: + """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 [block["title"] for block in adapter._streams[ref].blocks.values()] == [ + "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 + ] == [["blocks"], ["blocks"], ["blocks"]] 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..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 @@ -30,6 +30,7 @@ from .test_session_answers import ( EXAMPLES_PATH, + TOKEN, _approval_form, _interactions, _post, @@ -166,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) @@ -232,6 +234,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_session_turn_messages.py b/core/tests/switch_core/bridges/collaboration/test_session_turn_messages.py index 7a0cfcafd..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 @@ -132,9 +132,28 @@ 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_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: @@ -168,7 +187,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 +199,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 +221,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 +244,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 +284,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 +363,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 @@ -457,7 +478,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") @@ -465,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."}) @@ -480,29 +510,39 @@ 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 "Read file" in _blocks(client.posted[1]) + 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]) -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"] + 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 "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 == [] 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_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/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/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..9798ac3b8 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_slack_card_removal.py @@ -0,0 +1,151 @@ +"""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, + RichContentThrottled, +) +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, 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}, headers=self._headers) + ) + + +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_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.""" + 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_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 + + +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_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/bridges/collaboration/test_slack_sdk_only.py b/core/tests/switch_core/bridges/collaboration/test_slack_sdk_only.py index 05b05048c..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 @@ -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 @@ -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(): @@ -27,25 +25,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() @@ -72,16 +51,20 @@ 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", mark="working", on=True + ) client.reaction_error = None - await slack.mark_activity("C1", "1.0", working=True) + await slack.mark_activity("C1", "1.0", agent_name="worker", mark="working", on=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", mark="working", on=False, force=True + ) assert client.reactions == [("remove", "1.0", "eyes")] @@ -91,37 +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(), - ) - 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] for call in platform.update_rich.call_args_list] == [ - "C1:status", - "C1:log", - ] - assert [ - call.kwargs["working"] 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_activity_fold.py b/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py new file mode 100644 index 000000000..382d16c25 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_teams_activity_fold.py @@ -0,0 +1,331 @@ +"""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_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( + 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"] + + +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) == ["\u2317 \u2713 Ran the tests", "\u275d 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)) == ["\u275d Fixed yesterday."] + + +# ── 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 activity" + assert hide["title"] == "Hide activity" + 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" 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..b0fd05378 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: @@ -827,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] @@ -837,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" @@ -848,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" @@ -863,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" @@ -876,194 +887,13 @@ 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") -# ── 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" - - -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_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_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py new file mode 100644 index 000000000..2e2201da4 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_teams_card_removal.py @@ -0,0 +1,179 @@ +"""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_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(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARRIED) + + +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(RemovalFailed): + await adapter.remove_publication(CHANNEL, CARRIED) + + +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 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..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,27 +143,6 @@ def test_a_chat_channel_puts_the_room_linked_notice_at_the_root() -> None: assert connector.replies == [] -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_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_outbound_rendering.py b/core/tests/switch_core/bridges/collaboration/test_teams_outbound_rendering.py index 1e7b8966c..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 @@ -14,8 +14,17 @@ from switch_core.bridges.collaboration.teams.adapter import ( TeamsAdapter, TeamsConnectionConfig, + _hard_wrap, +) +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" ) -from switch_core.bridges.collaboration.teams.cards import agent_message_card def _run(coro: Any) -> Any: @@ -137,18 +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: - 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 +174,87 @@ 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() +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"), + ] - assert adapter.translate_outbound("one\n\ntwo") == "one\n\ntwo" +def test_a_body_with_no_newlines_is_one_block() -> None: + assert _lines("just a sentence") == [("Small", "just a sentence")] -def test_list_items_keep_their_own_lines() -> None: - adapter = _adapter() - rendered = adapter.translate_outbound("- one\n- two") +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 rendered == "- one\n\n- two" - assert rendered.count("- ") == 2 + 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_with_no_newlines_is_unchanged() -> None: +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 + # agent wrote it. adapter = _adapter() + body = "**Heading:**\nbody" + + assert adapter.translate_outbound(body) == body + assert agent_message_card(_RENDERING, body, [], [])["fallbackText"].endswith(body) + - 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 +263,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)) + 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 ───────────────────────────────────────────────────────────────── 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 521e6b278..000000000 --- a/core/tests/switch_core/bridges/collaboration/test_teams_runtime_state_layout.py +++ /dev/null @@ -1,235 +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. -""" - -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 new file mode 100644 index 000000000..9b0b4e1af --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_teams_sdk_only.py @@ -0,0 +1,791 @@ +"""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". + +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 + +import asyncio +import logging +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from switch_core.bridges.collaboration.adapter import ( + RequestCard, + RichContentFailed, + 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, +) +from switch_core.bridges.collaboration.teams.connector import ( + BotConnectorConflict, + BotConnectorGone, + BotConnectorRefused, + 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 + # 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, + "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, + "service_url": service_url, + } + ) + 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, + "service_url": service_url, + } + ) + + 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, + "service_url": service_url, + } + ) + + +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 _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) + + +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 publication is the only account of the turn ────────────────────────── + + +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 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.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 == _publication_ref(SERVICE_URL, f"{CHANNEL};messageid={ROOT}", 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)) + + +# ── A finished status stays, in both layouts ───────────────────────────────── + + +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") + + _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_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.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_chat_keeps_it_as_well() -> None: + adapter, connector = _teams(chat=True) + + _run(adapter.update_rich(CHAT, AGENT, "MSG1", _ended(), None)) + + assert connector.deletes == [] + assert connector.updates[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_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") + + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + _run(adapter.update_rich(CHANNEL, AGENT, "MSG1", _ended(), ROOT)) + + assert connector.deletes == [] + assert len(connector.updates) == 2 + + +# ── 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_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 + 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_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.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: + 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_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 + + +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 ─────────────────────────── + + +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/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..c994ec628 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_activity_fold.py @@ -0,0 +1,313 @@ +"""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_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( + 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"] + + +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 ──────────────────────────────────────────────── + + +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"] 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..adaa83b48 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, @@ -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: @@ -135,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 @@ -150,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 @@ -157,12 +179,19 @@ 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. 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 + # 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 @@ -197,14 +226,27 @@ 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: + 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: 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 @@ -214,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: @@ -623,10 +667,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 ───────────────────────────────────────────────────────────── @@ -811,6 +862,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) @@ -837,6 +899,151 @@ 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_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_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() @@ -1139,299 +1346,6 @@ def test_a_rejected_album_still_delivers_the_files() -> None: assert len(_bot(adapter).photos) == 2 -# ── Runtime state ──────────────────────────────────────────────────────────── - - -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 ────────────────────────────────────────────────────────────── @@ -1659,6 +1573,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_card_removal.py b/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py new file mode 100644 index 000000000..30bf89a54 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_card_removal.py @@ -0,0 +1,168 @@ +"""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, 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 +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 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..aa28239ff --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_telegram_sdk_only.py @@ -0,0 +1,1492 @@ +"""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. + +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 + +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from telegram.error import ( + BadRequest, + ChatMigrated, + Forbidden, + NetworkError, + RetryAfter, + TimedOut, +) + +from switch_core.bridges.collaboration.adapter import ( + ActivityMarkRefused, + CollaborationAdapter, + RequestCard, + RichContentFailed, + RichContentThrottled, + TurnActivity, +) +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.outbound import SessionRequestCards +from switch_core.bridges.collaboration.session.renderers import ( + RequestReference, + parse_answer_position, +) +from switch_core.bridges.collaboration.session.transport import ( + FixtureEventSource, + project, +) +from switch_core.bridges.collaboration.telegram.adapter import ( + _REDRAW_INTERVAL, + TelegramAdapter, +) +from switch_core.sessions.contract import ApprovalResult, Item + +from .test_session_activity import _item, _turn +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" +SESSION_URL = "https://console.example/sessions/session-demo" +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) + + +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") + 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 publication is the only account of the turn ────────────────────────── + + +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: + """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.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(), None) + + 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), None + ) + + 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_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] = [] + 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", await _card(), 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(), 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` 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) + 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 and your choice, e.g. R7 1." + ) + + +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.""" + 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.""" + adapter = _adapter() + 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(), None) + + +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(), None) + 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(), None) + + assert "Turn complete." in _edited(adapter)["text"] + + +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."), None + ) + + 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(), 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(), None) + + with pytest.raises(RichContentThrottled): + await adapter.update_rich(CHANNEL, "two", f"{CHAT_ID}:12", _activity(), None) + 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 stays, as a line ───────────────────────────────────────── + + +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", _running(), None) + + await adapter.update_rich(CHANNEL, "my-agent", ref, _finished(), None) + + 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_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", _running(), TOPIC_ID) + + await adapter.update_rich(CHANNEL, "my-agent", ref, _finished(), TOPIC_ID) + + assert _bot(adapter).deletes == [] + assert "Worked for 42s" in _edited(adapter)["text"] + + +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", _running(), None) + assert "Working…" in _bot(adapter).messages[0]["text"] + + await adapter.update_rich(CHANNEL, "my-agent", ref, _finished(), None) + + text = _edited(adapter)["text"] + assert "Working…" not in text + assert "running" not in text + + +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() + + await adapter.post_rich(CHANNEL, "my-agent", _running(), None) + + 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_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", _running(), None) + + await adapter.update_rich( + CHANNEL, "my-agent", ref, _finished(_tool("failed")), None + ) + + assert "1 failed" in _edited(adapter)["text"] + + +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) + + await adapter.update_rich( + CHANNEL, "my-agent", ref, _ended(error_summary="The host went away."), None + ) + + assert "went away" in _edited(adapter)["text"] + + +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. 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) + + await adapter.update_rich(CHANNEL, "my-agent", status, _finished(), None) + await adapter.update_rich(CHANNEL, "my-agent", card, await _card(), None) + + 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 ───────── + + +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 stays in the Console ──────────────────────── + + +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_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(9), None) + + text = _posted(adapter)["text"] + assert "Read file" not in text + assert " 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) + + 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: + """Nothing draws it today, and a title is host text whatever draws it + next.""" + adapter = _adapter() + content = TurnActivity( + [_item(title="everything below is mine")], + _turn("running"), + ) + + await adapter.post_rich(CHANNEL, "my-agent", content, None) + + assert "everything below is mine" not in _posted(adapter)["text"] + + +# ── 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.""" + 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="two", mark="working", on=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", mark="working", on=True, force=True + ) + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=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", mark="working", on=True + ) + + +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") + + with pytest.raises(ActivityMarkRefused): + await adapter.mark_activity( + CHANNEL, f"{CHAT_ID}:55", agent_name="one", mark="working", on=True + ) + + +async def test_a_refused_removal_is_reported_whatever_this_process_remembers() -> None: + """The adapter does not decide whether a mark is outstanding. + + 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", 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", mark="working", on=False + ) + + fresh = _adapter() + _bot(fresh).reaction_error = Forbidden("the bot may no longer react here") + + with pytest.raises(ActivityMarkRefused): + await fresh.mark_activity( + CHANNEL, + f"{CHAT_ID}:55", + agent_name="one", + mark="working", + on=False, + force=True, + ) + + +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] + + +# ── 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 a typed answer 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_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 and your choice, e.g. 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.""" + 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, None) + + 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 _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(), None) + + 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() + + 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"] 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 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 246f08d51..f93a01e7b 100644 --- a/core/tests/switch_core/db/test_tenant_exemption_allowlist.py +++ b/core/tests/switch_core/db/test_tenant_exemption_allowlist.py @@ -112,16 +112,20 @@ "switch_core.clients.client_lifecycle_service", "switch_core.provisioning.postgres", "switch_core.room_service", - # The SDK session card, activity and publication layers. Every entry to - # them is either an inbound bridge event, which `bridge_core` binds the - # channel's tenant around before the handler runs, or the publication - # task, created inside that same scope so the task's context carries it. - # A caller that arrived with nothing bound would not read an empty set: - # publication reads its session row through `require_tenant_id()`, and - # every table the other two touch is policied by it. + # 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", - "switch_core.sessions.publication", # ── 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. diff --git a/core/tests/switch_core/sessions/test_activity_durability.py b/core/tests/switch_core/sessions/test_activity_durability.py index 85d87e585..40926a8dc 100644 --- a/core/tests/switch_core/sessions/test_activity_durability.py +++ b/core/tests/switch_core/sessions/test_activity_durability.py @@ -1,21 +1,43 @@ """Restart and uncertain-delivery tests using real PostgreSQL checkpoints.""" import asyncio +import logging +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta import pytest +from mattermostdriver.exceptions import NotEnoughPermissions +from sqlalchemy import select, text, update +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 SessionTurnActivity +from switch_core.bridges.collaboration.session.outbound import ( + CardNotPosted, + SessionTurnActivity, +) from switch_core.bridges.collaboration.slack.adapter import ( SlackAdapter, SlackConnectionConfig, ) -from switch_core.db.models import SdkSession, 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 +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_authority import command, host_event, opened, setup from .test_publication import Platform from .test_publication_retries import cards_for @@ -30,7 +52,9 @@ def __init__(self): self.messages = {} self.post_count = 0 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): @@ -42,12 +66,13 @@ 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, 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): + 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}" @@ -56,19 +81,88 @@ 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): - if working: - self.reactions.add(ref) + async def mark_activity(self, channel, ref, *, agent_name, mark, on, force=False): + marked = self.reactions if mark == "working" else self.hourglass + if on: + marked.add(ref) + else: + marked.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 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. + + 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() + 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: + marked.add((agent_name, ref)) else: - self.reactions.discard(ref) + marked.discard((agent_name, ref)) + + +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")) -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,39 +170,37 @@ 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, ) -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 @@ -116,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) @@ -128,21 +220,40 @@ 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): - if content.tool_log: - raise TimeoutError("Final log edit failed") - return await original(channel, ref, content) + 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 +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() @@ -150,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( @@ -249,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", @@ -257,12 +370,10 @@ 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): - from switch_core.bridges.collaboration.session.outbound import CardNotPosted - await setup(session_factory) platform = ActivitySlack() platform.fail_after_post = True @@ -274,6 +385,363 @@ async def test_unknown_delivery_without_a_match_never_blindly_reposts(session_fa assert platform.post_count == 1 +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 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. + + 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() + platform.fail_after_post = True + with pytest.raises(TimeoutError): + await publish(activity(session_factory, platform)) + assert platform.post_count == 1 + + for _ in range(3): + assert await publish(activity(session_factory, platform)) is True + 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. + """ + + 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. + + await publish(activity(session_factory, platform)) + written_off = await stamp() + assert written_off + + await publish(activity(session_factory, platform)) + assert await stamp() == written_off + 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_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, +): + """A problem still reaches the channel after a status delivery is lost. + + 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() + platform.fail_after_post = True + + async def report(renderer): + return 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.", + ) + + with pytest.raises(TimeoutError): + await report(activity(session_factory, platform)) + platform.messages.clear() + + await report(activity(session_factory, platform)) + 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 @@ -289,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]) @@ -316,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 @@ -382,16 +850,81 @@ 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( + "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): + +async def test_reaction_failure_retries_without_blocking_the_activity(session_factory): from unittest.mock import AsyncMock await setup(session_factory) @@ -400,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"} @@ -444,25 +977,367 @@ async def test_failed_final_edit_releases_reaction_across_restart(session_factor ) -async def test_busy_journal_is_skipped_until_next_sweep(session_factory): +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) - journal = ActivityJournal(session_factory, "bridge") - platform = ActivitySlack() - async with journal.open("session-demo", "message-demo") as record: - assert record is not None - assert not await asyncio.wait_for( - publish(activity(session_factory, platform)), 2 - ) - assert platform.post_count == 0 - assert await publish(activity(session_factory, platform)) + 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") -async def test_throttled_initial_post_retries_without_uncertain_reservation( + assert platform.reactions == {("Other", "channel-demo:question")} + + +async def test_a_shared_bots_single_mark_survives_one_of_two_turns_ending( session_factory, ): - from unittest.mock import AsyncMock + """The inverse, and why the scoping is a capability rather than the rule. - from switch_core.bridges.collaboration.adapter import RichContentThrottled + 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"} + + +# ── 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 + + +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. + + 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_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. + + 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 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: + 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") + platform = ActivitySlack() + async with journal.open("session-demo", "message-demo") as record: + assert record is not None + assert not await asyncio.wait_for( + publish(activity(session_factory, platform)), 2 + ) + assert platform.post_count == 0 + assert await publish(activity(session_factory, platform)) + + +async def test_throttled_initial_post_retries_without_uncertain_reservation( + session_factory, +): + from unittest.mock import AsyncMock + + from switch_core.bridges.collaboration.adapter import RichContentThrottled await setup(session_factory) platform = ActivitySlack() @@ -475,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( @@ -493,9 +1368,12 @@ 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 + assert platform.post_count == 1 async def test_publisher_reserves_activity_before_an_early_request(session_factory): @@ -510,7 +1388,759 @@ 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 ────────────────────────────── +# +# 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 + + +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.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") + marked.add(ref) + elif self.refuse_remove: + raise ActivityMarkRefused("the bot may no longer react here") + else: + marked.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") + + +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") + + +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, mark, on, force=False): + await super().mark_activity( + channel, ref, agent_name=agent_name, mark=mark, on=on, force=force + ) + if not on: + 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_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, +): + """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"} + + +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, mark, on, force=False): + await super().mark_activity( + channel, ref, agent_name=agent_name, mark=mark, on=on, force=force + ) + if on 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"] + + +# ── 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", + "mark": "working", +} + + +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"] + + +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") 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 diff --git a/core/tests/switch_core/sessions/test_answered_card_removal.py b/core/tests/switch_core/sessions/test_answered_card_removal.py new file mode 100644 index 000000000..726ee6688 --- /dev/null +++ b/core/tests/switch_core/sessions/test_answered_card_removal.py @@ -0,0 +1,561 @@ +"""A permission card after somebody has answered it. + +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 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 +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. +""" + +from __future__ import annotations + +import asyncio +import logging + +import pytest +from sqlalchemy import select + +from switch_core.bridges.collaboration.adapter import ( + RemovalFailed, + RichContentFailed, + 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 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. + + `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. + + 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_answered_cards = True + + 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 + self.gone = 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) + 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="Allow once.") + 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.""" + 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): + """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_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 + 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 platform.removed == [("channel-demo", post.external_post_id)] + assert len(platform.edits) == drawn + assert await _removed_at(session_factory) is not None + + +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 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 + + +async def test_a_platform_that_cannot_prove_a_removal_is_not_asked_to_try( + session_factory, +): + """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) + + 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_stays_owed( + session_factory, caplog +): + """A platform that will not delete is the case the mark must not appear. + + 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" + 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): + 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_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, +): + """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_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 + 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_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 — + 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 +): + """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") + + 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_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 diff --git a/core/tests/switch_core/sessions/test_decided.py b/core/tests/switch_core/sessions/test_decided.py new file mode 100644 index 000000000..c5c52549d --- /dev/null +++ b/core/tests/switch_core/sessions/test_decided.py @@ -0,0 +1,175 @@ +"""Whether a settled request is one a person actually answered. + +`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. Cancelling is the +one option that is not an answer to the question, and it keeps its card. +""" + +from __future__ import annotations + +import pytest + +from switch_core.sessions.contract import SnapshotRequest, decided + +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", "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 — 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 + 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_decided(outcome: str) -> None: + assert decided(_request(state="closed", outcome=outcome, result=None)) is False + + +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 ( + decided( + _request( + state="closed", + outcome="provider-error", + result={"kind": "approval", "optionId": "once"}, + ) + ) + is False + ) + + +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 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_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 confirmed answer.""" + assert ( + decided( + _request( + state=state, + outcome="answered", + result={"kind": "approval", "optionId": "once"}, + ) + ) + is False + ) + + +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 ( + decided( + _request( + state="resolved", + outcome="answered", + result={"kind": "questions", "answers": []}, + ) + ) + is False + ) + + +def test_a_question_is_not_an_approval_however_it_settles() -> None: + """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 ( + decided( + _request( + state="resolved", + outcome="answered", + result={"kind": "questions", "answers": []}, + content={ + "kind": "questions", + "title": "Which suite?", + "questions": [], + }, + ) + ) + is False + ) diff --git a/core/tests/switch_core/sessions/test_publication.py b/core/tests/switch_core/sessions/test_publication.py index 3935fc43d..36ee11bb0 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 ( @@ -33,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( @@ -44,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, post, content: RequestCard): + async def update_rich(self, channel, agent, post, content: RequestCard, thread): message = render_request( content.request, content.reference, @@ -52,6 +56,7 @@ async def update_rich(self, channel, 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): @@ -68,7 +73,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) @@ -135,10 +144,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") @@ -163,10 +174,18 @@ 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, bridge_id="bridge", + surface="slack", posts=SessionRequestPostStore(), session_factory=session_factory, ) @@ -193,3 +212,81 @@ 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" + + 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. + + 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 diff --git a/core/tests/switch_core/sessions/test_publication_retries.py b/core/tests/switch_core/sessions/test_publication_retries.py index e063c49e6..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, @@ -40,7 +43,16 @@ class RecoverablePlatform(Platform): - async def find_request_card(self, channel, thread, token, created_at): + """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 ( posted_channel == channel @@ -55,6 +67,7 @@ def cards_for(factory, platform): return SessionRequestCards( platform, bridge_id="bridge", + surface="slack", posts=SessionRequestPostStore(), session_factory=factory, ) @@ -63,6 +76,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() @@ -267,6 +288,207 @@ 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. + + 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__() + 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" + + +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) + 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_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 +): + """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( @@ -292,7 +514,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" ) @@ -500,6 +722,127 @@ 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 + + +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 + + +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 ──────── @@ -643,7 +986,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 ) 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, ) diff --git a/core/tests/switch_core/sessions/test_session_presentation.py b/core/tests/switch_core/sessions/test_session_presentation.py index e000e5123..1942635de 100644 --- a/core/tests/switch_core/sessions/test_session_presentation.py +++ b/core/tests/switch_core/sessions/test_session_presentation.py @@ -102,6 +102,51 @@ async def test_missing_actor_falls_back_to_claimed_owner_in_same_room(): assert "client_rooms.room_id = 'room'" in query +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 = "UACTOR" + + assert ( + await notification_recipient( + db, + bridge_id="bridge", + room_id="room", + origin=origin("mattermost"), + agent=SimpleNamespace(owner_id="owner"), + thread_id="root-1", + ) + == "UACTOR" + ) + query = str( + db.scalar.call_args.args[0].compile(compile_kwargs={"literal_binds": True}) + ) + assert "clients.matrix_user_id = '@actor:switch'" in query + assert db.scalar.call_count == 1 + + +async def test_an_asker_with_no_account_here_still_reaches_the_owner(): + db = AsyncMock() + db.scalar.side_effect = [None, None, "UOWNER"] + + assert ( + await notification_recipient( + db, + bridge_id="bridge", + room_id="room", + origin=origin("mattermost"), + agent=SimpleNamespace(owner_id="owner"), + thread_id="root-1", + ) + == "UOWNER" + ) + + @pytest.mark.parametrize( "turn_status,session_status,online,expected", [ @@ -118,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 @@ -126,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", [ @@ -167,7 +232,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, thread): self.contents.append(content) platform = Capture() @@ -182,6 +247,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, agent, post, content, thread): + 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): 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..582727347 100644 --- a/core/tests/switch_core/sessions/test_turn_activity_publication.py +++ b/core/tests/switch_core/sessions/test_turn_activity_publication.py @@ -26,17 +26,23 @@ 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)) return f"{channel}:activity.1" - async def update_rich(self, channel, 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): + 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] 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/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/remote/helm/switch/README.md b/deploy/remote/helm/switch/README.md index 88e447e79..fdd164ae4 100644 --- a/deploy/remote/helm/switch/README.md +++ b/deploy/remote/helm/switch/README.md @@ -28,11 +28,26 @@ 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 fails | **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; 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 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 991e442ad..8f60bd908 100644 --- a/deploy/remote/helm/switch/templates/NOTES.txt +++ b/deploy/remote/helm/switch/templates/NOTES.txt @@ -89,6 +89,38 @@ 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 }}. + +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. 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. + +{{ 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 that entry is yours to add. +{{- end }} + +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. Postgres holds everything: every room, every message, every account, and the 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/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 75fffe8dc..9ca577c7c 100644 --- a/deploy/remote/helm/switch/values.yaml +++ b/deploy/remote/helm/switch/values.yaml @@ -226,6 +226,36 @@ 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, 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 + # fails. Enable this, or clear the address; do not leave the two disagreeing. + # + # 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: + 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 diff --git a/deploy/shared_resources/setup.py b/deploy/shared_resources/setup.py index 9dc0a3958..776d703a6 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( @@ -238,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 @@ -254,6 +278,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/DISCORD_SETUP.md b/docs/old/bridges/DISCORD_SETUP.md index a3011a5ac..382fbd5eb 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,22 @@ 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 +- **A reaction on the message it is answering** — 👀 while the agent is working + 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, 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. + 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 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 + 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 +146,38 @@ 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 **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. 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. + ## 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..652b634a4 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,122 @@ 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 +``` + +**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* +(`ServiceSettings.AllowedUntrustedInternalConnections`, space-separated hosts). +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 +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. + +**What a reader sees.** An open permission card gains one button per option, +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 +server itself put there. It is one extra API call, made only for request cards +on bridges that take callbacks. + +**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 The local stack (`just up` / `just standalone-up`) runs a **Mattermost server in @@ -71,6 +186,23 @@ 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. 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 - **Identity.** Each agent gets its own Mattermost bot account, so agent messages @@ -92,15 +224,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/README.md b/docs/old/bridges/README.md index fffa6b0bf..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 | @@ -131,7 +131,20 @@ 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. +- **`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 diff --git a/docs/old/bridges/SLACK_SETUP.md b/docs/old/bridges/SLACK_SETUP.md index bd225998d..9c9c30262 100644 --- a/docs/old/bridges/SLACK_SETUP.md +++ b/docs/old/bridges/SLACK_SETUP.md @@ -191,26 +191,32 @@ 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. -- `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. + the 👀 and ⏳ that mark the message an agent is working on or holding. +- `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,84 +277,62 @@ 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 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. - -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 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: +"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. - 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 @@ -368,9 +352,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/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..7bb5b4b0e 100644 --- a/docs/old/bridges/TELEGRAM_SETUP.md +++ b/docs/old/bridges/TELEGRAM_SETUP.md @@ -287,17 +287,29 @@ 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. -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. +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 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. -**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