CHOO-2789: Extend session activity and approvals to all messaging platforms - #484
Draft
simonflk-sb wants to merge 96 commits into
Draft
simonflk-sb wants to merge 96 commits into
simonflk-sb wants to merge 96 commits into
Conversation
Mattermost now draws turns and requests through the rich-content seam instead of the legacy runtime-state renderer, which is switched off here (renders_legacy_runtime_state = False) so the two cannot both draw. The platform's own presentation: - One compact status post per turn, posted by the agent's own bot in the thread and edited in place. No separate tool log: Mattermost cannot collapse one, and the status carries the counts. - A problem somebody has to act on still gets its own reply, so it notifies rather than arriving as a silent edit. Edited when it clears, never deleted — Mattermost leaves "(message deleted)" behind. - Request cards as plain text with the typed-answer grammar spelled out against the particular form. - Per-agent 👀 on the asking message, claimed and released per agent. Shared code grew two capability flags for this. separate_attention_slot splits the attention reply from separate_activity_log, which Slack had conflated because it wants both. activity_reactions_per_agent scopes the reaction claim, the in-memory holder key and the journal's reaction_held query by agent, so one agent finishing does not strip another's mark. SessionRequestCards now carries its surface, which the publisher reads rather than being told separately. That replaces two hard-coded "slack" literals: the queued-turn acknowledgement in refresh_activity and the responder lookup in refresh, both of which were Slack-only by accident of being written for Slack first. The neutral renderer gains turn_status and request_summary, and the pieces both platforms need (CLOSED, SURFACES, NO_OPTIONS, unanswerable, example_value) are lifted out of slack.py unchanged. The legacy Mattermost renderer and its tests stay: removing them is the next task, and until then those tests drive the private methods directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six review findings on the Mattermost SDK parity work. Uncertain sends. `RichContentFailed` is the publisher's licence to drop a reservation and post again, so the adapter now raises it only for a refusal Mattermost actually gave: the `mattermostdriver` exceptions that map to 4xx statuses it understood. A 429 becomes `RichContentThrottled` carrying `Retry-After`. Everything else — a timeout, a dropped connection, a 5xx — propagates as itself, the reservation survives, and `find_request_card` settles whether the post landed. Reaction cleanup. `mark_activity` went through a handler that logged and returned, so durable publication wrote a completion receipt for a turn that still had eyes on it, and a missing bot read as success. The raising `_react_or_raise` is now the SDK seam; the swallowing wrapper stays for the legacy runtime path, which has nothing to retry with. A delete Mattermost says is not there is still success — the channel is already as asked. Approval and question forms. Option labels were clipped to 150 characters while the form still said "Reply with `R42 1`", so two options differing past the ceiling were the same choice under two numbers. Labels now get a share of what the message can hold, an "accept for this session" option says its scope on the form, and a form that could not be shown whole — a cut label, a question left out, a body `_compose` had to trim — drops the instruction and names Console instead. Timer redraws. `redraws_for_elapsed_time` decides whether the clock alone is reason to republish a running turn. Slack opts in, where the status is a small line of its own; Mattermost does not, where it is the turn's one post. The duplicate-draw gate in `_edit` now applies everywhere rather than only where the activity log is separate, keyed on what the status actually shows. Notification recipient. `notifies_only_by_mention` names the agent's owner ahead of whoever typed the command, on platforms where an unnamed reader is an unnotified one. The other is always the fallback. An attention post with nobody to name says so, charged to the same character budget, restoring the explanation the legacy ping gave. Trigger-location typing. `notify_working` is a one-shot nudge sent as a turn opens, at the place the work was asked for and never on a redraw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps left by the previous pass. Clipping that the answer gate never saw. Option labels counted toward "is this form showing itself whole", but the approval title and detail, the question prompt and the option descriptions did not, so a permission detail naming a second operation past 1000 characters still invited `R42 1`, and two options distinguished only past 200 characters still read as the same choice twice. Every piece of a form a reader decides on now goes through one fitter that records whether it had to cut, so a budget cannot be added without the check that goes with it. Descriptions get the label budget for the same reason labels did. `_compose` drops a head line it cannot fit and has no count to report for it, which was the same hole from the other end; that now counts as cut too. A card asked of nobody. `notify_unreachable` reached `TurnActivity` only, so an open request that named no one rendered as if it had notified someone. `RequestCard` carries it too, set on the first post of an open card and never on a redraw — where the mention is dropped deliberately, having already been made. Mattermost appends the same notice it appends to a status, charged to the same budget as the form and the handle above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The owner-first preference went in as a guess at who a stalled session needs. The person waiting on the answer is the better guess: on a platform where the mention is the whole notification, naming somebody else leaves the asker watching a channel that never says their name. The owner stays as the fallback, so a turn started by someone with no account here still reaches somebody. That is what the function did before this branch, so the platform-dependent preference is gone rather than inverted, along with the flag that selected it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Discord now carries a turn the way Slack and Mattermost do: one status message per turn, posted into the turn's thread under the agent's webhook identity, edited in place as the turn moves, and a separate reply when something needs a person. A question names the asker, because Discord subscribes you to a thread you started, were mentioned in, or have spoken in and to nothing else — a reply that names nobody reaches nobody. Two behaviours are Discord's own. A finished turn's status is deleted when it sits at a channel root and kept when it sits in a thread, so a busy channel is not left with a row of completed statuses while a thread keeps its record. And a webhook message carries no metadata, so recovery after an uncertain send matches the request's visible handle rather than a hidden marker; `find_request_card` takes that handle, and an activity publication, which has none, cannot be recovered that way. The failure contract is what decides whether a reservation survives. A 4xx is Discord refusing and becomes `RichContentFailed`; a 5xx, a timeout or a dropped connection is not an answer and propagates as itself, so the caller keeps its reservation and settles it by searching instead of asking the same question twice. The legacy runtime-state renderer is still present but no longer reachable, so its tests drive `_apply_runtime_state` directly. Removing it is its own task. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six defects found in review of 433a46c, all in how a session publication is placed, found again, and failed. Destination. A publication whose thread could not be resolved fell back to the parent channel whatever the reason. A private thread the bot has lost access to and a thread that does not exist yet are indistinguishable from the error alone, and only one of them makes the parent an acceptable place for a request card: the other hands a private conversation's question and options to an audience that was never in it. The fallback now applies to exactly one case — no thread exists and none could be made, so the channel root the turn was addressed at is still its origin. Everything else refuses before anything is sent, which leaves the caller's reservation intact. Recovery. Publications now go through a second webhook of their own, "Switch Sessions", which nothing else ever posts to. Sharing the agents' webhook made authorship useless as evidence — an agent's reply saying "the request `R7`" satisfied the same test as the card, and binding to it meant every later settlement edit overwrote a sentence while the real card went on saying the request was open. Among publications, a card is picked out by its heading line: bold from the first character, ending in its own handle, matched line by line rather than at the top, because the top of a card is the mention that notifies whoever asked. In a DM, where there are no webhooks, the author is the bot and the heading test carries the weight. That is weaker, and is written down rather than left to be discovered. Throttling. A 429 reaches this seam two ways. The library raises RateLimited when it declines to sleep through one; the webhook transport raises a plain HTTPException once it has exhausted its own retries, or when the response lacks the header it classifies by. Only the first was read as a throttle, so the second discarded the reservation and threw away the delay Discord had just supplied. Both are throttles now, taking the delay from the Retry-After header and falling back to five seconds — a floor, because retrying a throttle immediately is how a throttle becomes a ban. Also: a DM redraw reads the agent's name back off the message it is about to edit when the in-memory note of who posted it is gone, since in a DM the body is the only durable record of the sender; and a Forbidden on reaction *removal* is logged as an error rather than as the same shrug as a reaction that could not be added, because a mark left on a finished turn is a false statement rather than an absence and no retry takes it back. Two findings are deliberately not fixed. A status still cannot be recovered after an uncertain send, and the library's webhook sender can still duplicate a post by retrying a 5xx below us. They are one trade-off, not two — both are the price of per-agent webhook identity on the status — and choosing between that identity and a bot post with an idempotency nonce is a product decision. Recorded as D17 for Simon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Telegram gains the SDK publication path the other adapters have: its own send and edit seam that raises instead of returning nothing, HTML markup, the expandable activity block inside the one status message, forum topic ids kept apart from reply targets, per-chat pacing that always sends the final state, and the capability flags that describe all of it. Two shared seams moved to make that possible. `update_rich` now takes the agent's name, and so does `SessionRequestCards.refresh`. A platform that writes the name into the message body — Telegram always, Discord in a DM — could only rebuild it from an in-memory map, so a restart mid-turn redrew the message as somebody else. The name now comes from the caller on every call, and both per-adapter maps are deleted. The neutral renderer takes a markup seam for the same reason: Telegram's body is HTML, and one renderer with a markup argument beats a second copy of the budget and faithfulness logic. The rest is delivery under uncertainty. A Telegram bot cannot search its own history, so a send whose outcome is unknown can never be resolved: the row stays reserved with `external_post_id` equal to its token, the answer guard refuses every typed answer to it, and the publisher retries a lookup that will never succeed. A question sits in the chat and typing the answer does nothing. `recovers_uncertain_posts` separates the two meanings of `None` from `find_request_card` — "not there yet" on Slack, Mattermost and Discord, "never" here. Where it is false the publisher sends one message naming the request and linking Console, records it on the new `unconfirmed_notice_at` column, and then leaves the row alone instead of failing the publish on every cycle. The column is written before the send, so a crash loses the notice rather than repeating it; a notice that cannot be sent is logged and not retried. The reservation and the answer guard are untouched, and nothing is reposted. A turn's status goes the other way: an unrecoverable status reservation is released and the slot posts again, with a warning that it may duplicate a message already in the chat. Holding it would also swallow that turn's later attention message, and a silent turn is the worse fault. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A status whose send was never acknowledged keeps its reservation for good where the platform cannot search for it, instead of being reposted on every cycle. Attention is published even when the status cannot be settled, so the turn keeps its one way of saying something went wrong. A finished turn's status is taken out of the chat in flat chats and forum topics alike, the way the legacy indicator was: a Telegram chat is the conversation, not a side channel, and a permanent "Worked for 12s" per turn is litter. Request cards and anything still reporting a problem or an unreached reader stay. A deletion Telegram refuses leaves the final state showing and says so; an unknown outcome is raised so the publisher can come back to it. A publication with a thread root now refuses definitely rather than detaching: a card that loses its reply target is the agent's question put to the whole chat, and an answer typed at it there binds a request those readers never saw. Relayed conversation keeps the old widening behaviour. Removing a working mark this process added is raised when Telegram refuses, so a turn is not recorded as cleaned up while the chat still shows it running. A refused addition, and a reconciling removal of a mark this process never added, stay reported-once as before. Pacing is charged to the chat and covers sends as well as edits, per the constraints note: Telegram's 429 is the chat's and several agents publish into one group. Terminal states, cleanup, attention and cards are never held back. The comments no longer assert an edit quota Telegram has not published. The status carries the expandable fold it was said to: the latest few tool calls and a count of the rest, inside `<blockquote expandable>` assembled after escaping, dropped first when the status is already near the limit. Typing goes to the forum topic the command came from, rather than to the chat at large. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Telegram card has had no controls: an approval could be read in the chat but only answered by typing. This puts the options on the message as buttons and takes the press back through the same authority a typed answer goes through. The obstacle is the payload. Telegram allows 64 bytes for everything a press hands back, and an option id is an unbounded, possibly non-ASCII string the host chose — so a button cannot carry one and a press that did would be a press we could not fit. It sends where the control was on the card instead, and the server resolves that against the form the card rendered. That is the stronger of the two, not a concession: a press can only ever name an option the card actually offered, whatever reached us. So the shape is shared rather than Telegram's. `offered_controls` states once which requests a press can answer — one question, one choice out of a list, still open — mirroring what the resolver will accept, so a card does not draw a control whose press is going to be refused. `resolve_pressed_position` turns a position into an option id against the record and hands it to the existing resolver, which keeps what a press means on each kind of card in one place. On the adapter: a keyboard on a card, redrawn on every edit so a settled card loses its buttons without anything having to remember it had them; a press attributed to `from_user`, which Telegram signs and the payload cannot claim; and an acknowledgement on every path out, including the ones where nothing happened, since an unanswered press spins on the presser's client until it gives up. A refusal reaches the presser as an alert on the press itself rather than a message in the chat — a bot cannot message someone who has not opened a chat with it, so this is also the only private reply Telegram offers in a group. A press that succeeds is acknowledged with no text: the card's redraw says an answer was taken, and saying so here would say it before the redraw proves it. A payload that would exceed the limit refuses the card rather than posting it without buttons, so a card never looks answerable by typing alone when it was meant to be pressable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…privately Two defects found reviewing the Telegram interaction seam. A card whose body had to be cut still drew live buttons. The footer under a clipped form says the request cannot be answered from this message, but the keyboard beside it would still resolve against the saved form and settle the request on text the reader was never shown. The keyboard was deciding pressability from `unavailable_reason` alone, which is a different question from the one the renderer had already answered while composing. The renderer now returns that answer with the drawing. `render_request` yields a `Drawn(text, answerable)`; `request_summary` stays as it was for the three platforms whose drawing is the whole of the card. Telegram carries the `Drawn` from `_draw` through `_render_rich` to `_controls`, on send and on redraw, so the buttons and the sentence under them cannot disagree. A truncated button label is deliberately still pressable: the body above it carries the option in full. An answer turned down by authority went out through `admin_message`, so a rejected approval was posted to the whole channel while the presser's client showed nothing. It now goes through `tell_actor` like the refusals the card itself gives — privately on a platform that has a private reply, and otherwise into the card's thread, which is where it used to post anyway. The press path passes no thread ref, because the reply to a press is addressed by the press. Typed answers keep the card's thread and the answerer's name. The comment claiming every refused press was already private is corrected: it was true of the resolution branch, not of this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Telegram and Discord both swallowed a refused reaction and returned as though it had worked. For a refused *addition* that is right: reactions are switched off in the chat, or the bot lacks the permission, and the turn should go on without the mark rather than retry it forever. For a refused *removal* it is a false statement — the 👀 is still on the message, the channel shows an agent working on something it finished, and reporting the turn as drawn stops the publisher ever asking again. Telegram tried to tell the two apart from `self._reacted`, a set a restart empties. Empty then means "no idea", not "nothing was added", so after a restart every stuck mark read as nothing to remove. Discord did not try at all and logged both. Both now raise `ActivityMarkRefused`, which says only that another attempt will be refused the same way; anything a retry might fix still raises as itself. The publisher decides what a refusal means, because the question — was a mark ever put there — is answered by the durable journal record and not by adapter memory. A refused addition is recorded on the turn's own record, and only that recorded refusal lets a refused removal count as done. Absence of evidence means the cleanup is outstanding and the turn is not drawn. Discord's pre-SDK path keeps deciding for itself, which is sound there: it attempts a removal only for a mark this process added, so its own memory is enough to know the mark is outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of e48d1e7 found the evidence attached to the wrong thing. Turns share one reaction: only the first to claim an asking message adds the mark, only the last to finish takes it off, and those are rarely the same turn. Recording a refusal on the turn that hit it therefore answered a question about the mark with a fact about a turn, and got it wrong twice. With reactions switched off, the first turn recorded its refusal and then completed, which compacts its row to a receipt and discarded it; the last holder found nothing, assumed a mark it had to remove, and never finished. In the other direction a turn's historical refusal outlived the moment it described: once another turn had successfully added the shared mark, the refused turn still read its own old refusal as proof the message was clean and reported a finished cleanup with the eyes still on it. Neither needed a restart. So the evidence is now keyed by the reaction — channel, asking message, and the agent where each reacts as its own bot — and says only "this may be on the message". It is written before the platform is called, because an addition that fails without an answer may still have landed, and it is retracted only when the platform states outright that nothing is there: a refused addition, or a removal that worked. Completion carries it into the receipt, because the holder that must remove the mark may be a later turn in a later process. Any holder's standing expectation, ended or not, makes a refused removal outstanding. A publisher with no journal kept the same rule instead of assuming the mark was absent: it has no durable state to be restarted into, so its own memory is the whole truth, and it now owes cleanup for a mark it added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A request card whose thread could not be resolved was posted to the parent channel. Discord answers "unknown channel" for a thread that was never started and for one that existed and has been deleted, and the adapter read both as "the turn was addressed at the channel root". For a private thread that has since been deleted that is false: the card carries the agent's question and its options, and the parent channel is an audience that was never in the conversation. Nothing takes that back. The distinction cannot be recovered from the platform, only from the recorded origin of the command, so the decision moves to where the origin is known. `_publication_thread` now raises `ThreadUnavailable` — a statement that no thread exists and none could be made, with no view on what to do about it — and never substitutes the channel. A thread that exists and will not open still raises as itself, because that one may be private and is nobody's licence to use the parent. `SessionRequestCards.post` takes `asked_at_root`, derived from `origin.thread_id is None`, and only a card that was asked at the channel root is posted there when its thread is unavailable; the fallback is logged as before. Asked in a thread, nothing is posted, the handle is released and the caller is told, which is the same path as any other refusal and is retried on the next cycle. A card answering a card carries its destination as its origin thread, so it reads as thread-asked and merely refuses the fallback it would not have needed. Turn activity is unchanged: it never fell back, and `ThreadUnavailable` is a `RichContentFailed` like the error it replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A platform that cannot search for what it posted has no way to finish a reservation whose send was never acknowledged. The publisher treated that as a fresh failure on every pass: two tracebacks every five seconds for the life of the process, and the session never entered the "published" set, so its whole pass re-ran each cycle and the failures that were new were buried under one that never changes. Close the question instead of asking it forever. The decision is stamped into the slot's existing journal entry as `abandoned_at`, so the record distinguishes a reservation still being chased from one given up on, and a restart can tell an old conclusion from one it has just reached. No schema change: `ActivityRecord.data` is already a JSON dict we write. `ActivityAbandoned` carries the decision to callers, and the callers differ in what it costs them. A status is the whole of a turn's display, so the publisher holds the turn back permanently and stops counting it. An attention message is one message beside a status that is still being drawn, so it is swallowed where it is raised — Telegram publishes a separate attention slot and cannot search, and writing the turn off there would have frozen a display that works. Reported once per slot, where the decision is made, at warning level. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Abandoning a turn's status held the whole turn back: the publisher checks that before it works out state or an error summary, so a turn that later failed never said so, its attention message never went out, and the journal receipt and the `:eyes:` on the asking message were never cleared. One message that cannot be confirmed is one message; the turn around it still owes everything else. The decision now stays where it is made. A status nothing can confirm counts as drawn as far as it can go, so the next state is free to publish, attention still goes out beside it, and an ended turn is still tidied up. Tell "no marker to search for" apart from "a search came back empty". Discord finds a card by the handle the card prints, and a turn's own messages print none, so a lookup for one can never match — but a lookup that misses on Slack or Mattermost means "not yet", and must be asked again. A new `carries_publication_marker` capability says which an adapter is, rather than inferring permanence from an empty result. It describes this bridge's publications, not the platform: an adapter that starts carrying a marker sets it True and recovers what it could not before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One reaction is shared by every turn working on the same message, and the evidence that it may be on there is shared with it. Two ways of clearing that evidence cleared more of it than they had grounds to, and both end the same way: a turn reports itself tidied up with the eyes still in plain sight and nothing left to make it try again. A refused addition now retracts the attempt it refused and nothing else. It is an answer about one request, not a report on the message, and an earlier turn's reaction can be sitting there regardless. A removal now clears the claims that existed when it was sent. Its acknowledgement can arrive after another publisher has started a turn on the same message and marked it afresh, and that mark really is there. The three sequences are covered against a real database: an addition refused after a restart, an acknowledgement overtaken by another publisher, and the same confusion inside one process, where memory is the only evidence there is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn asks for the reaction again every time it is published, so an addition refused now can be the second attempt against a mark the first one already put there — after a restart, or after a request whose answer was lost. Retracting the turn's own earlier grounds on the strength of that refusal ends where the last one did: a turn reporting itself tidied up with the eyes in plain sight. The expectation is now retracted only by the attempt that recorded it. Both sequences are covered against a real database, and both carry on to check the mark does come off once the chat will take it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Teams was not part-migrated, it was unstarted: no capability declared it published SDK sessions, so the bridge built no publisher for it and every turn went through the legacy runtime-state renderer. It now publishes the same way the other four do, and the legacy renderer no longer runs. The connector had to go first. One flattened BotConnectorError told a caller nothing it could act on, raw httpx exceptions escaped it entirely, and an accepted activity with no id came back as the empty string. Nothing above it could be truthful about a publication while that was the vocabulary. The hierarchy is now cut on certainty rather than severity: a refusal means nothing was written and the caller may throw its reservation away; a timeout, a 5xx, or an acceptance with no id mean the message may well be sitting in the channel, so they are their own kinds and the reservation survives them. An activity over Teams' size is refused here, named in bytes, rather than sent to earn a 413 nobody can read. A Teams edit is addressed to the conversation, and inside a channel post the conversation is named by the thread rather than by the message — so a redraw has to be told which thread the publication went into. update_rich gains thread_root_id on every adapter, required rather than defaulted, fed from the journal's anchor and the card row. Both were already durable, so this needed no migration; what was missing was the pass-through. The adapter deliberately does not fall back to its own _sent or _last_post maps: the first is empty after a restart and the second is the relay's guess at where an untied reply belongs. A finished status is taken out of a chat and left in place in a posts channel, because Teams replaces a deleted message with a tombstone that says less than the status it removed. A refused removal is not treated as a removal that happened: the final state is written into the message instead and the refusal is logged. An uncertain one is raised, because a status recorded as cleaned up when it was not is one that never goes. Card text is drawn with a markup that has no code span, since an Adaptive Card TextBlock renders backticks as backticks and a handle is the one literal a card exists to be answered with. Held back deliberately, and none of it designed out: expandable activity (what an update does to toggled state is undocumented and needs a tenant), Action.Submit buttons (a listener change, not an adapter one), per- conversation write serialisation, and Graph-based recovery of an uncertain publication. With no recovery, the shared publisher discloses an unconfirmed card — that is the shared default, not a Teams decision, and it is Simon's to rule on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of c09e965 found five things, all of which came back to the same habit: treating something this process worked out as if the platform had said it. A publication reference now carries its own address. post_rich returns `teams1|serviceUrl|conversationId|activityId` rather than a bare activity id, because a Teams edit is addressed to a conversation, the conversation Teams opens may not be the one that was asked for, and the service URL is regional and learned from inbound traffic. An update given one of these knows where it is editing; given a bare id it rebuilds, says so, and marks the address untrusted. A 404 completes a cleanup only at a trusted address — at a rebuilt one it may only mean the guess was wrong, so it is raised. This also fixes a chat card redrawn after a restart being rebuilt as a channel thread, because "is this a channel?" was answered from an emptied cache; it is now read off the address. 412 was reaching callers as RichContentFailed — the caller's licence to discard a reservation — for a conflict Microsoft documents as retryable. It is a short backoff now, on post, edit and delete, and the adapter serialises its own writes per conversation behind a bounded lock map. The unconfirmed-card notice was active on Teams by inheritance. Whether a platform can search for a lost publication and whether it may say in the channel that it cannot are now two flags: the second is False on the base and True on Telegram alone, which is where it was agreed. A platform that can do neither holds the reservation and logs it once, naming the card, the channel and the request. The first post of a card is on the same widening backoff the recovery search is. A refused post leaves no row, so the next cycle reserved, posted and released all over again, forever, against a destination that is saying no. No public disclosure was invented for it; that is a decision about the channel and is not this commit's to take. A failed card refresh addresses its notice to the conversation the card is in rather than to the card, so it lands somewhere on a platform where a message id is not a conversation. Also: a card whose recipient cannot be mentioned says so, and says something different from "nobody is linked", because they are; a Connector response that carries no usable id raises instead of yielding one; the size guard names both the UTF-16 metric it measures and the UTF-8 bytes it sends; and the `_claimants` docstring no longer carries a proof review disproved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A thread root reaches the Telegram adapter in two spellings. Inbound records a bare number — the topic in a forum, the message replied to everywhere else. The publication seam resolves its root through the message map, and what that stores is this platform's own reference to a message, `chat:message`. All three anchor helpers read the whole string as a number, so the composite form failed: `_publication_anchor` refused the send, which is why no Telegram permission card has ever been posted and the status was spinning on the same refusal every thirty seconds; `_anchor_kwargs` detached and lost the quote; `_topic_kwargs` silently widened a chat action to the whole forum. One resolver now answers what a root names in a given chat, and the three callers keep their own policy for one it cannot address. Its substantive judgement is that a composite reference is a reply target even in a forum: it names one message, and reading `75` out of `-100123:75` as a topic id would aim the post at whichever topic happens to hold that number. A composite belonging to another chat resolves to nothing rather than being stripped to its number — that is a confusion of destinations, not a missing quote. Once a reply anchor is possible in a forum, `_send_text` carrying it only on the first chunk would drop the tail of a long answer into General, away from the people reading the topic. So the anchor repeats on every chunk in a forum whichever kind it is: a repeated quote is noise, a split audience is not. The existing tests all passed a root of `"88"`, a form the publication seam never produces. Three of the six added here fail against the old adapter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repeating an anchor on every chunk does not hold the destination if the anchor is optional. In a forum the reply target is also the only thing naming the topic, so `allow_sending_without_reply=True` is permission to deliver into General, and that permission travels on every repeated copy of it. A target deleted before or during a run therefore scattered the answer in front of the whole group instead of the people in the conversation. The flag is now set from the chat: required in a forum, where the send fails instead, and still permissive outside one, where detaching costs the quote and not the audience. The unformatted retry in `_send_chunk` re-sends with the same kwargs, so it cannot detach either. `_topic_kwargs` was described in the previous handoff as fixed and was not: a same-chat message reference resolves to a message, so `notify_working` went on raising a chat-wide typing indicator, seen by a whole forum that did not ask for it while the people who did saw nothing. No topic id can be recovered from a message reference, and the number in one would name whichever topic happens to hold it, so the nudge is now dropped rather than misplaced. It is pure best effort, it expires in about five seconds, and the status that follows carries the real state. Outside a forum the chat is still the right destination. Seven tests: the mandatory flag on every chunk, the ordinary-chat policy left alone, a target already gone before the first chunk where the retry must keep its anchor, a target deleted mid-run which must truncate rather than scatter, the attachment caller of the same helper, and the nudge dropped in a forum but still sent in an ordinary chat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four residuals from the Teams publication-address work, plus a Telegram regression from the commit before last. The conversation write registry asked `asyncio.Lock.locked()` whether a lock was in use. That is False for the whole window between a release and the woken waiter resuming, so a lock with a writer queued behind it looks idle and can be evicted; the next writer then gets a brand-new lock and the two run side by side in one conversation. The registry now counts its own users, holder and queue alike, and `_writes_to` is the context manager that keeps the count. A notice about a card that could not be updated went to the thread root, which Teams rebuilds into an address in whatever region the process last heard from. Where the publication reference is there it names the conversation Teams confirmed, service URL included, and it now wins. The choice is the adapter's: the base port keeps today's behaviour, which is right for the other four platforms. A card posted into a carried publication reference took the conversation from the reference and the region from the channel, sending a card in one region to another and writing the wrong region into the reference that comes back. Both halves of the reference are kept, as `_relay` already did. A 404 at a rebuilt address said the status was gone. It says nothing of the kind — the address may simply be wrong — so it leaves as `RichContentFailed` with the cleanup outstanding, and the port's contract no longer claims more than a refusal arriving as the port's own error. Telegram: working out where a nudge belongs needs a getChat on a cold cache, and hoisting that lookup out of the try turned a best-effort signal into a precondition of the status that follows. A chat that cannot be read now costs the nudge and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A removal is issued on behalf of the turns expecting the mark, and clears their expectations when the platform answers. A turn in that set can ask for the mark again while the answer is in flight — the publisher allows a queued receipt to be replaced by the real turn under the same command key, and a publisher that takes the message up with no claim of its own asks afresh — and the reaction the second ask puts there is as new as any other turn's. Clearing by turn loses it, and the turn then finishes clean with the 👀 still on the message. Nothing serialises the two: a removal holds its own turn's record lock and no other's. So an expectation is now stamped with the attempt that recorded it, in memory and in the journal, and a removal clears a holder only while it still carries the ask the removal went out against. A refusal rolls its own attempt back to what stood before it rather than dropping the expectation outright, which is the same rule read from the other end: an answer speaks for the ask that provoked it and for no other. An expectation written before the asks were stamped carries no stamp and is named by the empty one, until the turn asks again. The regression drives the window through the real publisher and a real database: a queued receipt claims the mark, another turn ends last and its removal is answered late, and the queued command becomes its real turn and marks afresh in the gap. It fails without the fix with the mark visible and the turn reporting a clean finish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A channel that was deleted, or that the bot has been put out of, refuses the post every time. The widening wait bounds how often that costs a reservation and a released handle, but it never ends it: the post is tried again every ten minutes for as long as the request is open, and the session it belongs to is an unfinished publication throughout, so every cycle reports the same failure over whatever is new. Once the wait has stretched as far as it goes the card is given up on, with one error-level record naming the request and saying it can still be answered in Console. Nothing is said in the channel — there is no reachable channel to say it in, and this does not choose somewhere else to say it. The giving up is this process's, not the row's: the reservation went back with the refusal, so there is nothing to stamp, and a restart is entitled to try again. `CardRefused` separates the platform's own refusal from the other ways a card comes to be unposted, so that bound applies to the destination answering and not, say, to a card that turns out to be posted already. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Telegram status used to be deleted when the turn ended, on the reasoning that a chat is the conversation itself and a permanent "Worked for 12s" is clutter. That left a reader scrolling back with nothing: no sign the turn ran, no duration, and no way into the Console. The status now stays, edited to its final state, and it is compact for the reason it used to be deleted. The tool log Telegram folded into an expandable quotation is gone, and so is the "Now:/Last:" line naming the tool of the moment — `turn_status` takes `current_tool` and Telegram declines it. What is left is the state, the duration, one Console link, the agent's name (a bot has no other identity) and, when a call failed or was declined, the tally saying so. With nothing retired there is no retirement to remember, so the delete path and its bookkeeping come out with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn ending at the channel root or in a DM took its status down; only a thread kept one. The reasoning was that outside a thread the status was just the thing saying work was happening — but the channel root is where most turns are published, so in practice the outcome, the duration and the link to the session were exactly where a reader scrolling back could not find them. Now nothing is taken down, which is what a request card already did. With no retirement there is no retired set to consult, and no half-deleted case to degrade into, so `_retire_rich`, `_is_flat`, `_turn_has_ended` and the ref bookkeeping come out with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A posts channel already kept it, because Teams leaves "This message has been deleted." behind and that is worse than the line it replaces. A chat, a group chat and a chat-layout channel deleted it, on the reasoning that a bot's own message goes from those without trace and a finished status is clutter. What went with it was the only account of the turn a reader scrolling back could find: that it ran, how long it took, and the link to open it. Both layouts now end the same way, edited in place. The layout question only ever decided how to retire, so `_retires` and `_retire_rich` go, and with them the degraded paths for a deletion refused, a deletion timed out, and a message already gone — none of which can arise when nothing is deleted. A Gone on the edit is still reported: it is a `BotConnectorRefused`, which `_edit_rich` turns into `RichContentFailed`. The shared descriptions of what a status becomes at the end of a turn are updated to match, on every platform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Adaptive Card TextBlock renders Markdown, where a lone newline is whitespace, so a multi-line body arrived as a run-on sentence. Doubling every lone newline fixed that by turning each line into its own paragraph: a six-line permission card read as six paragraphs, and the blank line between "1." and "2." also made Markdown render the options as a loose list, gapping them again. The body is now split into consecutive TextBlocks, a line to a block, carrying spacing "None" where a line simply follows the one above and "Small" where the body itself left a blank line. Spacing is a schema primitive with defined values in the host config rather than a guess at a Markdown renderer's newline handling. Consecutive list items stay in one block so the list keeps its numbering. Every agent message on Teams shares the builder, so ordinary prose gets the same treatment. The admin message is the one plain-text seam with no card to put blocks in, and keeps the doubling. Nothing was removed from a card: heading, handle, title, detail, numbered options, reply fallback and settled outcome are all present, in order. fallbackText and the activity summary now carry the body as written, which is what toasts and mobile read. Needs a Teams client check in both layouts, open and settled — the tests can only pin the JSON. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The removal of a granted card ran after the redraw, which is only safe while the card is certainly there. It is not: a deletion whose acknowledgement was lost, or a process that died before the timestamp committed, leaves a row still owing a removal and no message at the address. A restart has an empty redraw guard, so the first thing it does is edit that message. The edit fails, the "card could not be updated" fallback posts a notice about a card nobody can see, and it re-raises before the second delete — the one that would have found the address empty and settled it — is ever reached. Every restart says it again, and the row never catches up. Ask the platform first. A removal that succeeds leaves nothing to draw, so the cycle moves on; a removal refused or throttled falls through to the settled draw exactly as before, which is the card a failure is meant to leave behind. Recovery keeps its unconditional draw through a flag rather than the redraw gate: the gate was told about the post that went unconfirmed, so at the same revision and state it reads a message found by its handle as one already drawn. The fake that hid this now refuses edits to a message it has deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requested behaviour change: a card answered "Deny" is removed from the channel too. The reasoning that kept it — the channel holding the only readable record of a refusal — does not survive contact with where that record actually lives: the request, its decision and the person who made it are in the session, in Console and in the row, which outlives the card either way. What is left behind on a refusal is a settled message still occupying a channel. So the predicate stops asking which way the answer went. `granted` becomes `decided`, and `GRANTS` goes with it: what matters is that the host confirmed a person chose an option the card actually offered. An answer naming an option the request never had, an approval settled with no result, a questions result against an approval — each is still unreadable, and an unreadable answer still leaves a card someone can read by hand. So does a request that ended with no answer at all: expired, withdrawn, or reported as a provider error. `removes_approved_cards` is renamed `removes_answered_cards` to match, and the two test modules with "granted"/"approved" in their names are renamed rather than left describing a rule that no longer exists. Also corrects the `removed_at` comment in models.py, which still described the mark being written before the platform was asked and cleared on refusal — the ordering that was fixed two commits ago. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the card on any option the request offered went wider than the change that was asked for. The requested amendment was that a refusal removes its card as a grant does; `cancel` is a fourth decision and the contract has always held it apart from `decline`, so extending the rule to it was an unrequested behaviour change hidden inside a requested one. `DECISIONS` names the three that answer the question a permission card asks. Cancelling stops the operation instead of permitting or refusing it, and the card it leaves is the channel's only record of where a run was halted. Widening the set later is one line if that is what is wanted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The publication webhook deletes what it sent, so no Manage Messages permission is involved and none is in the documented install. A DM card has no webhook behind it and is deleted as the bot's own message. Only a rate limit survives as itself. A refusal, a server error and a request that never came back all become RemovalFailed, because the caller does the same thing with all three: keep the settled card, record nothing, and ask again later. The uncertainty an uncertain send has to preserve does not arise, since asking again about a deletion that did land is answered with "already gone" — which is logged rather than raised, and is how a lost acknowledgement settles itself. A reference that is not two snowflakes is refused before the channel is resolved, rather than partway through by an int() that happens to raise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bot deletes its own messages in a group as an ordinary member, and in a broadcast channel under the Delete Messages right the install already asks for. The limit is time: after 48 hours Telegram refuses, and says so. That refusal is raised, so the card stays settled and readable rather than being recorded as gone; a card answered inside two days, which is every card anybody is waiting on, comes back. Two BadRequests that only their text tells apart: "message to delete not found" is the absence the caller asked for and is logged, and "message can't be deleted" is a real failure. The house pattern for reading a BadRequest by its text is already used for "not modified" on the edit path. A rate limit is charged to the bot rather than to the message, so a throttled deletion records the chat-wide quiet period the redraws share, and a deletion is not sent into a wait that is already running. Unlike the edit path, a reference with no chat is refused rather than completed from the channel argument: an edit that lands wrong rewrites one of our own messages or is refused, while a deletion is neither reversible nor confined to messages we posted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleted as the admin: the card was posted by an agent's bot and the reference does not say which, and the admin is the account that may delete a post it did not write. A 404 means nothing remains at the address, which is what the caller asked for, so it is logged and returned rather than raised; a 429 keeps the wait Mattermost named; everything else is owed and asked about again. Clients with the channel open see Mattermost's own "(message deleted)" placeholder until the next load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addressed through the publication reference — service URL, conversation and activity, all three as Teams confirmed them — rather than through the map a restart empties. That is also what makes a 404 readable: gone at an address Teams issued means gone, while gone at an address rebuilt from the channel is as likely to be the address, and is owed rather than recorded. A 412 is a wait for the same reason it is on a redraw. Gives `_Publication.trusted` its first reader. A posts-layout channel keeps "This message has been deleted." where the card was; a chat and a threads-layout channel take it away entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four docstrings and two test names still claimed nothing a bridge publishes is ever taken down. That is now true only of a status: an answered request card is removed, on every platform, through remove_publication and never through a redraw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Discord answers "Unknown Webhook" with the same 404 as "Unknown Message", and the publication webhook is looked up by name and created when none is found — so a webhook deleted in the channel's settings is replaced by one that sent none of the cards already posted, and answers 404 about every one of them while they sit on the screen. Reading either as absence stamps removed_at on a card that is still there and nothing looks at the row again. Resolve the channel and the webhook outside the deletion's error handling, restrict the absence reading to code 10008, and confirm it through the channel route, which answers about the message. Teams named no wait on a 412 or on a 429 without Retry-After, and the synthetic interval substituted for them came back shorter than the interval the cleanup had grown to. The caller takes a named wait as authoritative and drops its own, so a channel refusing persistently was retried hardest. Both are now owed like any other failure and the growing backoff applies. Drop the claim that every awaited card is answered inside Telegram's 48 hours; nothing here bounds how long a card stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card's options become Action.Execute buttons in an ActionSet, which is the one card action that reaches the bot and can answer with something only the presser sees. A press posts nothing into the conversation; a refusal comes back as the invoke's own response and stays with the person who made it. An Action.Submit would have posted the refusal where everybody reading the post could see whose answer failed. Only a card that carries buttons asks for schema 1.5. A client too old for the universal action model renders the whole card as its fallback text, so the version is raised where there is something to gain by it and nowhere else, and the buttons themselves fall back to `drop`. The body still lists every option in full, unlike Telegram's, because those buttons are exactly what an old client drops — and a card whose options only ever lived on them would drop the question with them. Typing still answers it, by the same numbers the buttons carry. What travels in a button is the card's opaque token and the option's position, and nothing else: no option id, no actor, no label. Who pressed comes from the activity, and which card comes from the conversation, region and replyToId the activity names, so the reference is the one the request was stored under rather than one rebuilt from what this process last learned. A press on a card that outlived a restart resolves the same way. Controls are absent wherever a press cannot land: a status, a settled card, one that says it cannot be answered here, one whose decision text was cut, and a bridge with no interaction handler to route a press to. A press that lands is answered with nothing at all — the card's own redraw is what says the answer was taken, and saying so here would be saying it before the redraw that proves it. Owed live check: the private-response behaviour is read from Microsoft's documented invoke contract, not observed. A transport prototype on a real tenant still has to establish that a press submits invisibly and that a refusal is visible to the presser alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A request card posted to Discord now carries one button per option, and a press answers it the same way typing the number does — resolved against the stored card rather than against anything held in memory, so a press works after a restart. Two ownership facts shape this. Discord only lets an application-owned webhook carry interactive components, and the publication webhook is adopted by name, so it may be someone else's. The creator on the resolved webhook is the probe; a webhook the bridge minted is its own by construction. Where it is not ours the card is posted without buttons and a warning names the remedy, rather than switching to a different webhook: a webhook may only edit and delete what it sent, so swapping would strand every card already posted through it. A DM card is the bot's own message and has no webhook to own. Presses arrive as gateway interactions registered alongside the command tree, since the tree is handed application commands only. The press is acknowledged with a deferred message update before any Switch work — invisible, because the card's own redraw is what says an answer was taken — and a refusal reaches the presser alone through a follow-up, carried out of the shared inbound path by a context variable that `tell_actor` writes to. A typed answer has no press to follow up and still lands in the card's thread. The buttons are rebuilt on every redraw and passed on every edit, `None` included, so a settled or unanswerable card stops offering a press without anything having to remember it once had one. Unlike Teams, an option a button says in full is dropped from the body: every Discord client renders components, so there is no older client to keep the list for. An option the button had to cut stays whole in the text. More options than Discord's five-by-five grid holds means no buttons at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mattermost keeps an action's context confidential and hands it back to the integration untouched, and documents it as the place to put something that proves a callback came from the server rather than from whoever found the URL. This is that proof, and the reading of it. What travels is a signature, not the key. A context that leaks — through a bug, a backup, a database dump — then hands over one card's button rather than the means to forge every card's. The card's token rides along as the subject of the press, never as its credential: it says which request is being answered and nothing about who may answer it, and the actor is read from the body Mattermost fills in. The key is derived from the server secret and the bridge's id rather than stored on the bridge. A stored secret would have to be minted when a bridge is registered, leaving every Mattermost bridge that predates this unable to carry a button until somebody edited its configuration by hand, and would add a second secret to keep and rotate. Deriving separates one bridge's signature from another's, and rotating the server secret rotates it — which invalidates buttons already on the channel, so that case is refused by name in the log rather than silently. The context is read as strictly as it is written: exactly the three signed fields and nothing beside them, since the signature cannot vouch for a field it does not cover. No route serves this yet. Where the callback is hosted is an open deployment question; nothing here depends on the answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Almost every platform Switch bridges to is dialled out to, and nothing has to reach Switch for it to work. A Mattermost button press is the exception: the Mattermost server delivers it by HTTP. The listener is owned by the lifecycle service rather than by an adapter, because the port belongs to the process and not to a bridge. Two Mattermost bridges — two tenants, or a test server beside a real one — are ordinary, and a listener each would be a port and an ingress rule each. Each bridge is routed by type and id, so a second platform needing callbacks is a sibling rather than a collision. It gets a socket of its own rather than a route on the agent API, which also carries the MCP server and the operator dashboard: what an operator has to expose for a button should be callbacks and nothing else. Nothing binds until a bridge asks to be served, so a deployment with no callback address anywhere opens no port at all, and the bind outlives any one bridge so a restart cannot unbind the port under its neighbours. A bridge that is not running answers 404 rather than refusing the connection, which Mattermost would retry into. Each bridge is handed its place already bound to it, along with the key it authenticates its own presses with. The key moves here from the Mattermost module so that no adapter ever holds the server secret it is derived from, and gains the bridge type so two bridges sharing an id across platforms do not share a key. A verified press goes the whole way: the presser comes from the body, which the server fills in and the button cannot, and the card and option come from the signed context. A refusal raised while the answer is judged is carried back out as `ephemeral_text`, the one reply Mattermost shows to the presser alone, instead of being posted where the channel would read it. A presser the server cannot name is refused rather than attributed to their raw id — the id is what authority is judged on, but the handle is what a participant is created under, and a lookup failure must not become somebody's permanent name in the room. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things have to be true for a button press to reach Switch, and none of them is code. The Mattermost server has to have an address for Switch. That is `callback_base_url` on the bridge, and it is a third address unrelated to the two already there: those are routes to Mattermost, this is the route back, and between two servers it is frequently nothing like the route a browser takes. Both local stacks now set it — the service name under the standalone compose, where switch-core is a container, and the host under the dev compose, where it is not. Mattermost has to be allowed to use it. It refuses outbound integration requests to private addresses unless the host is allowlisted, and the symptom otherwise is a press that does nothing with an error only in the Mattermost server log. Both bundled Mattermosts now allow the address the seeder gives them. And an operator has to know the rest: that the listener is a port of its own and not public ingress, that TLS is a proxy's job as it is for Teams, and that rotating the server secret invalidates the buttons on cards already posted while leaving those cards answerable by typing. The seeder skips a bridge that is already registered, so an existing stack keeps working without buttons until `callback_base_url` is added by hand. The Helm chart does not publish the port yet; the setup page says so rather than implying it works there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An edit replaces a message's whole blocks array, so the client redraws the plan block from scratch and closes any step the reader had expanded. That is why the clock lived in a message of its own: at a redraw every five seconds, nothing stayed open long enough to read. chat.appendStream replaces nothing. A task_update carrying an id Slack already holds merges into that card, and a plan_update moves the header without touching the cards, so the two messages become one, the clock ticks in its header, and an expanded step stays expanded. Verified against the live API before it was built: in-place card mutation, and the undocumented plain-string `details` a chunk requires where the task_card block wants rich_text. Threads with no asker, no workspace id or no thread at all fall back to an ordinary post, with a warning naming the missing piece. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bridges configured for callbacks start in tasks of their own, so they reach the first bind together: both found nothing bound, both tried, and the loser failed startup on a port its own neighbour had just taken. The bind is now serialised, and a bind that fails cleans up the runner it set up rather than leaking it. A bridge also registered itself before binding, so a failed bind left the door claiming to serve a bridge that never came up. Bind first, register after: a press in the gap is answered as not running, which is what it is. The other half is a bridge that dies in its own task. Nothing calls stop for it, so its place on the listener stayed registered and went on handing presses to an adapter that was no longer running. The crash cleanup now withdraws the endpoint with the rest. Both were reachable only once a card carries buttons, which is why they are fixed before that lands rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A card's options become message-attachment actions in the post's props, one per option, numbered the way the body numbers them and the way a typed answer names them. Each carries its own signed context addressed to this bridge's place on the shared callback listener, so a press cannot be retargeted at another option by anything a client can see. The body still prints every option. Mattermost documents no budget for a button's label, so there is no width at which an option could be called fully shown by the control. Buttons come off a card the moment it stops being pressable, because every redraw builds the actions again. That makes props part of a card's edit, and a patch replaces props wholesale — on the server version deployed locally nothing re-applies the identity props it set when the post was made. So a redraw reads the post back and merges, rather than writing the props Switch knows about and silently dropping `from_bot` off the card. A bridge that draws no buttons at all does neither, and behaves exactly as it did before. Action ids are written rather than left to the server, which mints them only on the create path. A card is redrawn many times, and an id that changed under a reader would remount the control they were mid-press on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seeder registered a fresh bridge with one and left an existing bridge alone, which meant every deployment that predates callbacks — including every developer's — kept drawing cards with no buttons and no way to say why. The dashboard is no help: its registration form is generated from the connection schema and does offer the field, but there is no form for editing a connection afterwards, only the two toggles. So the only cure was a hand-written API call. The seeder now sends the address on every run, the way it already adopts the default-bridge invariant. Sent rather than compared because a bridge's config is not readable back — it carries the admin password, so no endpoint returns it — and the patch is merged over what is stored, so nothing else has to be restated. The setup page picks up the rest: the API call for an operator who is not running the seeder, that a Mattermost container created before the allowlist landed has to be recreated rather than restarted, and what a reader actually sees on a card now that one carries buttons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A streamed activity message grew without bound and repeated itself. Two defects, one code path. The Console link went out on a `task_update` that was re-sent whenever the card moved. `details` on a `task_update` *appends* to what the card already holds rather than replacing it, so the link accumulated: a reader watching a long turn saw "Open in Console app" nine times. Measured against the live API, not inferred. The link now lives on one card of its own, sent once. The steps were `task_update` chunks too, and a stream cannot take a card back. Past fifty Slack silently stored no more, so the header disclosed a count of steps nobody could ever see. They move into ordinary `plan` blocks carried inside the stream, addressed by `block_id` and replaced whole. Two of those blocks rotate through fifty-step pages, so the newest hundred steps are always on screen and the message is the same three blocks at step 5 and at step 230. What fell off the front is said in the older page's title, which is the line a reader sees with the block collapsed. Boundaries are fixed rather than a sliding window: a step never moves between pages once it has landed, so a settled page is not rewritten and a card the reader opened is still the card they opened. Checked against the live API over 230 steps: 231 appends accepted, the finished message holds three blocks, and the link appears once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes to the streamed turn, all in the same path. The Console link was being dropped from the session card whenever the card serialised past 256 characters, which every real session url does: the raw switchdash form runs to 186 and the gateway redirect Slack actually gets to 205. The 256 came from a refusal seen once and never searched for. Bisecting the live API found no boundary below 8000 characters for a task_update's details, its title, its id, a plan_update title, or two cards in one append, and a 213-character production-shaped url is accepted and stored as a link. So the budget and the trimming built on it are gone, and the whole url goes in the card. What the message had to leave out was written into the older section's title. It is now one cumulative line of its own above both sections, naming the whole missing range — Steps 1-150 no longer shown — rather than a count a reader has to add up. That line has to be created before the sections it describes, because Slack fixes a block where it was first written, so the top of the three block ids starts as the first page of steps and is replaced in place by the line once there is something to disclose. The adapter was remembering the session card it last drew rather than the one it sent, so a publish that arrived without the url erased the record of the link and the next one appended the same link to a card already carrying it. Verified against the live API over 230 steps in the adapter's own chunk order: four blocks in the finished message, the disclosure line above the two pages, the 213-character url stored whole, and the link present exactly once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d card Two things a reader sees, both raised from a live channel. The body was listing every option underneath buttons that already showed them. The renderer has carried the seam for this since Discord and Telegram used it: an option a control shows in full loses its line. Mattermost passed no width because the server documents no cap on an action's name, which mistook the constraint — what bounds a label is what still reads as a button, not what the API will accept. The suppression is a promise that a control is carrying the option, so it holds only where one will be. The plain text of a `RichContentFailed` is drawn without controls, and a card that earns none — a form too big to show faithfully — is drawn a second time, because the pass that discovered it had already dropped the lines. An answered card is now edited down to its outcome instead of being taken back. The settled rendering already existed; Mattermost never reached it. Mattermost is alone in leaving a "(message deleted)" line behind a post removed while somebody has the channel open, which is reason enough for it alone to keep its cards — the other four platforms are untouched. The seam below the capability is kept, and the test file says why: the flag is the whole of the decision, and reversing it should not also need an implementation written again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Slack never parses The header carried the tool call — "Working… 40s · Running: Read" — which said what was happening but not where to look for it. Past fifty steps there is more than one section to open, and the header pointed at none of them. It moves onto the heading of the section holding the step instead: "Steps 51–75 · Running: Read". Said once, where it is useful, and it leaves the header to say only where the turn got to. The stream's own plan was sending its one card complete from the moment the stream opened, so the top of the message showed a check beside "Working…". Slack draws a block's glyph from the cards in it and that plan holds exactly one, so the card now carries the turn's status and turns over at the end. That is the only update it ever takes, and its shape is measured rather than guessed: an update of id and status alone stores an empty title, and one that repeats `details` appends the Console link a second time. So the adapter now works out which fields it still owes rather than deciding whether to resend the card whole. Card titles were escaped on the grounds that Slack asks for `&`, `<` and `>` escaped and that a tool call named `Ran <!here>` would notify the channel. Measured against the live API, neither holds for this field: a card title parses no mrkdwn, so a title sent escaped is stored and shown escaped, and `<!here>` is stored as the text it is rather than as a broadcast. Every `&&` in a shell command was reaching the reader as `&&`. A field that parses nothing needs nothing escaped for it, so the plan and card titles are left as written. The mrkdwn surfaces — sections, contexts, the message fallback — still escape. Checked against the live API over 230 steps: the header ends free of the tool, each section names the step it is holding, the card ends complete with its title intact and the link in it once, and a title holding `echo "x" && ls 2>/dev/null` comes back with no entities in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A card whose options are carried by buttons stops listing them in its body. The text of a `RichContentFailed` has no buttons — the caller forwards it as an ordinary message — so reporting a refusal with that same drawing asks for a choice it has stopped printing. Wherever buttons were possible the card is now drawn twice, and both drawings travel together: the post gets the one with the options suppressed, every failure gets the one without buttons. That is also the drawing the post itself gets when the card turns out to earn no controls, which folds in the second pass that was already there for the too-big form. All four failure sites take it: a refused post, a refused edit, no bot for the agent, and a connection that has gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn's activity became a single message when `separate_activity_log` went to False, but `_save_anchor` after `_edit` stayed inside the branch that draws a second one. With no log message the anchor was never persisted, so the state the message was already showing was unknown to the next process to pick the turn up, and every publisher sweep or restart re-edited a message that had not changed — collapsing an expanded plan each time. The tests that counted a status message and a tool log separately are updated to the one message they now get, including the durability suites that assert on `channel-demo:N` by position. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slack posts the per-call log into the channel beside the status. Discord showed only the status: state, what it is doing now, and a tally. The calls were rendered nowhere, so the detail existed in the code and not on the platform. The status now carries a View activity button. Pressing it opens a message only the presser can see, with the calls oldest-first, when the read was taken, a Refresh and a link into Console. Nothing is added to the channel's history, and a second reader pressing the same button gets their own copy. Three pieces make that possible. `activity_log` in the neutral renderer draws the list for any platform with somewhere to put one, against a single budget for the whole message. When the budget runs out it cuts the oldest end, and it pays for the line saying how many it cut out of the calls rather than out of the leftover space: a log that quietly showed its tail would read as a turn that only made those calls. The address of a status message now outlives the turn it is showing. The anchor a publisher keeps is a delivery reservation and is discarded the moment an ordinary turn ends, while the message stays in the channel with its button on it, so `_save_anchor` writes the address separately and the compaction keeps it. `activity_shown_at` reads a turn back from that address, re-running every check the publisher makes before it may draw one: tenant, bridge, the room the command came from, the channel that room points at now, and the agent's membership of it. Not the surface the command arrived on — a turn is published to the room whatever asked for it. Who may read it is the adapter's to decide, because only it knows who pressed. A press is authorised against the conversation the reference names, never against the one it arrived from: the initial view has those two the same, but Refresh sits on a private message with no journal row and so carries an address, and an address a presser supplied is treated as though it had been typed. A private thread is checked by membership rather than by the parent's permissions, and a refresh arriving on a public message is refused outright, since an update would rewrite that message into somebody's tool log. Whether an ephemeral message survives a client reload, and whether a component on one yields a fresh interaction token past fifteen minutes, are not documented and need a live check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_still_reads` refused a reader who had lost a channel, and checked a private thread by membership rather than by the parent's permissions, but allowed a channel outside any guild unconditionally — there were no permissions to consult, so it consulted nothing. That branch was the one place the destination check could be steered into. Every other path here treats the address as something that arrived on a press and is not evidence of anything; this one rested on the address having been generated by us, which is the inference the rest of the design refuses to make. Who may read a channel with no guild is exactly who is in it, so that is what is now asked, via `recipients` or the single `recipient`. A channel that can say neither is refused and logged, like any other destination whose audience cannot be established. This bridge does not in practice publish into real DMs — `create_dm_channel` makes a private guild channel, which `permissions_for` already answers for — so the change closes a reachable-only-by-forgery gap rather than a live one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Bring live agent status and answerable permission requests to Mattermost, Discord, Telegram and Teams, building on the Slack implementation in #394. People can follow a turn and respond from the messaging app they already use.
Key Changes
Design Decisions
Technical Notes
Stacked on #394: base
work/rich-session-activity-slack, headwork/refresh-messaging-platforms.This comparison also includes upstream commits
901d3f35(durable room follow-ups after reset/compaction) andcee01b0f(clearer automatic-session failure wording), merged via1a676117. Neither commit is in the Slack base at7906d058.Release Notes
Important
Run
just migratebefore running the updated server. This adds the unconfirmed-card notice timestamp and the upstream room-control follow-up column; the migration chain is joined in this branch.Testing
82ad1b92, the implementation author reports clean formatting, lint and mypy across 277 source files, with 1,980 session and collaboration tests passing.Remaining Before Ready for Review
Recovery redesign and queued-message interruption controls are deferred.