Skip to content

feat(bridges): render agents under their display name, defused on every platform - #330

Open
raullopez-sandbox wants to merge 6 commits into
mainfrom
feat/display-name-02-bridge-rendering
Open

feat(bridges): render agents under their display name, defused on every platform#330
raullopez-sandbox wants to merge 6 commits into
mainfrom
feat/display-name-02-bridge-rendering

Conversation

@raullopez-sandbox

@raullopez-sandbox raullopez-sandbox commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Third PR in the display-name stack. Base is #325 — review that one first.

What changes

Agents are addressed by a lowercase identifier (switchdev), and until now that
is also what humans saw. This shows the display name instead, where one is set.

Rendering lands on Slack and Discord here. The escaping lands on all five.
That split is deliberate: _ping_operator is on the base class, so the moment
this PR merges every platform inlines a display name into a message body —
including the three that do not otherwise render one yet. Leaving the escaping
to the later per-platform PRs would leave main exposed in between.

What deliberately does not change

  • clients.display_name is still the Matrix identity stamped onto every event as sender_name. Breaking this re-imports an agent's own message as a third-party human — a puppet identity gets minted and the agent appears twice.
  • Icons are still derived from the identifier.
  • The echo filters are untouched and name-independent: Slack matches on bot_id, Discord on webhook_id/author.id.
  • Mattermost's name-based check is out of scope and unaffected — it needs a PUT /bots/{id} at provisioning rather than a render change.

How

bridge_core installs an AgentPresentation resolver on the adapter, and
agent_rendering(name) returns one AgentRendering carrying the field label,
the body-escaped label, and the icon — one DB lookup per send, where the
first draft would have done two.

Slack keeps its white-background icon recolouring by overriding the pure
adapt_icon_url(raw, name) hook rather than the accessor. This matters: Slack's
old override took a name and re-queried, so a naive paired accessor would have
silently bypassed the recolour.

Escaping: a safe default, specialised by override

escape_label_for_body now defuses @ and ] with a zero-width space instead
of returning the label unchanged. Those two cover the constructs a name can use
to claim something it is not: addressing somebody, and rendering an anchor whose
destination it chose and whose visible text hides it.

The ] and not the [ — the zero-width space has to break the ]( adjacency
every Markdown dialect requires. After the [ it lands inside the visible text
and the link still matches; verified against the compiled patterns rather than
assumed.

A platform that adds no override is now safe. Before, it rendered a forged name
correctly and said nothing.

An override adds to the base rule; it does not replace it

A review flagged that Slack and Discord were still open, and its verifiers
refuted it. The finding was correct.
Probing the real pipeline:

display name platform before
@opsbot Slack @U123 *<!subteam^S999>* needs your input. real group ping
@opsbot Discord @123 **<@&4242>** needs your input. real role ping
@alice Discord @123 **<@777>** needs your input. real user ping

Neither adapter is wrong on its own. A label never has to reach the platform in
the platform's syntax: Switch's own outbound mention pass runs over the
finished body, after the label is inlined,
and turns a plain @handle into a
real mention. The defect is in the composition of two individually-correct
functions — which is why reading either one refutes it. Every verifier checked
whether markup could survive the escaper; none checked what ran afterwards.

Both now call super(). Three regression tests, each verified to fail without
the fix.

Per platform, on top of the base

  • Discord — escapes markdown and mentions, and inserts U+200B after <; Discord resolves <#…>, <t:…> and <:emoji:…> out of raw content and has no backslash escape for <. Every send also passes AllowedMentions(everyone=False), so a mass ping is refused by permission as well as defused in text.
  • Slack — escapes &, <, >. mrkdwn has no escape sequence for *, _, ~ or backtick; the docstring says so rather than implying coverage it does not have.
  • Teams — breaks <at>, which _mention_entities finds in a rendered body and pairs with a live entity. Broken syntax rather than entity escaping: an Adaptive Card is not HTML, so &lt; would be shown to the reader.
  • Field values are left unescaped on purpose — platforms render those verbatim, so escaping would show the entities to the reader.

Discord may reject a display name as a webhook username with a 400. That falls
back to the identifier and latches for the rest of the send, so a multi-call
send cannot post half under each name.

Known and disclosed, not fixed

  • Emphasis. A label's * or _ can unbalance the run around it. Cosmetic; the line held is that forged markup is closed while broken formatting is not worth an escape that would appear in every ordinary name.
  • A bare URL still auto-links. A display name is never dereferenced by the server — unlike icon_url, which is hardened precisely because the server fetches it — and an agent's owner can already have it post any link.

Verification

  • 2176 tests pass, ruff format + ruff check clean, mypy clean on 186 files. Verified on this commit in isolation, not just at the tip of the stack, since these merge one at a time.
  • Test-merged onto current main in a throwaway worktree: clean, 2195 pass. fix(slack): resolve agent mentions that crossed a workspace boundary #327's Slack change touches the inbound mention path, not the outbound one, so it does not reopen this.
  • The new tests were mutation-tested: breaking the resolver wiring, dropping icon_url, un-escaping the body label, and removing either super() call each make specific tests fail. One mutation passed, which exposed an accessor with zero production callers — since deleted.
  • Payloads were captured from the real adapters and diffed before/after, with before rebound to the actual prior implementation rather than a mock-up, and every row asserted to differ.

Also in here: 22 test assertions contained literal invisible U+200B
characters, so assert "@<U+200B>channel" in x sat directly above
assert "@channel" not in x looking like a contradiction. Converted to
\u200b escapes; the tests still pass, confirming it was purely presentational.

🤖 Generated with Claude Code

…ry platform

Agents are addressed by a lowercase identifier (`switchdev`). That name is
also what every collaboration bridge showed to humans. This renders the
presentation `display_name` instead, where one is set, without moving the
identifier anywhere it is used for routing.

Rendering lands on Slack and Discord here; Teams, Telegram and Mattermost
follow. The escaping lands on all five at once, and has to: `_ping_operator`
is on the base class, so the moment this commit exists every platform
inlines a display name into a message body, including the three that do not
otherwise render one yet. Splitting the escaping across the later commits
would leave main exposed in between.

The identifier stays load-bearing and untouched. `clients.display_name` is
still the Matrix identity stamped onto every event as `sender_name`; icons
are still derived from the identifier; and the echo filters that stop an
agent re-importing its own message are unchanged — Slack matches on
`bot_id`, Discord on `webhook_id`/`author.id`, both name-independent.
Mattermost's name-based check is out of scope here and unaffected.

Label and icon resolve together in one lookup. `bridge_core` installs an
`AgentPresentation` resolver on the adapter; `agent_rendering(name)` returns
an `AgentRendering` carrying the field label, the body-escaped label and the
icon. Slack keeps its white-background icon recolouring by overriding the
pure `adapt_icon_url` hook rather than the accessor, so the recolour applies
everywhere that posts as an agent.

`escape_label_for_body` is safe by default rather than empty by default. It
defuses `@` and `]` with a zero-width space, which covers the two constructs
a name can use to claim something it is not — addressing somebody, and
rendering an anchor whose destination it chose. The `]` and not the `[`: the
zero-width space has to break the `](` adjacency, and after the `[` it lands
inside the visible text and the link still matches. A platform that adds no
override is now safe, where before it rendered a forged name and said
nothing.

An override adds its platform's rule on top and calls up to the base one.
None replaces it, and the `@` rule is why. A platform's escaper defends
against the label carrying that platform's syntax — but Switch's own
outbound mention pass runs over the finished body, after the label is
inlined, and turns a plain `@handle` into a real mention needing no syntax
from the label at all. Empirically, before this: a display name of
`@opsbot` produced `<!subteam^S999>` on Slack and `<@&4242>` on Discord,
and `@alice` produced `<@777>`. Both adapters escaped correctly; the defect
was in the composition.

On top of that base: Discord escapes markdown and mentions and inserts
U+200B after `<`, since it resolves `<#…>`, `<t:…>` and `<:emoji:…>` out of
raw content and has no backslash escape for `<`. Every Discord send also
passes `AllowedMentions(everyone=False)`, so a mass ping is refused by
permission as well as defused in text. Slack escapes `&`, `<` and `>`;
mrkdwn has no escape for `*`, `_`, `~` or backtick, which the docstring
states rather than pretending otherwise. Teams breaks `<at>`, which
`_mention_entities` finds in a rendered body and pairs with a live entity.
Field values are left unescaped deliberately — platforms render those
verbatim.

Emphasis is deliberately not covered anywhere. A label's `*` or `_` can
unbalance the run around it; that is cosmetic, and the line held is that
forged markup is closed while broken formatting is not worth an escape that
would show up in every ordinary name.

Discord may reject a display name as a webhook username with a 400. That
falls back to the identifier and latches for the rest of the send, so a
multi-call send cannot post half under each name.

A bare URL in a display name still auto-links. Left as-is and documented: a
display name is never dereferenced by the server, unlike `icon_url`, and an
agent's owner can already have it post any link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base automatically changed from feat/display-name-01-column to feat/display-name-00-console-slug-suggestion September 2, 2026 07:38
@raullopez-sandbox
raullopez-sandbox force-pushed the feat/display-name-02-bridge-rendering branch from 6c28fe9 to 7537b0b Compare September 2, 2026 07:45
@raullopez-sandbox raullopez-sandbox changed the title feat(bridges): render agents under their display name on Slack and Discord feat(bridges): render agents under their display name, defused on every platform Sep 2, 2026
@raullopez-sandbox
raullopez-sandbox marked this pull request as ready for review September 2, 2026 08:20
Base automatically changed from feat/display-name-00-console-slug-suggestion to main September 2, 2026 09:10
raullopez-sandbox and others added 5 commits September 3, 2026 14:16
Resolved merge conflict in adapter.py by combining:
- Display name rendering from the PR branch (using agent_label_for_body)
- Error handling logic from main (detail parameter with reason/need/lead)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Resolved conflict in adapter.py by combining display name feature with
error detail feature:
- Uses agent_label_for_body to show display name
- Supports detail parameter for error reasons
- Shows warning emoji when there's an error

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The agent detail page header now shows the agent's display name as the
primary title when available, falling back to the machine name. When both
exist and differ, the machine name is shown as a subtitle in monospace.

Changes:
- Add displayName field to RemoteAgentSummary type
- Update agent page header to prioritize displayName over machine name
- Show machine name as subtitle when it differs from display name
- Update test fixtures to include displayName field

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The agent list API endpoint was not returning the display_name field,
causing the Switch Console UI to not show agent display names.

Changes:
- Add display_name field to AgentInfo schema
- Update list_agents handler to include display_name in response
- Update list_delegatable_agents to include display_name

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant