Matrix gains emoji-list backed by MSC2545 image packs: room state
im.ponies.room_emotes (any state key) plus the personal
im.ponies.user_emotes account-data pack, sticker-only entries excluded,
identifiers are literal shortcodes (Matrix reactions send the annotation
key as-is; the mxc URL is exposed alongside). Discord emoji-list results
now ride the lifecycle-owned bounded entity cache, invalidated by
GuildEmojisUpdate; the non-privileged GuildExpressions intent is added so
that dispatch actually arrives. Google Chat custom-emoji listing is
documented as unavailable: customEmojis.list requires user auth while the
plugin is a chat.bot service-account channel.
* feat(channels): custom emoji discovery via emoji-list across Discord, Slack, Telegram
Make custom emojis discoverable by the agent. The message tool's emoji
param now documents custom-emoji syntax per channel (gate-aware, only
naming emoji-list when the action is actually advertised). Discord
emoji-list defaults guildId from the current conversation and returns
reaction-ready { name, identifier, animated? } entries; Slack returns
normalized shortcodes with aliasOf. Telegram gains emoji-list backed by
one canonical allowed-reactions owner (getChat available_reactions,
custom_emoji entries preserved), numeric custom-emoji reactions, and
replaces the dead 'reaction disallow list' error advice with a bounded
sample of the chat's allowed reactions.
* test(channels): expect telegram emoji-list provider-owned read gate in plugin shape contract
* test(telegram): prove emoji-list authority chain via mock-gateway e2e
Ephemeral gateway + mock Bot API + mock OpenAI provider: current-chat
emoji-list returns normalized standard and custom_emoji identifiers with
exactly one getChat call; a delegated cross-chat request is rejected with
the conversation-binding error and zero Bot API requests reference the
foreign chat.
* fix(discord): survive malformed gateway HELLO frames
The HELLO branch dereferenced payload.d through a bare cast and replaced
only null/undefined with the 45s default, so a { op: 10, d: null } frame
threw a TypeError out of the socket message listener (no try/catch), and
a zero, negative, sub-second, or out-of-range interval reached setTimeout
unchanged, scheduling the first heartbeat immediately.
Normalize the HELLO body with the canonical record guard and accept the
interval only inside the timer-safe range, matching the QQ gateway. The
cast is removed rather than guarded.
* fix(discord): honor any heartbeat interval the gateway can schedule
Review of the first revision pointed out that the 1000 ms lower bound was
an interoperability break: a compatible gateway negotiating, say, 500 ms
would have had its interval replaced by the 45s default and then been
disconnected as unresponsive. Discord documents no lower bound.
Drop the bound to a positive timer-safe integer, so shape validation and
the timer ceiling stay while every interval this client can actually
schedule is honored. Regression rows pin 500 ms and 1 ms.
* fix(discord): reject upload-file buffer attachments with send guidance
The flat message tool schema advertises buffer for every action, so
callers reasonably infer upload-file + buffer is valid on Discord. The
Discord handler only reads filePath/path/media for upload-file and
failed with an error that never mentioned the supported path.
Scope the buffer schema description to action="send" and make Discord
upload-file reject a buffer-only call with guidance to use send instead
of duplicating the send buffer pipeline under a second action.
* review: keep the shared buffer description channel-neutral
iMessage's upload-file deliberately consumes a buffer, so scoping the
shared schema text to send would teach models away from a supported
path. Keep the guidance owner-local: Discord's rejection carries it.
* fix(discord,msteams): upload-file silently drops a caption
Both channels accept `media` as an alias for the uploaded file, so a
send-shaped call reaches `upload-file` carrying its text in `caption`.
Discord read only `message`/`content` and MS Teams read only
`text`/`content`/`message`, so that text was dropped and the file landed
with no message at all.
Accept `caption` as the lowest-precedence alias on both channels, matching
the WhatsApp and Slack upload paths. An explicitly empty higher-precedence
value still wins, so existing callers keep their behavior. The MS Teams
reader is scoped to `upload-file` because the other actions carry their own
text field.
* refactor: narrow this PR to the Discord channel
Microsoft Teams keeps the same defect and the same fix shape, but a Teams
after-fix delivery or activity trace is not reachable without a live tenant,
so that half is deferred to its own change rather than blocking the Discord
repair that already has a production-boundary trace.
* fix(channels): preserve gateway context for inbound turns
* fix(plugin-sdk): type bound channel reply dispatcher
* fix(channels): carry bound reply dispatchers
* fix(channels): keep reply carrier internal
* fix(auto-reply): consolidate dispatcher type imports
* fix(channels): keep reply dispatch typing internal
* fix(channels): derive inbound reply dispatcher types from turn plan
Keep ChannelRuntimeSurface identical to main: adapters read the bound
dispatchReplyFromConfig through the existing PluginRuntime["channel"]
wiring type and derive its type from the public ChannelInboundTurnPlan
contract, so the compatibility surface no longer grows a Gateway-bound
reply member.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(channels): hoist bound channel runtime casts for assertion ratchet
The assertion-safety ratchet collects SAFETY comments with a plain
ts.Scanner pass, which desyncs at the first template literal with a
substitution — comments after that point in a file are invisible, so
deep casts in monitor files cannot be SAFETY-covered. Hoist one shared
cast per call-site scope (absorbing the pre-existing buildContext
casts) and retype Discord's internal channelRuntime chain as
PluginRuntime["channel"] so dispatch reads need no assertion.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(channels): verify gateway dispatcher ownership
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
* test(codex): assert sequenced node process notifications
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
* docs(changelog): preserve release-owned root changelog
Channel Gateway tools preserve the owning dispatcher for Telegram, Discord, iMessage, Signal, and WhatsApp so terminal tools remain available. Thanks @VACInc.
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
* test(codex): avoid shadowing process notification bindings
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
---------
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Centralize untrusted audio transcript framing across channel plugins and keep Discord voice transcripts out of typed command provenance.
Co-authored-by: xialonglee <li.xialong@xydigit.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* feat(discord): auto-join occupied voice rooms
Add opt-in voice.autoJoin[].whenOccupied residency so Discord voice bots join for the first human and leave when the room becomes empty while preserving existing always-on, manual, transcript, and follow-user behavior.\n\nCloses #125973
* test(discord): isolate process runtime mocks
Use stable hoisted runtime-env mocks so isolate=false Discord test ordering cannot turn sleepWithAbort back into an unmocked function.
* fix(discord): defer unknown voice occupancy
Treat memberless voice states as unresolved instead of human so bot-only rooms cannot trigger occupancy-managed auto-join. Add cache-to-listener and manager regressions.\n\nCloses #125973
* test: isolate shared module mocks
Replace ineffective non-isolated module spies with stable hoisted mocks and a child-process SQLite connection-reuse probe so gateway and Discord shards are order-independent.
* test(gateway): inline connection reuse probe
Keep the child-process SQLite ownership probe in its owning Vitest file so Knip sees the full test surface without weakening process isolation or the original order regression.
* feat(gateway): proxy channel conversation avatars
* feat(discord): capture conversation avatars
* feat(slack): capture DM sender avatars
* test(discord): bind guild avatar mock
* feat(ui): render channel conversation avatars
* fix(ui): align sidebar owner fixtures
* fix(gateway): version channel-avatar routes by media revision
A stable per-session URL let AuthenticatedAvatarRouteLoader's blob and
sticky-404 caches pin a mounted row to a stale or blank avatar after the
backing media changed. Append an opaque digest of the media reference so
replacement and 404-recovery change the route identity.
* test(ui): align sidebar owner facet
* fix(ui): keep owner chip until channel avatar loads
A session with a channelAvatarUrl suppressed its owner chip even while the
blob was loading, auth was not ready, or the route 404ed, leaving an empty
lead slot. The chip now rides as fallback content inside the avatar element
and yields only to a usable image. Covers 404 and auth-pending states;
avatar rows keep renderedOwnerId unset so an owner-viewer stays visible in
the facepile.
* perf(ui): keep channel avatar fallback within budget
* perf(ui): lazy-load the channel avatar element
The avatar element and its authenticated blob loader rode the startup
bundle through session-leading-indicator, pushing startup JS 51 B over the
CI gzip budget. Channel avatars are not startup-critical: register the
element on the first avatar row; the owner-chip fallback covers the
one-time upgrade window. Startup JS returns ~1 KiB under the ceiling.
* build(ui): raise startup baseline for channel avatars
CI-measured startup JS is 344379 B against a 343289 B baseline (+1090 B).
The avatar element and blob loader are code-split out of startup (previous
commit); the residual is the sidebar lead-slot render branch and row
plumbing, which cannot be deferred. Baseline updated via
check-control-ui-performance --update-baseline with CI bytes per the
script's contract; well inside the 4096 B ratchet step and 358400 B
ceiling.
* fix(tts): normalize streamed speech and report fallbacks
Streamed Discord voice now strips Markdown and raw link destinations like buffered synthesis.
Record the first failed streaming provider and reason code before successful file fallback.
* test(ui): stabilize reconnect retry timing
Drive the retry deterministically by advancing the exact 100 ms fake timer while preserving the ordering and outbox proof. Repairs CI run 32110130614 job 95627654678.
* fix(discord): ignore expected TTS stream skips
Address the ClawSweeper finding by retaining warning metadata only for actual failed attempts. Expected unsupported-for-streaming skips stay silent while real streaming failures still warn after successful file fallback.
* test(ui): settle interaction state before assertions
Repairs CI run 32117859632 by invoking the markdown table handler directly with a targeted event and waiting for durable IndexedDB composer state before navigation or page close.
* test: remove incidental UI and process identities
CI run 32120793258 exposed two owner-specific test assumptions.
Preserve the child-session hover visibility and pointer assertions, then focus and activate its menu with Enter so an unrelated delayed hovercard cannot intercept the pointer.
Assert the exact owned descendant PID is dead when QA cleanup completes instead of checking a globally reusable PGID.
* test(ui): stabilize connected send retry timing
CI run 32125741781 exposed the wall-clock race in the connected retry test.
Reuse the canonical realtime audio duration and OpenAI-compatible wire-format helpers while removing redundant state transitions and truncate payload construction. The production diff is net-negative.
Provider interruption policy and lifecycle ownership stay local to each provider; this does not introduce shared mutable assistant tracking or change behavior.